summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main/java
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 09:22:09 +0000
commit43a97878ce14b72f0981164f87f2e35e14151312 (patch)
tree620249daf56c0258faa40cbdcf9cfba06de2a846 /mobile/android/geckoview/src/main/java
parentInitial commit. (diff)
downloadfirefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz
firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/geckoview/src/main/java')
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java414
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java181
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java537
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java96
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java588
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java1607
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java200
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java456
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java801
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java413
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java71
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java273
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java161
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java985
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java106
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java137
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java186
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java225
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java24
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java230
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java220
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java102
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java25
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java18
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java56
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java72
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java26
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java147
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java323
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java71
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java77
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java140
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java93
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java38
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java59
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java62
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java104
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java710
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java508
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java178
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java166
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java119
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java93
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java170
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java1110
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java340
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java518
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java39
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java771
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java50
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java43
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java45
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java490
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java250
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java298
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java79
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java254
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java163
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java252
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java232
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java101
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java154
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java50
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java39
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java440
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java12
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java190
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java927
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java40
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java213
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java63
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java74
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java613
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java141
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java21
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java134
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java58
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java72
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java1152
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java255
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java46
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java12
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java88
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java334
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java117
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java168
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java149
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java145
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja38
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java170
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java16
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java1445
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java1229
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java692
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java15
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java133
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java1656
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java203
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java385
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java483
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java2616
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java172
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java829
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java205
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java1072
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java1050
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java1305
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java6760
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java106
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java690
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java42
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java1122
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java189
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java54
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java639
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java60
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java244
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java942
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java182
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java646
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java266
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java171
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java160
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java1331
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java131
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java463
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java405
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java572
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java2750
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java1359
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java117
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java233
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java29
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java165
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java62
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java180
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java248
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java380
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java183
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md1299
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java40
153 files changed, 58195 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java
new file mode 100644
index 0000000000..1dcd375d45
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java
@@ -0,0 +1,414 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.util.SparseArray;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Timer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class AndroidGamepadManager {
+ // This is completely arbitrary.
+ private static final float TRIGGER_PRESSED_THRESHOLD = 0.25f;
+ private static final long POLL_TIMER_PERIOD = 1000; // milliseconds
+
+ private static enum Axis {
+ X(MotionEvent.AXIS_X),
+ Y(MotionEvent.AXIS_Y),
+ Z(MotionEvent.AXIS_Z),
+ RZ(MotionEvent.AXIS_RZ);
+
+ public final int axis;
+
+ private Axis(final int axis) {
+ this.axis = axis;
+ }
+ }
+
+ // A list of gamepad button mappings. Axes are determined at
+ // runtime, as they vary by Android version.
+ private static enum Trigger {
+ Left(6),
+ Right(7);
+
+ public final int button;
+
+ private Trigger(final int button) {
+ this.button = button;
+ }
+ }
+
+ private static final int FIRST_DPAD_BUTTON = 12;
+ // A list of axis number, gamepad button mappings for negative, positive.
+ // Button mappings are added to FIRST_DPAD_BUTTON.
+ private static enum DpadAxis {
+ UpDown(MotionEvent.AXIS_HAT_Y, 0, 1),
+ LeftRight(MotionEvent.AXIS_HAT_X, 2, 3);
+
+ public final int axis;
+ public final int negativeButton;
+ public final int positiveButton;
+
+ private DpadAxis(final int axis, final int negativeButton, final int positiveButton) {
+ this.axis = axis;
+ this.negativeButton = negativeButton;
+ this.positiveButton = positiveButton;
+ }
+ }
+
+ private static enum Button {
+ A(KeyEvent.KEYCODE_BUTTON_A),
+ B(KeyEvent.KEYCODE_BUTTON_B),
+ X(KeyEvent.KEYCODE_BUTTON_X),
+ Y(KeyEvent.KEYCODE_BUTTON_Y),
+ L1(KeyEvent.KEYCODE_BUTTON_L1),
+ R1(KeyEvent.KEYCODE_BUTTON_R1),
+ L2(KeyEvent.KEYCODE_BUTTON_L2),
+ R2(KeyEvent.KEYCODE_BUTTON_R2),
+ SELECT(KeyEvent.KEYCODE_BUTTON_SELECT),
+ START(KeyEvent.KEYCODE_BUTTON_START),
+ THUMBL(KeyEvent.KEYCODE_BUTTON_THUMBL),
+ THUMBR(KeyEvent.KEYCODE_BUTTON_THUMBR),
+ DPAD_UP(KeyEvent.KEYCODE_DPAD_UP),
+ DPAD_DOWN(KeyEvent.KEYCODE_DPAD_DOWN),
+ DPAD_LEFT(KeyEvent.KEYCODE_DPAD_LEFT),
+ DPAD_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT);
+
+ public final int button;
+
+ private Button(final int button) {
+ this.button = button;
+ }
+ }
+
+ private static class Gamepad {
+ // ID from GamepadService
+ public byte[] handle;
+ // Retain axis state so we can determine changes.
+ public float axes[];
+ public boolean dpad[];
+ public int triggerAxes[];
+ public float triggers[];
+
+ public Gamepad(final byte[] handle, final int deviceId) {
+ this.handle = handle;
+ axes = new float[Axis.values().length];
+ dpad = new boolean[4];
+ triggers = new float[2];
+
+ final InputDevice device = InputDevice.getDevice(deviceId);
+ if (device != null) {
+ // LTRIGGER/RTRIGGER don't seem to be exposed on older
+ // versions of Android.
+ if (device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null
+ && device.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null) {
+ triggerAxes = new int[] {MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER};
+ } else if (device.getMotionRange(MotionEvent.AXIS_BRAKE) != null
+ && device.getMotionRange(MotionEvent.AXIS_GAS) != null) {
+ triggerAxes = new int[] {MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS};
+ } else {
+ triggerAxes = null;
+ }
+ }
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private static native byte[] nativeAddGamepad();
+
+ @WrapForJNI(calledFrom = "ui")
+ private static native void nativeRemoveGamepad(byte[] aGamepadHandle);
+
+ @WrapForJNI(calledFrom = "ui")
+ private static native void onButtonChange(
+ byte[] aGamepadHandle, int aButton, boolean aPressed, float aValue);
+
+ @WrapForJNI(calledFrom = "ui")
+ private static native void onAxisChange(byte[] aGamepadHandle, boolean[] aValid, float[] aValues);
+
+ private static boolean sStarted;
+ private static final SparseArray<Gamepad> sGamepads = new SparseArray<>();
+ private static final SparseArray<List<KeyEvent>> sPendingGamepads = new SparseArray<>();
+ private static InputManager.InputDeviceListener sListener;
+ private static Timer sPollTimer;
+
+ private AndroidGamepadManager() {}
+
+ @WrapForJNI
+ private static void start(final Context context) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ doStart(context);
+ }
+ });
+ }
+
+ /* package */ static void doStart(final Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ scanForGamepads();
+ addDeviceListener(context);
+ sStarted = true;
+ }
+ }
+
+ @WrapForJNI
+ private static void stop(final Context context) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ doStop(context);
+ }
+ });
+ }
+
+ /* package */ static void doStop(final Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (sStarted) {
+ removeDeviceListener(context);
+ sPendingGamepads.clear();
+ sGamepads.clear();
+ sStarted = false;
+ }
+ }
+
+ /* package */ static void handleGamepadAdded(final int deviceId, final byte[] gamepadHandle) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return;
+ }
+
+ final List<KeyEvent> pending = sPendingGamepads.get(deviceId);
+ if (pending == null) {
+ removeGamepad(deviceId);
+ return;
+ }
+
+ sPendingGamepads.remove(deviceId);
+ sGamepads.put(deviceId, new Gamepad(gamepadHandle, deviceId));
+ // Handle queued KeyEvents
+ for (final KeyEvent ev : pending) {
+ handleKeyEvent(ev);
+ }
+ }
+
+ private static float sDeadZoneThresholdOverride = 1e-2f;
+
+ private static boolean isValueInDeadZone(final MotionEvent event, final int axis) {
+ final float threshold;
+ if (sDeadZoneThresholdOverride >= 0) {
+ threshold = sDeadZoneThresholdOverride;
+ } else {
+ final InputDevice.MotionRange range = event.getDevice().getMotionRange(axis);
+ threshold = range.getFlat() + range.getFuzz();
+ }
+ final float value = event.getAxisValue(axis);
+ return (Math.abs(value) < threshold);
+ }
+
+ private static float deadZone(final MotionEvent ev, final int axis) {
+ if (isValueInDeadZone(ev, axis)) {
+ return 0.0f;
+ }
+ return ev.getAxisValue(axis);
+ }
+
+ private static void mapDpadAxis(
+ final Gamepad gamepad, final boolean pressed, final float value, final int which) {
+ if (pressed != gamepad.dpad[which]) {
+ gamepad.dpad[which] = pressed;
+ onButtonChange(gamepad.handle, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value));
+ }
+ }
+
+ public static boolean handleMotionEvent(final MotionEvent ev) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return false;
+ }
+
+ final Gamepad gamepad = sGamepads.get(ev.getDeviceId());
+ if (gamepad == null) {
+ // Not a device we care about.
+ return false;
+ }
+
+ // First check the analog stick axes
+ final boolean[] valid = new boolean[Axis.values().length];
+ final float[] axes = new float[Axis.values().length];
+ boolean anyValidAxes = false;
+ for (final Axis axis : Axis.values()) {
+ final float value = deadZone(ev, axis.axis);
+ final int i = axis.ordinal();
+ if (value != gamepad.axes[i]) {
+ axes[i] = value;
+ gamepad.axes[i] = value;
+ valid[i] = true;
+ anyValidAxes = true;
+ }
+ }
+ if (anyValidAxes) {
+ // Send an axismove event.
+ onAxisChange(gamepad.handle, valid, axes);
+ }
+
+ // Map triggers to buttons.
+ if (gamepad.triggerAxes != null) {
+ for (final Trigger trigger : Trigger.values()) {
+ final int i = trigger.ordinal();
+ final int axis = gamepad.triggerAxes[i];
+ final float value = deadZone(ev, axis);
+ if (value != gamepad.triggers[i]) {
+ gamepad.triggers[i] = value;
+ final boolean pressed = value > TRIGGER_PRESSED_THRESHOLD;
+ onButtonChange(gamepad.handle, trigger.button, pressed, value);
+ }
+ }
+ }
+ // Map d-pad to buttons.
+ for (final DpadAxis dpadaxis : DpadAxis.values()) {
+ final float value = deadZone(ev, dpadaxis.axis);
+ mapDpadAxis(gamepad, value < 0.0f, value, dpadaxis.negativeButton);
+ mapDpadAxis(gamepad, value > 0.0f, value, dpadaxis.positiveButton);
+ }
+ return true;
+ }
+
+ public static boolean handleKeyEvent(final KeyEvent ev) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return false;
+ }
+
+ final int deviceId = ev.getDeviceId();
+ final List<KeyEvent> pendingGamepad = sPendingGamepads.get(deviceId);
+ if (pendingGamepad != null) {
+ // Queue up key events for pending devices.
+ pendingGamepad.add(ev);
+ return true;
+ }
+
+ if (sGamepads.get(deviceId) == null) {
+ final InputDevice device = ev.getDevice();
+ if (device != null
+ && (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ // This is a gamepad we haven't seen yet.
+ addGamepad(device);
+ sPendingGamepads.get(deviceId).add(ev);
+ return true;
+ }
+ // Not a device we care about.
+ return false;
+ }
+
+ int key = -1;
+ for (final Button button : Button.values()) {
+ if (button.button == ev.getKeyCode()) {
+ key = button.ordinal();
+ break;
+ }
+ }
+ if (key == -1) {
+ // Not a key we know how to handle.
+ return false;
+ }
+ if (ev.getRepeatCount() > 0) {
+ // We would handle this key, but we're not interested in
+ // repeats. Eat it.
+ return true;
+ }
+
+ final Gamepad gamepad = sGamepads.get(deviceId);
+ final boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN;
+ onButtonChange(gamepad.handle, key, pressed, pressed ? 1.0f : 0.0f);
+ return true;
+ }
+
+ private static void scanForGamepads() {
+ final int[] deviceIds = InputDevice.getDeviceIds();
+ if (deviceIds == null) {
+ return;
+ }
+ for (int i = 0; i < deviceIds.length; i++) {
+ final InputDevice device = InputDevice.getDevice(deviceIds[i]);
+ if (device == null) {
+ continue;
+ }
+ if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD) {
+ continue;
+ }
+ addGamepad(device);
+ }
+ }
+
+ private static void addGamepad(final InputDevice device) {
+ sPendingGamepads.put(device.getId(), new ArrayList<KeyEvent>());
+ final byte[] gamepadId = nativeAddGamepad();
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ handleGamepadAdded(device.getId(), gamepadId);
+ }
+ });
+ }
+
+ private static void removeGamepad(final int deviceId) {
+ final Gamepad gamepad = sGamepads.get(deviceId);
+ nativeRemoveGamepad(gamepad.handle);
+ sGamepads.remove(deviceId);
+ }
+
+ private static void addDeviceListener(final Context context) {
+ sListener =
+ new InputManager.InputDeviceListener() {
+ @Override
+ public void onInputDeviceAdded(final int deviceId) {
+ final InputDevice device = InputDevice.getDevice(deviceId);
+ if (device == null) {
+ return;
+ }
+ if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ addGamepad(device);
+ }
+ }
+
+ @Override
+ public void onInputDeviceRemoved(final int deviceId) {
+ if (sPendingGamepads.get(deviceId) != null) {
+ // Got removed before Gecko's ack reached us.
+ // gamepadAdded will deal with it.
+ sPendingGamepads.remove(deviceId);
+ return;
+ }
+ if (sGamepads.get(deviceId) != null) {
+ removeGamepad(deviceId);
+ }
+ }
+
+ @Override
+ public void onInputDeviceChanged(final int deviceId) {}
+ };
+ final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ im.registerInputDeviceListener(sListener, ThreadUtils.getUiHandler());
+ }
+
+ private static void removeDeviceListener(final Context context) {
+ final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ im.unregisterInputDeviceListener(sListener);
+ sListener = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java
new file mode 100644
index 0000000000..9de47406aa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java
@@ -0,0 +1,181 @@
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.os.Build;
+import android.text.TextUtils;
+import android.util.Log;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class Clipboard {
+ private static final String HTML_MIME = "text/html";
+ private static final String UNICODE_MIME = "text/unicode";
+ private static final String LOGTAG = "GeckoClipboard";
+
+ private Clipboard() {}
+
+ /**
+ * Get the text on the primary clip on Android clipboard
+ *
+ * @param context application context.
+ * @return a plain text string of clipboard data.
+ */
+ public static String getText(final Context context) {
+ return getData(context, UNICODE_MIME);
+ }
+
+ /**
+ * Get the data on the primary clip on clipboard
+ *
+ * @param context application context
+ * @param mimeType the mime type we want. This supports text/html and text/unicode only. If other
+ * type, we do nothing.
+ * @return a string into clipboard.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getData(final Context context, final String mimeType) {
+ final ClipboardManager cm =
+ (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ if (cm.hasPrimaryClip()) {
+ final ClipData clip = cm.getPrimaryClip();
+ if (clip == null || clip.getItemCount() == 0) {
+ return null;
+ }
+
+ final ClipDescription description = clip.getDescription();
+ if (HTML_MIME.equals(mimeType)
+ && description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) {
+ final CharSequence data = clip.getItemAt(0).getHtmlText();
+ if (data == null) {
+ return null;
+ }
+ return data.toString();
+ }
+ if (UNICODE_MIME.equals(mimeType)) {
+ try {
+ return clip.getItemAt(0).coerceToText(context).toString();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Couldn't get clip data from clipboard", e);
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Set plain text to clipboard
+ *
+ * @param context application context
+ * @param text a plain text to set to clipboard
+ * @return true if copy is successful.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean setText(final Context context, final CharSequence text) {
+ return setData(context, ClipData.newPlainText("text", text));
+ }
+
+ /**
+ * Store HTML to clipboard
+ *
+ * @param context application context
+ * @param text a plain text to set to clipboard
+ * @param html a html text to set to clipboard
+ * @return true if copy is successful.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean setHTML(
+ final Context context, final CharSequence text, final String htmlText) {
+ return setData(context, ClipData.newHtmlText("html", text, htmlText));
+ }
+
+ /**
+ * Store {@link android.content.ClipData} to clipboard
+ *
+ * @param context application context
+ * @param clipData a {@link android.content.ClipData} to set to clipboard
+ * @return true if copy is successful.
+ */
+ private static boolean setData(final Context context, final ClipData clipData) {
+ // In API Level 11 and above, CLIPBOARD_SERVICE returns android.content.ClipboardManager,
+ // which is a subclass of android.text.ClipboardManager.
+ final ClipboardManager cm =
+ (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ try {
+ cm.setPrimaryClip(clipData);
+ } catch (final NullPointerException e) {
+ // Bug 776223: This is a Samsung clipboard bug. setPrimaryClip() can throw
+ // a NullPointerException if Samsung's /data/clipboard directory is full.
+ // Fortunately, the text is still successfully copied to the clipboard.
+ } catch (final RuntimeException e) {
+ // If clipData is too large, TransactionTooLargeException occurs.
+ Log.e(LOGTAG, "Couldn't set clip data to clipboard", e);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Check whether primary clipboard has given MIME type.
+ *
+ * @param context application context
+ * @param mimeType MIME type
+ * @return true if the clipboard is nonempty, false otherwise.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean hasData(final Context context, final String mimeType) {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+ if (HTML_MIME.equals(mimeType) || UNICODE_MIME.equals(mimeType)) {
+ return !TextUtils.isEmpty(getData(context, mimeType));
+ }
+ return false;
+ }
+
+ // Calling getPrimaryClip causes a toast message from Android 12.
+ // https://developer.android.com/about/versions/12/behavior-changes-all#clipboard-access-notifications
+
+ final ClipboardManager cm =
+ (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+
+ if (!cm.hasPrimaryClip()) {
+ return false;
+ }
+
+ final ClipDescription description = cm.getPrimaryClipDescription();
+ if (description == null) {
+ return false;
+ }
+
+ if (HTML_MIME.equals(mimeType)) {
+ return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML);
+ }
+
+ if (UNICODE_MIME.equals(mimeType)) {
+ // We cannot check content in data at this time to avoid toast message.
+ return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)
+ || description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN);
+ }
+
+ return false;
+ }
+
+ /** Deletes all text from the clipboard. */
+ @WrapForJNI(calledFrom = "gecko")
+ public static void clearText(final Context context) {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
+ setText(context, null);
+ return;
+ }
+ // Although we don't know more details of https://crbug.com/1203377, Blink doesn't use
+ // clearPrimaryClip on Android P since this may throw an exception, even if it is supported
+ // on Android P.
+ final ClipboardManager cm =
+ (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ cm.clearPrimaryClip();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java
new file mode 100644
index 0000000000..91bd44b552
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java
@@ -0,0 +1,537 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.SuppressLint;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Process;
+import android.util.Log;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.UUID;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoRuntime;
+
+public class CrashHandler implements Thread.UncaughtExceptionHandler {
+
+ private static final String LOGTAG = "GeckoCrashHandler";
+ private static final Thread MAIN_THREAD = Thread.currentThread();
+ private static final String DEFAULT_SERVER_URL =
+ "https://crash-reports.mozilla.com/submit?id=%1$s&version=%2$s&buildid=%3$s";
+
+ // Context for getting device information
+ protected final Context appContext;
+ // Thread that this handler applies to, or null for a global handler
+ protected final Thread handlerThread;
+ protected final Thread.UncaughtExceptionHandler systemUncaughtHandler;
+
+ protected boolean crashing;
+ protected boolean unregistered;
+
+ protected final Class<? extends Service> handlerService;
+
+ /**
+ * Get the root exception from the 'cause' chain of an exception.
+ *
+ * @param exc An exception
+ * @return The root exception
+ */
+ public static Throwable getRootException(final Throwable exc) {
+ Throwable cause;
+ Throwable result = exc;
+ for (cause = exc; cause != null; cause = cause.getCause()) {
+ result = cause;
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the standard stack trace string of an exception.
+ *
+ * @param exc An exception
+ * @return The exception stack trace.
+ */
+ public static String getExceptionStackTrace(final Throwable exc) {
+ final StringWriter sw = new StringWriter();
+ final PrintWriter pw = new PrintWriter(sw);
+ exc.printStackTrace(pw);
+ pw.flush();
+ return sw.toString();
+ }
+
+ /** Terminate the current process. */
+ public static void terminateProcess() {
+ Process.killProcess(Process.myPid());
+ }
+
+ /** Create and register a CrashHandler for all threads and thread groups. */
+ public CrashHandler(final Class<? extends Service> handlerService) {
+ this((Context) null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for all threads and thread groups.
+ *
+ * @param appContext A Context for retrieving application information.
+ */
+ public CrashHandler(final Context appContext, final Class<? extends Service> handlerService) {
+ this.appContext = appContext;
+ this.handlerThread = null;
+ this.handlerService = handlerService;
+ this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(this);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ */
+ public CrashHandler(final Thread thread, final Class<? extends Service> handlerService) {
+ this(thread, null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ * @param appContext A Context for retrieving application information.
+ */
+ public CrashHandler(
+ final Thread thread,
+ final Context appContext,
+ final Class<? extends Service> handlerService) {
+ this.appContext = appContext;
+ this.handlerThread = thread;
+ this.handlerService = handlerService;
+ this.systemUncaughtHandler = thread.getUncaughtExceptionHandler();
+ thread.setUncaughtExceptionHandler(this);
+ }
+
+ /** Unregister this CrashHandler for exception handling. */
+ public void unregister() {
+ unregistered = true;
+
+ // Restore the previous handler if we are still the topmost handler.
+ // If not, we are part of a chain of handlers, and we cannot just restore the previous
+ // handler, because that would replace whatever handler that's above us in the chain.
+
+ if (handlerThread != null) {
+ if (handlerThread.getUncaughtExceptionHandler() == this) {
+ handlerThread.setUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ } else {
+ if (Thread.getDefaultUncaughtExceptionHandler() == this) {
+ Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ }
+ }
+
+ /**
+ * Record an exception stack in logs.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ */
+ public static void logException(final Thread thread, final Throwable exc) {
+ try {
+ Log.e(
+ LOGTAG,
+ ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD "
+ + thread.getId()
+ + " (\""
+ + thread.getName()
+ + "\")",
+ exc);
+
+ if (MAIN_THREAD != thread) {
+ Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:");
+ for (final StackTraceElement ste : MAIN_THREAD.getStackTrace()) {
+ Log.e(LOGTAG, " " + ste.toString());
+ }
+ }
+ } catch (final Throwable e) {
+ // If something throws here, we want to continue to report the exception,
+ // so we catch all exceptions and ignore them.
+ }
+ }
+
+ private static long getCrashTime() {
+ return System.currentTimeMillis() / 1000;
+ }
+
+ private static long getStartupTime() {
+ // Process start time is also the proc file modified time.
+ final long uptimeMins = (new File("/proc/self/cmdline")).lastModified();
+ if (uptimeMins == 0L) {
+ return getCrashTime();
+ }
+ return uptimeMins / 1000;
+ }
+
+ private static String getJavaPackageName() {
+ return CrashHandler.class.getPackage().getName();
+ }
+
+ private static String getProcessName() {
+ try {
+ final FileReader reader = new FileReader("/proc/self/cmdline");
+ final char[] buffer = new char[64];
+ try {
+ if (reader.read(buffer) > 0) {
+ // cmdline is delimited by '\0', and we want the first token.
+ final int nul = Arrays.asList(buffer).indexOf('\0');
+ return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim();
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (final IOException e) {
+ }
+
+ return null;
+ }
+
+ protected String getAppPackageName() {
+ final Context context = getAppContext();
+
+ if (context != null) {
+ return context.getPackageName();
+ }
+
+ // Package name is also the process name in most cases.
+ final String processName = getProcessName();
+ if (processName != null) {
+ return processName;
+ }
+
+ // Fallback to using CrashHandler's package name.
+ return getJavaPackageName();
+ }
+
+ protected Context getAppContext() {
+ return appContext;
+ }
+
+ /**
+ * Get the crash "extras" to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return "Extras" in the from of a Bundle
+ */
+ protected Bundle getCrashExtras(final Thread thread, final Throwable exc) {
+ final Context context = getAppContext();
+ final Bundle extras = new Bundle();
+ final String pkgName = getAppPackageName();
+
+ extras.putLong("CrashTime", getCrashTime());
+ extras.putLong("StartupTime", getStartupTime());
+ extras.putString("Android_ProcessName", getProcessName());
+ extras.putString("Android_PackageName", pkgName);
+
+ final String notes = GeckoAppShell.getAppNotes();
+ if (notes != null) {
+ extras.putString("Notes", notes);
+ }
+
+ if (context != null) {
+ final PackageManager pkgMgr = context.getPackageManager();
+ try {
+ final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0);
+ extras.putString("Version", pkgInfo.versionName);
+ extras.putInt("BuildID", pkgInfo.versionCode);
+ extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000);
+ } catch (final PackageManager.NameNotFoundException e) {
+ Log.i(LOGTAG, "Error getting package info", e);
+ }
+ }
+
+ extras.putString("JavaStackTrace", getExceptionStackTrace(exc));
+ return extras;
+ }
+
+ /**
+ * Get the crash minidump content to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Minidump content
+ */
+ protected byte[] getCrashDump(final Thread thread, final Throwable exc) {
+ return new byte[0]; // No minidump.
+ }
+
+ protected static String normalizeUrlString(final String str) {
+ if (str == null) {
+ return "";
+ }
+ return Uri.encode(str);
+ }
+
+ /**
+ * Get the server URL to send the crash report to.
+ *
+ * @param extras The crash extras Bundle
+ */
+ protected String getServerUrl(final Bundle extras) {
+ return String.format(
+ DEFAULT_SERVER_URL,
+ normalizeUrlString(extras.getString("ProductID")),
+ normalizeUrlString(extras.getString("Version")),
+ normalizeUrlString(extras.getString("BuildID")));
+ }
+
+ /**
+ * Launch the crash reporter activity that sends the crash report to the server.
+ *
+ * @param dumpFile Path for the minidump file
+ * @param extraFile Path for the crash extra file
+ * @return Whether the crash reporter was successfully launched
+ */
+ protected boolean launchCrashReporter(final String dumpFile, final String extraFile) {
+ try {
+ final Context context = getAppContext();
+ final ProcessBuilder pb;
+
+ if (handlerService == null) {
+ Log.w(LOGTAG, "No crash handler service defined, unable to report crash");
+ return false;
+ }
+
+ if (context != null) {
+ final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED);
+ intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile);
+ intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile);
+ intent.putExtra(
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ intent.setClass(context, handlerService);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ return true;
+ }
+
+ final int deviceSdkVersion = Build.VERSION.SDK_INT;
+ if (deviceSdkVersion < 17) {
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ "startservice",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + handlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ } else {
+ final String startServiceCommand;
+ if (deviceSdkVersion >= 26) {
+ startServiceCommand = "start-foreground-service";
+ } else {
+ startServiceCommand = "startservice";
+ }
+
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ startServiceCommand,
+ "--user", /* USER_CURRENT_OR_SELF */
+ "-3",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + handlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ }
+
+ pb.start().waitFor();
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error launching crash reporter", e);
+ return false;
+
+ } catch (final InterruptedException e) {
+ Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e);
+ // Fall-through
+ }
+ return true;
+ }
+
+ /**
+ * Report an exception to Socorro.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Whether the exception was successfully reported
+ */
+ @SuppressLint("SdCardPath")
+ protected boolean reportException(final Thread thread, final Throwable exc) {
+ final Context context = getAppContext();
+ final String id = UUID.randomUUID().toString();
+
+ // Use the cache directory under the app directory to store crash files.
+ final File dir;
+ if (context != null) {
+ dir = context.getCacheDir();
+ } else {
+ dir = new File("/data/data/" + getAppPackageName() + "/cache");
+ }
+
+ dir.mkdirs();
+ if (!dir.exists()) {
+ return false;
+ }
+
+ final File dmpFile = new File(dir, id + ".dmp");
+ final File extraFile = new File(dir, id + ".extra");
+
+ try {
+ // Write out minidump file as binary.
+
+ final byte[] minidump = getCrashDump(thread, exc);
+ final FileOutputStream dmpStream = new FileOutputStream(dmpFile);
+ try {
+ dmpStream.write(minidump);
+ } finally {
+ dmpStream.close();
+ }
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error writing minidump file", e);
+ return false;
+ }
+
+ try {
+ // Write out crash extra file as text.
+
+ final Bundle extras = getCrashExtras(thread, exc);
+ final String url = getServerUrl(extras);
+ extras.putString("ServerURL", url);
+
+ final JSONObject json = new JSONObject();
+ for (final String key : extras.keySet()) {
+ json.put(key, extras.get(key));
+ }
+
+ final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile));
+ try {
+ extraWriter.write(json.toString());
+ } finally {
+ extraWriter.close();
+ }
+ } catch (final IOException | JSONException e) {
+ Log.e(LOGTAG, "Error writing extra file", e);
+ return false;
+ }
+
+ return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath());
+ }
+
+ /**
+ * Implements the default behavior for handling uncaught exceptions.
+ *
+ * @param thread The exception thread
+ * @param exc An uncaught exception
+ */
+ @Override
+ public void uncaughtException(final Thread thread, final Throwable exc) {
+ if (this.crashing) {
+ // Prevent possible infinite recusions.
+ return;
+ }
+
+ Thread resolvedThread = thread;
+ if (resolvedThread == null) {
+ // Gecko may pass in null for thread to denote the current thread.
+ resolvedThread = Thread.currentThread();
+ }
+
+ try {
+ Throwable rootException = exc;
+ if (!this.unregistered) {
+ // Only process crash ourselves if we have not been unregistered.
+
+ this.crashing = true;
+ rootException = getRootException(exc);
+ logException(resolvedThread, rootException);
+
+ if (reportException(resolvedThread, rootException)) {
+ // Reporting succeeded; we can terminate our process now.
+ return;
+ }
+ }
+
+ if (systemUncaughtHandler != null) {
+ // Follow the chain of uncaught handlers.
+ systemUncaughtHandler.uncaughtException(resolvedThread, rootException);
+ }
+ } finally {
+ terminateProcess();
+ }
+ }
+
+ public static CrashHandler createDefaultCrashHandler(final Context context) {
+ return new CrashHandler(context, null) {
+ @Override
+ protected Bundle getCrashExtras(final Thread thread, final Throwable exc) {
+ final Bundle extras = super.getCrashExtras(thread, exc);
+
+ extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME);
+ extras.putString("ProductID", BuildConfig.MOZ_APP_ID);
+ extras.putString("Version", BuildConfig.MOZ_APP_VERSION);
+ extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID);
+ extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR);
+ extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL);
+ return extras;
+ }
+
+ @Override
+ public boolean reportException(final Thread thread, final Throwable exc) {
+ if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
+ // Only use Java crash reporter if enabled on official build.
+ return super.reportException(thread, exc);
+ }
+ return false;
+ }
+ };
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java
new file mode 100644
index 0000000000..0aacef39a4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java
@@ -0,0 +1,96 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.util.Log;
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// This class implements the functionality needed to find third-party root
+// certificates that have been added to the android CA store.
+public class EnterpriseRoots {
+ private static final String LOGTAG = "EnterpriseRoots";
+
+ // Gecko calls this function from C++ to find third-party root certificates
+ // it can use as trust anchors for TLS connections.
+ @WrapForJNI
+ private static byte[][] gatherEnterpriseRoots() {
+
+ // The KeyStore "AndroidCAStore" contains the certificates we're
+ // interested in.
+ final KeyStore ks;
+ try {
+ ks = KeyStore.getInstance("AndroidCAStore");
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "getInstance() failed", kse);
+ return new byte[0][0];
+ }
+ try {
+ ks.load(null);
+ } catch (final CertificateException ce) {
+ Log.e(LOGTAG, "load() failed", ce);
+ return new byte[0][0];
+ } catch (final IOException ioe) {
+ Log.e(LOGTAG, "load() failed", ioe);
+ return new byte[0][0];
+ } catch (final NoSuchAlgorithmException nsae) {
+ Log.e(LOGTAG, "load() failed", nsae);
+ return new byte[0][0];
+ }
+ // Given the KeyStore, we get an identifier for each object in it. For
+ // each one that is a Certificate, we try to distinguish between
+ // entries that shipped with the OS and entries that were added by the
+ // user or an administrator. The former we ignore and the latter we
+ // collect in an array of byte arrays and return.
+ final Enumeration<String> aliases;
+ try {
+ aliases = ks.aliases();
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "aliases() failed", kse);
+ return new byte[0][0];
+ }
+ final ArrayList<byte[]> roots = new ArrayList<byte[]>();
+ while (aliases.hasMoreElements()) {
+ final String alias = aliases.nextElement();
+ final boolean isCertificate;
+ try {
+ isCertificate = ks.isCertificateEntry(alias);
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "isCertificateEntry() failed", kse);
+ continue;
+ }
+ // Built-in certificate aliases start with "system:", whereas
+ // 3rd-party certificate aliases start with "user:". It's
+ // unfortunate to be relying on this implementation detail, but
+ // there appears to be no other way to differentiate between the
+ // two.
+ if (isCertificate && alias.startsWith("user:")) {
+ final Certificate certificate;
+ try {
+ certificate = ks.getCertificate(alias);
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "getCertificate() failed", kse);
+ continue;
+ }
+ try {
+ roots.add(certificate.getEncoded());
+ } catch (final CertificateEncodingException cee) {
+ Log.e(LOGTAG, "getEncoded() failed", cee);
+ }
+ }
+ }
+ Log.d(LOGTAG, "found " + roots.size() + " enterprise roots");
+ return roots.toArray(new byte[0][0]);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
new file mode 100644
index 0000000000..647ac5bc09
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
@@ -0,0 +1,588 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import android.os.Handler;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoResult;
+
+@RobocopTarget
+public final class EventDispatcher extends JNIObject {
+ private static final String LOGTAG = "GeckoEventDispatcher";
+
+ private static final EventDispatcher INSTANCE = new EventDispatcher();
+
+ /**
+ * The capacity of a HashMap is rounded up to the next power-of-2. Every time the size of the map
+ * goes beyond 75% of the capacity, the map is rehashed. Therefore, to empirically determine the
+ * initial capacity that avoids rehashing, we need to determine the initial size, divide it by
+ * 75%, and round up to the next power-of-2.
+ */
+ private static final int DEFAULT_UI_EVENTS_COUNT = 128; // Empirically measured
+
+ private static class Message {
+ final String type;
+ final GeckoBundle bundle;
+ final EventCallback callback;
+
+ Message(final String type, final GeckoBundle bundle, final EventCallback callback) {
+ this.type = type;
+ this.bundle = bundle;
+ this.callback = callback;
+ }
+ }
+
+ // GeckoBundle-based events.
+ private final MultiMap<String, BundleEventListener> mListeners =
+ new MultiMap<>(DEFAULT_UI_EVENTS_COUNT);
+ private Deque<Message> mPendingMessages = new ArrayDeque<>();
+
+ private boolean mAttachedToGecko;
+ private final NativeQueue mNativeQueue;
+ private final String mName;
+
+ private static Map<String, EventDispatcher> sDispatchers = new HashMap<>();
+
+ @ReflectionTarget
+ @WrapForJNI(calledFrom = "gecko")
+ public static EventDispatcher getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Gets a named EventDispatcher.
+ *
+ * <p>Named EventDispatchers can be used to communicate to Gecko's corresponding named
+ * EventDispatcher.
+ *
+ * <p>Messages for named EventDispatcher are queued by default when no listener is present. Queued
+ * messages will be released automatically when a listener is attached.
+ *
+ * <p>A named EventDispatcher needs to be disposed manually by calling {@link #shutdown} when it
+ * is not needed anymore.
+ *
+ * @param name Name for this EventDispatcher.
+ * @return the existing named EventDispatcher for a given name or a newly created one if it
+ * doesn't exist.
+ */
+ @ReflectionTarget
+ @WrapForJNI(calledFrom = "gecko")
+ public static EventDispatcher byName(final String name) {
+ synchronized (sDispatchers) {
+ EventDispatcher dispatcher = sDispatchers.get(name);
+
+ if (dispatcher == null) {
+ dispatcher = new EventDispatcher(name);
+ sDispatchers.put(name, dispatcher);
+ }
+
+ return dispatcher;
+ }
+ }
+
+ /* package */ EventDispatcher() {
+ mNativeQueue = GeckoThread.getNativeQueue();
+ mName = null;
+ }
+
+ /* package */ EventDispatcher(final String name) {
+ mNativeQueue = GeckoThread.getNativeQueue();
+ mName = name;
+ }
+
+ public EventDispatcher(final NativeQueue queue) {
+ mNativeQueue = queue;
+ mName = null;
+ }
+
+ private boolean isReadyForDispatchingToGecko() {
+ return mNativeQueue.isReady();
+ }
+
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ @WrapForJNI(stubName = "Shutdown")
+ protected native void shutdownNative();
+
+ @WrapForJNI private static final int DETACHED = 0;
+ @WrapForJNI private static final int ATTACHED = 1;
+ @WrapForJNI private static final int REATTACHING = 2;
+
+ @WrapForJNI(calledFrom = "gecko")
+ private synchronized void setAttachedToGecko(final int state) {
+ if (mAttachedToGecko && state == DETACHED) {
+ dispose(false);
+ }
+ mAttachedToGecko = (state == ATTACHED);
+ }
+
+ /**
+ * Shuts down this EventDispatcher and release resources.
+ *
+ * <p>Only named EventDispatcher can be shut down manually. A shut down EventDispatcher will not
+ * receive any further messages.
+ */
+ public void shutdown() {
+ if (mName == null) {
+ throw new RuntimeException("Only named EventDispatcher's can be shut down.");
+ }
+
+ mAttachedToGecko = false;
+ shutdownNative();
+ dispose(false);
+
+ synchronized (sDispatchers) {
+ sDispatchers.put(mName, null);
+ }
+ }
+
+ private void dispose(final boolean force) {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+ if (geckoHandler == null) {
+ return;
+ }
+
+ geckoHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (force || !mAttachedToGecko) {
+ disposeNative();
+ }
+ }
+ });
+ }
+
+ public void registerUiThreadListener(final BundleEventListener listener, final String... events) {
+ try {
+ synchronized (mListeners) {
+ for (final String event : events) {
+ if (!BuildConfig.RELEASE_OR_BETA && mListeners.containsEntry(event, listener)) {
+ throw new IllegalStateException("Already registered " + event);
+ }
+ mListeners.add(event, listener);
+ }
+ flush(events);
+ }
+ } catch (final Exception e) {
+ throw new IllegalArgumentException("Invalid new list type", e);
+ }
+ }
+
+ public void unregisterUiThreadListener(
+ final BundleEventListener listener, final String... events) {
+ synchronized (mListeners) {
+ for (final String event : events) {
+ if (!mListeners.remove(event, listener) && !BuildConfig.RELEASE_OR_BETA) {
+ throw new IllegalArgumentException(event + " was not registered");
+ }
+ }
+ }
+ }
+
+ @WrapForJNI
+ private native boolean hasGeckoListener(final String event);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void dispatchToGecko(
+ final String event, final GeckoBundle data, final EventCallback callback);
+
+ /**
+ * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * @param type Event type
+ * @param message Bundle message
+ */
+ public void dispatch(final String type, final GeckoBundle message) {
+ dispatch(type, message, /* callback */ null);
+ }
+
+ private abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
+ @Override
+ public void sendError(final Object response) {
+ completeExceptionally(new QueryException(response));
+ }
+ }
+
+ public class QueryException extends Exception {
+ public final Object data;
+
+ public QueryException(final Object data) {
+ this.data = data;
+ }
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes when the event handler returns.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<Void> queryVoid(final String type) {
+ return queryVoid(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes when the event handler returns.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<Void> queryVoid(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given boolean value returned by the handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<Boolean> queryBoolean(final String type) {
+ return queryBoolean(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given boolean value returned by the handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<Boolean> queryBoolean(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given String value returned by the handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<String> queryString(final String type) {
+ return queryString(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given String value returned by the handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<String> queryString(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the
+ * handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<GeckoBundle> queryBundle(final String type) {
+ return queryBundle(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the
+ * handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<GeckoBundle> queryBundle(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ private <T> GeckoResult<T> query(final String type, final GeckoBundle message) {
+ final CallbackResult<T> result =
+ new CallbackResult<T>() {
+ @Override
+ @SuppressWarnings("unchecked") // Not a lot we can do about this :(
+ public void sendSuccess(final Object response) {
+ complete((T) response);
+ }
+ };
+
+ dispatch(type, message, result);
+ return result;
+ }
+
+ /**
+ * Flushes pending messages of given types.
+ *
+ * <p>All unhandled messages are put into a pending state by default for named EventDispatcher
+ * obtained from {@link #byName}.
+ *
+ * @param types Types of message to flush.
+ */
+ private void flush(final String[] types) {
+ final Set<String> typeSet = new HashSet<>(Arrays.asList(types));
+
+ final Deque<Message> pendingMessages;
+ synchronized (mPendingMessages) {
+ pendingMessages = mPendingMessages;
+ mPendingMessages = new ArrayDeque<>(pendingMessages.size());
+ }
+
+ Message message;
+ while (!pendingMessages.isEmpty()) {
+ message = pendingMessages.removeFirst();
+ if (typeSet.contains(message.type)) {
+ dispatchToThreads(message.type, message.bundle, message.callback);
+ } else {
+ synchronized (mPendingMessages) {
+ mPendingMessages.addLast(message);
+ }
+ }
+ }
+ }
+
+ /**
+ * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * @param type Event type
+ * @param message Bundle message
+ * @param callback Optional object for callbacks from events.
+ */
+ @AnyThread
+ private void dispatch(
+ final String type, final GeckoBundle message, final EventCallback callback) {
+ final boolean isGeckoReady;
+ synchronized (this) {
+ isGeckoReady = isReadyForDispatchingToGecko();
+ if (isGeckoReady && mAttachedToGecko && hasGeckoListener(type)) {
+ dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback));
+ return;
+ }
+ }
+
+ dispatchToThreads(type, message, callback, isGeckoReady);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private boolean dispatchToThreads(
+ final String type, final GeckoBundle message, final EventCallback callback) {
+ return dispatchToThreads(type, message, callback, /* isGeckoReady */ true);
+ }
+
+ private boolean dispatchToThreads(
+ final String type,
+ final GeckoBundle message,
+ final EventCallback callback,
+ final boolean isGeckoReady) {
+ // We need to hold the lock throughout dispatching, to ensure the listeners list
+ // is consistent, while we iterate over it. We don't have to worry about listeners
+ // running for a long time while we have the lock, because the listeners will run
+ // on a separate thread.
+ synchronized (mListeners) {
+ if (mListeners.containsKey(type)) {
+ // Use a delegate to make sure callbacks happen on a specific thread.
+ final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback);
+
+ // Event listeners will call | callback.sendError | if applicable.
+ for (final BundleEventListener listener : mListeners.get(type)) {
+ ThreadUtils.getUiHandler()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ final Double startTime = GeckoJavaSampler.tryToGetProfilerTime();
+ listener.handleMessage(type, message, wrappedCallback);
+ GeckoJavaSampler.addMarker(
+ "EventDispatcher handleMessage", startTime, null, type);
+ }
+ });
+ }
+ return true;
+ }
+ }
+
+ if (!isGeckoReady) {
+ // Usually, we discard an event if there is no listeners for it by
+ // the time of the dispatch. However, if Gecko(View) is not ready and
+ // there is no listener for this event that's possibly headed to
+ // Gecko, we make a special exception to queue this event until
+ // Gecko(View) is ready. This way, Gecko can first register its
+ // listeners, and accept the event when it is ready.
+ mNativeQueue.queueUntilReady(
+ this,
+ "dispatchToGecko",
+ String.class,
+ type,
+ GeckoBundle.class,
+ message,
+ EventCallback.class,
+ JavaCallbackDelegate.wrap(callback));
+ return true;
+ }
+
+ // Named EventDispatchers use pending messages
+ if (mName != null) {
+ synchronized (mPendingMessages) {
+ mPendingMessages.addLast(new Message(type, message, callback));
+ }
+ return true;
+ }
+
+ final String error = "No listener for " + type;
+ if (callback != null) {
+ callback.sendError(error);
+ }
+
+ Log.w(LOGTAG, error);
+ return false;
+ }
+
+ @WrapForJNI
+ public boolean hasListener(final String event) {
+ synchronized (mListeners) {
+ return mListeners.containsKey(event);
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ dispose(true);
+ }
+
+ private static class NativeCallbackDelegate extends JNIObject implements EventCallback {
+ @WrapForJNI(calledFrom = "gecko")
+ private NativeCallbackDelegate() {}
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // We dispose in finalize().
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // EventCallback
+ public native void sendSuccess(Object response);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // EventCallback
+ public native void sendError(Object response);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ @Override // Object
+ protected native void finalize();
+ }
+
+ private static class JavaCallbackDelegate implements EventCallback {
+ private final Thread mOriginalThread = Thread.currentThread();
+ private final EventCallback mCallback;
+
+ public static EventCallback wrap(final EventCallback callback) {
+ if (callback == null) {
+ return null;
+ }
+ if (callback instanceof NativeCallbackDelegate) {
+ // NativeCallbackDelegate always posts to Gecko thread if needed.
+ return callback;
+ }
+ return new JavaCallbackDelegate(callback);
+ }
+
+ JavaCallbackDelegate(final EventCallback callback) {
+ mCallback = callback;
+ }
+
+ private void makeCallback(final boolean callSuccess, final Object rawResponse) {
+ final Object response;
+ if (rawResponse instanceof Number) {
+ // There is ambiguity because a number can be converted to either int or
+ // double, so e.g. the user can be expecting a double when we give it an
+ // int. To avoid these pitfalls, we disallow all numbers. The workaround
+ // is to wrap the number in a JS object / GeckoBundle, which supports
+ // type coersion for numbers.
+ throw new UnsupportedOperationException("Cannot use number as Java callback result");
+ } else if (rawResponse != null && rawResponse.getClass().isArray()) {
+ // Same with arrays.
+ throw new UnsupportedOperationException("Cannot use arrays as Java callback result");
+ } else if (rawResponse instanceof Character) {
+ response = rawResponse.toString();
+ } else {
+ response = rawResponse;
+ }
+
+ // Call back synchronously if we happen to be on the same thread as the thread
+ // making the original request.
+ if (ThreadUtils.isOnThread(mOriginalThread)) {
+ if (callSuccess) {
+ mCallback.sendSuccess(response);
+ } else {
+ mCallback.sendError(response);
+ }
+ return;
+ }
+
+ // Make callback on the thread of the original request, if the original thread
+ // is the UI or Gecko thread. Otherwise default to the background thread.
+ final Handler handler =
+ mOriginalThread == ThreadUtils.getUiThread()
+ ? ThreadUtils.getUiHandler()
+ : mOriginalThread == ThreadUtils.sGeckoThread
+ ? ThreadUtils.sGeckoHandler
+ : ThreadUtils.getBackgroundHandler();
+ final EventCallback callback = mCallback;
+
+ handler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (callSuccess) {
+ callback.sendSuccess(response);
+ } else {
+ callback.sendError(response);
+ }
+ }
+ });
+ }
+
+ @Override // EventCallback
+ public void sendSuccess(final Object response) {
+ makeCallback(/* success */ true, response);
+ }
+
+ @Override // EventCallback
+ public void sendError(final Object response) {
+ makeCallback(/* success */ false, response);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
new file mode 100644
index 0000000000..bb7408fbc2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
@@ -0,0 +1,1607 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.ActivityManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.res.TypedArray;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.PixelFormat;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.hardware.display.DisplayManager;
+import android.location.Criteria;
+import android.location.Location;
+import android.location.LocationListener;
+import android.location.LocationManager;
+import android.media.AudioManager;
+import android.net.ConnectivityManager;
+import android.net.LinkProperties;
+import android.net.Network;
+import android.net.NetworkInfo;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.LocaleList;
+import android.os.Looper;
+import android.os.PowerManager;
+import android.os.Vibrator;
+import android.provider.Settings;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextThemeWrapper;
+import android.view.Display;
+import android.view.InputDevice;
+import android.view.WindowManager;
+import android.webkit.MimeTypeMap;
+import androidx.annotation.Nullable;
+import androidx.collection.SimpleArrayMap;
+import androidx.core.content.res.ResourcesCompat;
+import java.net.Proxy;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Locale;
+import java.util.StringTokenizer;
+import org.jetbrains.annotations.NotNull;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.InputDeviceUtils;
+import org.mozilla.gecko.util.ProxySelector;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.R;
+
+public class GeckoAppShell {
+ private static final String LOGTAG = "GeckoAppShell";
+
+ /*
+ * Keep these values consistent with |SensorType| in HalSensor.h
+ */
+ public static final int SENSOR_ORIENTATION = 0;
+ public static final int SENSOR_ACCELERATION = 1;
+ public static final int SENSOR_PROXIMITY = 2;
+ public static final int SENSOR_LINEAR_ACCELERATION = 3;
+ public static final int SENSOR_GYROSCOPE = 4;
+ public static final int SENSOR_LIGHT = 5;
+ public static final int SENSOR_ROTATION_VECTOR = 6;
+ public static final int SENSOR_GAME_ROTATION_VECTOR = 7;
+
+ // We have static members only.
+ private GeckoAppShell() {}
+
+ // Name for app-scoped prefs
+ public static final String APP_PREFS_NAME = "GeckoApp";
+
+ private static class GeckoCrashHandler extends CrashHandler {
+
+ public GeckoCrashHandler(final Class<? extends Service> handlerService) {
+ super(handlerService);
+ }
+
+ @Override
+ protected String getAppPackageName() {
+ final Context appContext = getAppContext();
+ if (appContext == null) {
+ return "<unknown>";
+ }
+ return appContext.getPackageName();
+ }
+
+ @Override
+ protected Context getAppContext() {
+ return getApplicationContext();
+ }
+
+ @Override
+ public boolean reportException(final Thread thread, final Throwable exc) {
+ try {
+ if (exc instanceof OutOfMemoryError) {
+ final SharedPreferences prefs =
+ getApplicationContext().getSharedPreferences(APP_PREFS_NAME, 0);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(PREFS_OOM_EXCEPTION, true);
+
+ // Synchronously write to disk so we know it's done before we
+ // shutdown
+ editor.commit();
+ }
+
+ reportJavaCrash(exc, getExceptionStackTrace(exc));
+
+ } catch (final Throwable e) {
+ }
+
+ // reportJavaCrash should have caused us to hard crash. If we're still here,
+ // it probably means Gecko is not loaded, and we should do something else.
+ if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
+ // Only use Java crash reporter if enabled on official build.
+ return super.reportException(thread, exc);
+ }
+ return false;
+ }
+ }
+
+ private static String sAppNotes;
+ private static CrashHandler sCrashHandler;
+
+ public static synchronized CrashHandler ensureCrashHandling(
+ final Class<? extends Service> handler) {
+ if (sCrashHandler == null) {
+ sCrashHandler = new GeckoCrashHandler(handler);
+ }
+
+ return sCrashHandler;
+ }
+
+ private static Class<? extends Service> sCrashHandlerService;
+
+ public static synchronized void setCrashHandlerService(
+ final Class<? extends Service> handlerService) {
+ sCrashHandlerService = handlerService;
+ }
+
+ public static synchronized Class<? extends Service> getCrashHandlerService() {
+ return sCrashHandlerService;
+ }
+
+ @WrapForJNI(exceptionMode = "ignore")
+ /* package */ static synchronized String getAppNotes() {
+ return sAppNotes;
+ }
+
+ public static synchronized void appendAppNotesToCrashReport(final String notes) {
+ if (sAppNotes == null) {
+ sAppNotes = notes;
+ } else {
+ sAppNotes += '\n' + notes;
+ }
+ }
+
+ private static volatile boolean locationHighAccuracyEnabled;
+ private static volatile boolean locationListeningRequested = false;
+ private static volatile boolean locationPaused = false;
+
+ // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB.
+ private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768;
+
+ private static int sDensityDpi;
+ private static Float sDensity;
+ private static int sScreenDepth;
+ private static boolean sUseMaxScreenDepth;
+ private static Float sScreenRefreshRate;
+
+ /* Is the value in sVibrationEndTime valid? */
+ private static boolean sVibrationMaybePlaying;
+
+ /* Time (in System.nanoTime() units) when the currently-playing vibration
+ * is scheduled to end. This value is valid only when
+ * sVibrationMaybePlaying is true. */
+ private static long sVibrationEndTime;
+
+ private static Sensor gAccelerometerSensor;
+ private static Sensor gLinearAccelerometerSensor;
+ private static Sensor gGyroscopeSensor;
+ private static Sensor gOrientationSensor;
+ private static Sensor gLightSensor;
+ private static Sensor gRotationVectorSensor;
+ private static Sensor gGameRotationVectorSensor;
+
+ /*
+ * Keep in sync with constants found here:
+ * http://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl
+ */
+ public static final int WPL_STATE_START = 0x00000001;
+ public static final int WPL_STATE_STOP = 0x00000010;
+ public static final int WPL_STATE_IS_DOCUMENT = 0x00020000;
+ public static final int WPL_STATE_IS_NETWORK = 0x00040000;
+
+ /* Keep in sync with constants found here:
+ http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl
+ */
+ public static final int LINK_TYPE_UNKNOWN = 0;
+ public static final int LINK_TYPE_ETHERNET = 1;
+ public static final int LINK_TYPE_USB = 2;
+ public static final int LINK_TYPE_WIFI = 3;
+ public static final int LINK_TYPE_WIMAX = 4;
+ public static final int LINK_TYPE_MOBILE = 9;
+
+ public static final String PREFS_OOM_EXCEPTION = "OOMException";
+
+ /* The Android-side API: API methods that Android calls */
+
+ // helper methods
+ @WrapForJNI
+ /* package */ static native void reportJavaCrash(Throwable exc, String stackTrace);
+
+ private static Rect sScreenSizeOverride;
+
+ @WrapForJNI(stubName = "NotifyObservers", dispatchTo = "gecko")
+ private static native void nativeNotifyObservers(String topic, String data);
+
+ @WrapForJNI(stubName = "AppendAppNotesToCrashReport", dispatchTo = "gecko")
+ public static native void nativeAppendAppNotesToCrashReport(final String notes);
+
+ @RobocopTarget
+ public static void notifyObservers(final String topic, final String data) {
+ notifyObservers(topic, data, GeckoThread.State.RUNNING);
+ }
+
+ public static void notifyObservers(
+ final String topic, final String data, final GeckoThread.State state) {
+ if (GeckoThread.isStateAtLeast(state)) {
+ nativeNotifyObservers(topic, data);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ state,
+ GeckoAppShell.class,
+ "nativeNotifyObservers",
+ String.class,
+ topic,
+ String.class,
+ data);
+ }
+ }
+
+ /*
+ * The Gecko-side API: API methods that Gecko calls
+ */
+
+ @WrapForJNI(exceptionMode = "ignore")
+ private static String getExceptionStackTrace(final Throwable e) {
+ return CrashHandler.getExceptionStackTrace(CrashHandler.getRootException(e));
+ }
+
+ @WrapForJNI(exceptionMode = "ignore")
+ private static synchronized void handleUncaughtException(final Throwable e) {
+ if (sCrashHandler != null) {
+ sCrashHandler.uncaughtException(null, e);
+ }
+ }
+
+ private static float getLocationAccuracy(final Location location) {
+ final float radius = location.getAccuracy();
+ return (location.hasAccuracy() && radius > 0) ? radius : 1001;
+ }
+
+ private static Location determineReliableLocation(
+ @NotNull final Location locA, @NotNull final Location locB) {
+ // The 6 seconds were chosen arbitrarily
+ final long closeTime = 6000000000L;
+ final boolean isNearSameTime =
+ Math.abs((locA.getElapsedRealtimeNanos() - locB.getElapsedRealtimeNanos())) <= closeTime;
+ final boolean isAMoreAccurate = getLocationAccuracy(locA) < getLocationAccuracy(locB);
+ final boolean isAMoreRecent = locA.getElapsedRealtimeNanos() > locB.getElapsedRealtimeNanos();
+ if (isNearSameTime) {
+ return isAMoreAccurate ? locA : locB;
+ }
+ return isAMoreRecent ? locA : locB;
+ }
+
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static @Nullable Location getLastKnownLocation(final LocationManager lm) {
+ Location lastKnownLocation = null;
+ final List<String> providers = lm.getAllProviders();
+
+ for (final String provider : providers) {
+ final Location location = lm.getLastKnownLocation(provider);
+ if (location == null) {
+ continue;
+ }
+
+ if (lastKnownLocation == null) {
+ lastKnownLocation = location;
+ continue;
+ }
+ lastKnownLocation = determineReliableLocation(lastKnownLocation, location);
+ }
+ return lastKnownLocation;
+ }
+
+ // Toggles the location listeners on/off, which will then provide/stop location information
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized boolean enableLocationUpdates(final boolean enable) {
+ locationListeningRequested = enable;
+ final boolean canListen = updateLocationListeners();
+ if (!canListen && locationListeningRequested) {
+ // Didn't successfully start listener when requested
+ locationListeningRequested = false;
+ }
+ return canListen;
+ }
+
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static synchronized boolean updateLocationListeners() {
+ final boolean shouldListen = locationListeningRequested && !locationPaused;
+ final LocationManager lm = getLocationManager(getApplicationContext());
+ if (lm == null) {
+ return false;
+ }
+
+ if (!shouldListen) {
+ // Could not complete request, because paused
+ if (locationListeningRequested) {
+ return false;
+ }
+ lm.removeUpdates(sAndroidListeners);
+ return true;
+ }
+
+ if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
+ && !lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ return false;
+ }
+
+ if (!locationHighAccuracyEnabled) {
+ final Location lastKnownLocation = getLastKnownLocation(lm);
+ if (lastKnownLocation != null) {
+ sAndroidListeners.onLocationChanged(lastKnownLocation);
+ }
+ }
+
+ final Criteria criteria = new Criteria();
+ criteria.setSpeedRequired(false);
+ criteria.setBearingRequired(false);
+ criteria.setAltitudeRequired(false);
+ if (locationHighAccuracyEnabled) {
+ criteria.setAccuracy(Criteria.ACCURACY_FINE);
+ } else {
+ criteria.setAccuracy(Criteria.ACCURACY_COARSE);
+ }
+
+ final String provider = lm.getBestProvider(criteria, true);
+ if (provider == null) {
+ return false;
+ }
+
+ final Looper l = Looper.getMainLooper();
+ lm.requestLocationUpdates(provider, 100, 0.5f, sAndroidListeners, l);
+ return true;
+ }
+
+ public static void pauseLocation() {
+ locationPaused = true;
+ updateLocationListeners();
+ }
+
+ public static void resumeLocation() {
+ locationPaused = false;
+ updateLocationListeners();
+ }
+
+ private static LocationManager getLocationManager(final Context context) {
+ try {
+ return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+ } catch (final NoSuchFieldError e) {
+ // Some Tegras throw exceptions about missing the CONTROL_LOCATION_UPDATES permission,
+ // which allows enabling/disabling location update notifications from the cell radio.
+ // CONTROL_LOCATION_UPDATES is not for use by normal applications, but we might be
+ // hitting this problem if the Tegras are confused about missing cell radios.
+ Log.e(LOGTAG, "LOCATION_SERVICE not found?!", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableLocationHighAccuracy(final boolean enable) {
+ locationHighAccuracyEnabled = enable;
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ /* package */ static native void onSensorChanged(
+ int halType, float x, float y, float z, float w, long time);
+
+ @WrapForJNI(calledFrom = "any", dispatchTo = "gecko")
+ /* package */ static native void onLocationChanged(
+ double latitude,
+ double longitude,
+ double altitude,
+ float accuracy,
+ float altitudeAccuracy,
+ float heading,
+ float speed);
+
+ private static class AndroidListeners implements SensorEventListener, LocationListener {
+ @Override
+ public void onAccuracyChanged(final Sensor sensor, final int accuracy) {}
+
+ @Override
+ public void onSensorChanged(final SensorEvent s) {
+ final int sensorType = s.sensor.getType();
+ int halType = 0;
+ float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f;
+ // SensorEvent timestamp is in nanoseconds, Gecko expects microseconds.
+ final long time = s.timestamp / 1000;
+
+ switch (sensorType) {
+ case Sensor.TYPE_ACCELEROMETER:
+ case Sensor.TYPE_LINEAR_ACCELERATION:
+ case Sensor.TYPE_ORIENTATION:
+ if (sensorType == Sensor.TYPE_ACCELEROMETER) {
+ halType = SENSOR_ACCELERATION;
+ } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION) {
+ halType = SENSOR_LINEAR_ACCELERATION;
+ } else {
+ halType = SENSOR_ORIENTATION;
+ }
+ x = s.values[0];
+ y = s.values[1];
+ z = s.values[2];
+ break;
+
+ case Sensor.TYPE_GYROSCOPE:
+ halType = SENSOR_GYROSCOPE;
+ x = (float) Math.toDegrees(s.values[0]);
+ y = (float) Math.toDegrees(s.values[1]);
+ z = (float) Math.toDegrees(s.values[2]);
+ break;
+
+ case Sensor.TYPE_LIGHT:
+ halType = SENSOR_LIGHT;
+ x = s.values[0];
+ break;
+
+ case Sensor.TYPE_ROTATION_VECTOR:
+ case Sensor.TYPE_GAME_ROTATION_VECTOR: // API >= 18
+ halType =
+ (sensorType == Sensor.TYPE_ROTATION_VECTOR
+ ? SENSOR_ROTATION_VECTOR
+ : SENSOR_GAME_ROTATION_VECTOR);
+ x = s.values[0];
+ y = s.values[1];
+ z = s.values[2];
+ if (s.values.length >= 4) {
+ w = s.values[3];
+ } else {
+ // s.values[3] was optional in API <= 18, so we need to compute it
+ // The values form a unit quaternion, so we can compute the angle of
+ // rotation purely based on the given 3 values.
+ w =
+ 1.0f
+ - s.values[0] * s.values[0]
+ - s.values[1] * s.values[1]
+ - s.values[2] * s.values[2];
+ w = (w > 0.0f) ? (float) Math.sqrt(w) : 0.0f;
+ }
+ break;
+ }
+
+ GeckoAppShell.onSensorChanged(halType, x, y, z, w, time);
+ }
+
+ // Geolocation.
+ @Override
+ public void onLocationChanged(final Location location) {
+ // No logging here: user-identifying information.
+
+ final double altitude = location.hasAltitude() ? location.getAltitude() : Double.NaN;
+
+ final float accuracy = location.hasAccuracy() ? location.getAccuracy() : Float.NaN;
+
+ final float altitudeAccuracy =
+ Build.VERSION.SDK_INT >= 26 && location.hasVerticalAccuracy()
+ ? location.getVerticalAccuracyMeters()
+ : Float.NaN;
+
+ final float speed = location.hasSpeed() ? location.getSpeed() : Float.NaN;
+
+ final float heading = location.hasBearing() ? location.getBearing() : Float.NaN;
+
+ // nsGeoPositionCoords will convert NaNs to null for optional
+ // properties of the JavaScript Coordinates object.
+ GeckoAppShell.onLocationChanged(
+ location.getLatitude(),
+ location.getLongitude(),
+ altitude,
+ accuracy,
+ altitudeAccuracy,
+ heading,
+ speed);
+ }
+
+ @Override
+ public void onProviderDisabled(final String provider) {}
+
+ @Override
+ public void onProviderEnabled(final String provider) {}
+
+ @Override
+ public void onStatusChanged(final String provider, final int status, final Bundle extras) {}
+ }
+
+ private static final AndroidListeners sAndroidListeners = new AndroidListeners();
+
+ private static SimpleArrayMap<String, PowerManager.WakeLock> sWakeLocks;
+
+ /** Wake-lock for the CPU. */
+ static final String WAKE_LOCK_CPU = "cpu";
+ /** Wake-lock for the screen. */
+ static final String WAKE_LOCK_SCREEN = "screen";
+ /** Wake-lock for the audio-playing, eqaul to LOCK_CPU. */
+ static final String WAKE_LOCK_AUDIO_PLAYING = "audio-playing";
+ /** Wake-lock for the video-playing, eqaul to LOCK_SCREEN.. */
+ static final String WAKE_LOCK_VIDEO_PLAYING = "video-playing";
+
+ static final int WAKE_LOCKS_COUNT = 2;
+
+ /** No one holds the wake-lock. */
+ static final int WAKE_LOCK_STATE_UNLOCKED = 0;
+ /** The wake-lock is held by a foreground window. */
+ static final int WAKE_LOCK_STATE_LOCKED_FOREGROUND = 1;
+ /** The wake-lock is held by a background window. */
+ static final int WAKE_LOCK_STATE_LOCKED_BACKGROUND = 2;
+
+ @SuppressLint("Wakelock") // We keep the wake lock independent from the function
+ // scope, so we need to suppress the linter warning.
+ private static void setWakeLockState(final String lock, final int state) {
+ if (sWakeLocks == null) {
+ sWakeLocks = new SimpleArrayMap<>(WAKE_LOCKS_COUNT);
+ }
+
+ PowerManager.WakeLock wl = sWakeLocks.get(lock);
+
+ // we should still hold the lock for background audio.
+ if (WAKE_LOCK_AUDIO_PLAYING.equals(lock) && state == WAKE_LOCK_STATE_LOCKED_BACKGROUND) {
+ return;
+ }
+
+ if (state == WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl == null) {
+ final PowerManager pm =
+ (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
+
+ if (WAKE_LOCK_CPU.equals(lock) || WAKE_LOCK_AUDIO_PLAYING.equals(lock)) {
+ wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, lock);
+ } else if (WAKE_LOCK_SCREEN.equals(lock) || WAKE_LOCK_VIDEO_PLAYING.equals(lock)) {
+ // ON_AFTER_RELEASE is set, the user activity timer will be reset when the
+ // WakeLock is released, causing the illumination to remain on a bit longer.
+ wl =
+ pm.newWakeLock(
+ PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, lock);
+ } else {
+ Log.w(LOGTAG, "Unsupported wake-lock: " + lock);
+ return;
+ }
+
+ wl.acquire();
+ sWakeLocks.put(lock, wl);
+ } else if (state != WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl != null) {
+ wl.release();
+ sWakeLocks.remove(lock);
+ }
+ }
+
+ @SuppressWarnings("fallthrough")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableSensor(final int aSensortype) {
+ final SensorManager sm =
+ (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+
+ switch (aSensortype) {
+ case SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor == null) {
+ gGameRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR);
+ }
+ if (gGameRotationVectorSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gGameRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gGameRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor == null) {
+ gRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
+ }
+ if (gRotationVectorSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ORIENTATION:
+ if (gOrientationSensor == null) {
+ gOrientationSensor = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION);
+ }
+ if (gOrientationSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gOrientationSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_ACCELERATION:
+ if (gAccelerometerSensor == null) {
+ gAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ }
+ if (gAccelerometerSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_LIGHT:
+ if (gLightSensor == null) {
+ gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT);
+ }
+ if (gLightSensor != null) {
+ sm.registerListener(sAndroidListeners, gLightSensor, SensorManager.SENSOR_DELAY_NORMAL);
+ }
+ break;
+
+ case SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor == null) {
+ gLinearAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION);
+ }
+ if (gLinearAccelerometerSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gLinearAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor == null) {
+ gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+ }
+ if (gGyroscopeSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gGyroscopeSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ default:
+ Log.w(LOGTAG, "Error! Can't enable unknown SENSOR type " + aSensortype);
+ }
+ }
+
+ @SuppressWarnings("fallthrough")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableSensor(final int aSensortype) {
+ final SensorManager sm =
+ (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+
+ switch (aSensortype) {
+ case SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gGameRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ORIENTATION:
+ if (gOrientationSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gOrientationSensor);
+ }
+ break;
+
+ case SENSOR_ACCELERATION:
+ if (gAccelerometerSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gAccelerometerSensor);
+ }
+ break;
+
+ case SENSOR_LIGHT:
+ if (gLightSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gLightSensor);
+ }
+ break;
+
+ case SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gLinearAccelerometerSensor);
+ }
+ break;
+
+ case SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gGyroscopeSensor);
+ }
+ break;
+ default:
+ Log.w(LOGTAG, "Error! Can't disable unknown SENSOR type " + aSensortype);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void moveTaskToBack() {
+ // This is a vestige, to be removed as full-screen support for GeckoView is implemented.
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Encoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(true /* aIsEncoder */);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Decoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(false /* aIsEncoder */);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getExtensionFromMimeType(final String aMimeType) {
+ return MimeTypeMap.getSingleton().getExtensionFromMimeType(aMimeType);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getMimeTypeFromExtensions(final String aFileExt) {
+ final StringTokenizer st = new StringTokenizer(aFileExt, ".,; ");
+ String type = null;
+ String subType = null;
+ while (st.hasMoreElements()) {
+ final String ext = st.nextToken();
+ final String mt = getMimeTypeFromExtension(ext);
+ if (mt == null) continue;
+ final int slash = mt.indexOf('/');
+ final String tmpType = mt.substring(0, slash);
+ if (!tmpType.equalsIgnoreCase(type)) type = type == null ? tmpType : "*";
+ final String tmpSubType = mt.substring(slash + 1);
+ if (!tmpSubType.equalsIgnoreCase(subType)) subType = subType == null ? tmpSubType : "*";
+ }
+ if (type == null) type = "*";
+ if (subType == null) subType = "*";
+ return type + "/" + subType;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void notifyAlertListener(String name, String topic, String cookie);
+
+ /**
+ * Called by the NotificationListener to notify Gecko that a previously shown notification has
+ * been closed.
+ */
+ public static void onNotificationClose(final String name, final String cookie) {
+ if (GeckoThread.isRunning()) {
+ notifyAlertListener(name, "alertfinished", cookie);
+ }
+ }
+
+ /**
+ * Called by the NotificationListener to notify Gecko that a previously shown notification has
+ * been clicked on.
+ */
+ public static void onNotificationClick(final String name, final String cookie) {
+ if (GeckoThread.isRunning()) {
+ notifyAlertListener(name, "alertclickcallback", cookie);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ GeckoAppShell.class,
+ "notifyAlertListener",
+ name,
+ "alertclickcallback",
+ cookie);
+ }
+ }
+
+ public static synchronized void setDisplayDpiOverride(@Nullable final Integer dpi) {
+ if (dpi == null) {
+ return;
+ }
+ if (sDensityDpi != 0) {
+ Log.e(LOGTAG, "Tried to override screen DPI after it's already been set");
+ return;
+ }
+ sDensityDpi = dpi;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized int getDpi() {
+ if (sDensityDpi == 0) {
+ sDensityDpi = getApplicationContext().getResources().getDisplayMetrics().densityDpi;
+ }
+ return sDensityDpi;
+ }
+
+ public static synchronized void setDisplayDensityOverride(@Nullable final Float density) {
+ if (density == null) {
+ return;
+ }
+ if (sDensity != null) {
+ Log.e(LOGTAG, "Tried to override screen density after it's already been set");
+ return;
+ }
+ sDensity = density;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized float getDensity() {
+ if (sDensity == null) {
+ sDensity = Float.valueOf(getApplicationContext().getResources().getDisplayMetrics().density);
+ }
+
+ return sDensity;
+ }
+
+ private static int sTotalRam;
+
+ private static int getTotalRam(final Context context) {
+ if (sTotalRam == 0) {
+ final ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
+ final ActivityManager am =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ am.getMemoryInfo(memInfo); // `getMemoryInfo()` returns a value in B. Convert to MB.
+ sTotalRam = (int) (memInfo.totalMem / (1024 * 1024));
+ Log.d(LOGTAG, "System memory: " + sTotalRam + "MB.");
+ }
+
+ return sTotalRam;
+ }
+
+ private static boolean isHighMemoryDevice(final Context context) {
+ return getTotalRam(context) > HIGH_MEMORY_DEVICE_THRESHOLD_MB;
+ }
+
+ public static synchronized void useMaxScreenDepth(final boolean enable) {
+ sUseMaxScreenDepth = enable;
+ }
+
+ /** Returns the colour depth of the default screen. This will either be 32, 24 or 16. */
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized int getScreenDepth() {
+ if (sScreenDepth == 0) {
+ sScreenDepth = 16;
+ final Context applicationContext = getApplicationContext();
+ final PixelFormat info = new PixelFormat();
+ final WindowManager wm =
+ (WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE);
+ PixelFormat.getPixelFormatInfo(wm.getDefaultDisplay().getPixelFormat(), info);
+ if (info.bitsPerPixel >= 24 && isHighMemoryDevice(applicationContext)) {
+ sScreenDepth = sUseMaxScreenDepth ? info.bitsPerPixel : 24;
+ }
+ }
+
+ return sScreenDepth;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized float getScreenRefreshRate() {
+ if (sScreenRefreshRate != null) {
+ return sScreenRefreshRate;
+ }
+
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final float refreshRate = wm.getDefaultDisplay().getRefreshRate();
+ // Android 11+ supports multiple refresh rate. So we have to get refresh rate per call.
+ // https://source.android.com/docs/core/graphics/multiple-refresh-rate
+ if (Build.VERSION.SDK_INT < 30) {
+ // Until Android 10, refresh rate is fixed, so we can cache it.
+ sScreenRefreshRate = Float.valueOf(refreshRate);
+ }
+ return refreshRate;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void performHapticFeedback(final boolean aIsLongPress) {
+ // Don't perform haptic feedback if a vibration is currently playing,
+ // because the haptic feedback will nuke the vibration.
+ if (!sVibrationMaybePlaying || System.nanoTime() >= sVibrationEndTime) {
+ final int[] pattern;
+ if (aIsLongPress) {
+ pattern = new int[] {0, 1, 20, 21};
+ } else {
+ pattern = new int[] {0, 10, 20, 30};
+ }
+ vibrateOnHapticFeedbackEnabled(pattern);
+ sVibrationMaybePlaying = false;
+ sVibrationEndTime = 0;
+ }
+ }
+
+ private static Vibrator vibrator() {
+ return (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE);
+ }
+
+ // Helper method to convert integer array to long array.
+ private static long[] convertIntToLongArray(final int[] input) {
+ final long[] output = new long[input.length];
+ for (int i = 0; i < input.length; i++) {
+ output[i] = input[i];
+ }
+ return output;
+ }
+
+ // Vibrate only if haptic feedback is enabled.
+ private static void vibrateOnHapticFeedbackEnabled(final int[] milliseconds) {
+ if (Settings.System.getInt(
+ getApplicationContext().getContentResolver(),
+ Settings.System.HAPTIC_FEEDBACK_ENABLED,
+ 0)
+ > 0) {
+ if (milliseconds.length == 1) {
+ vibrate(milliseconds[0]);
+ } else {
+ vibrate(convertIntToLongArray(milliseconds), -1);
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void vibrate(final long milliseconds) {
+ sVibrationEndTime = System.nanoTime() + milliseconds * 1000000;
+ sVibrationMaybePlaying = true;
+ try {
+ vibrator().vibrate(milliseconds);
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void vibrate(final long[] pattern, final int repeat) {
+ // If pattern.length is odd, the last element in the pattern is a
+ // meaningless delay, so don't include it in vibrationDuration.
+ long vibrationDuration = 0;
+ final int iterLen = pattern.length & ~1;
+ for (int i = 0; i < iterLen; i++) {
+ vibrationDuration += pattern[i];
+ }
+
+ sVibrationEndTime = System.nanoTime() + vibrationDuration * 1000000;
+ sVibrationMaybePlaying = true;
+ try {
+ vibrator().vibrate(pattern, repeat);
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void cancelVibrate() {
+ sVibrationMaybePlaying = false;
+ sVibrationEndTime = 0;
+ try {
+ vibrator().cancel();
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ private static ConnectivityManager sConnectivityManager;
+
+ private static void ensureConnectivityManager() {
+ if (sConnectivityManager == null) {
+ sConnectivityManager =
+ (ConnectivityManager)
+ getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean isNetworkLinkUp() {
+ ensureConnectivityManager();
+ try {
+ final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo();
+ if (info == null || !info.isConnected()) return false;
+ } catch (final SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean isNetworkLinkKnown() {
+ ensureConnectivityManager();
+ try {
+ if (sConnectivityManager.getActiveNetworkInfo() == null) return false;
+ } catch (final SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getNetworkLinkType() {
+ ensureConnectivityManager();
+ final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo();
+ if (info == null) {
+ return LINK_TYPE_UNKNOWN;
+ }
+
+ switch (info.getType()) {
+ case ConnectivityManager.TYPE_ETHERNET:
+ return LINK_TYPE_ETHERNET;
+ case ConnectivityManager.TYPE_WIFI:
+ return LINK_TYPE_WIFI;
+ case ConnectivityManager.TYPE_WIMAX:
+ return LINK_TYPE_WIMAX;
+ case ConnectivityManager.TYPE_MOBILE:
+ return LINK_TYPE_MOBILE;
+ default:
+ Log.w(LOGTAG, "Ignoring the current network type.");
+ return LINK_TYPE_UNKNOWN;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String getDNSDomains() {
+ if (Build.VERSION.SDK_INT < 23) {
+ return "";
+ }
+
+ ensureConnectivityManager();
+ final Network net = sConnectivityManager.getActiveNetwork();
+ if (net == null) {
+ return "";
+ }
+
+ final LinkProperties lp = sConnectivityManager.getLinkProperties(net);
+ if (lp == null) {
+ return "";
+ }
+
+ return lp.getDomains();
+ }
+
+ @SuppressLint("ResourceType")
+ @WrapForJNI(calledFrom = "gecko")
+ private static int[] getSystemColors() {
+ // attrsAppearance[] must correspond to AndroidSystemColors structure in android/nsLookAndFeel.h
+ final int[] attrsAppearance = {
+ android.R.attr.textColorPrimary,
+ android.R.attr.textColorPrimaryInverse,
+ android.R.attr.textColorSecondary,
+ android.R.attr.textColorSecondaryInverse,
+ android.R.attr.textColorTertiary,
+ android.R.attr.textColorTertiaryInverse,
+ android.R.attr.textColorHighlight,
+ android.R.attr.colorForeground,
+ android.R.attr.colorBackground,
+ android.R.attr.panelColorForeground,
+ android.R.attr.panelColorBackground,
+ Build.VERSION.SDK_INT >= 21 ? android.R.attr.colorAccent : 0,
+ };
+
+ final int[] result = new int[attrsAppearance.length];
+
+ final ContextThemeWrapper contextThemeWrapper =
+ new ContextThemeWrapper(getApplicationContext(), android.R.style.TextAppearance);
+
+ final TypedArray appearance = contextThemeWrapper.obtainStyledAttributes(attrsAppearance);
+
+ if (appearance != null) {
+ for (int i = 0; i < appearance.getIndexCount(); i++) {
+ final int idx = appearance.getIndex(i);
+ final int color = appearance.getColor(idx, 0);
+ result[idx] = color;
+ }
+ appearance.recycle();
+ }
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static byte[] getIconForExtension(final String aExt, final int iconSize) {
+ try {
+ int resolvedIconSize = iconSize;
+ if (iconSize <= 0) {
+ resolvedIconSize = 16;
+ }
+
+ String resolvedExt = aExt;
+ if (aExt != null && aExt.length() > 1 && aExt.charAt(0) == '.') {
+ resolvedExt = aExt.substring(1);
+ }
+
+ final PackageManager pm = getApplicationContext().getPackageManager();
+ Drawable icon = getDrawableForExtension(pm, resolvedExt);
+ if (icon == null) {
+ // Use a generic icon.
+ icon =
+ ResourcesCompat.getDrawable(
+ getApplicationContext().getResources(),
+ R.drawable.ic_generic_file,
+ getApplicationContext().getTheme());
+ }
+
+ Bitmap bitmap = getBitmapFromDrawable(icon);
+ if (bitmap.getWidth() != resolvedIconSize || bitmap.getHeight() != resolvedIconSize) {
+ bitmap = Bitmap.createScaledBitmap(bitmap, resolvedIconSize, resolvedIconSize, true);
+ }
+
+ final ByteBuffer buf = ByteBuffer.allocate(resolvedIconSize * resolvedIconSize * 4);
+ bitmap.copyPixelsToBuffer(buf);
+
+ return buf.array();
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "getIconForExtension failed.", e);
+ return null;
+ }
+ }
+
+ private static Bitmap getBitmapFromDrawable(final Drawable drawable) {
+ if (drawable instanceof BitmapDrawable) {
+ return ((BitmapDrawable) drawable).getBitmap();
+ }
+
+ int width = drawable.getIntrinsicWidth();
+ width = width > 0 ? width : 1;
+ int height = drawable.getIntrinsicHeight();
+ height = height > 0 ? height : 1;
+
+ final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ final Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+
+ return bitmap;
+ }
+
+ public static String getMimeTypeFromExtension(final String ext) {
+ final MimeTypeMap mtm = MimeTypeMap.getSingleton();
+ return mtm.getMimeTypeFromExtension(ext);
+ }
+
+ private static Drawable getDrawableForExtension(final PackageManager pm, final String aExt) {
+ final Intent intent = new Intent(Intent.ACTION_VIEW);
+ final String mimeType = getMimeTypeFromExtension(aExt);
+ if (mimeType != null && mimeType.length() > 0) intent.setType(mimeType);
+ else return null;
+
+ final List<ResolveInfo> list = pm.queryIntentActivities(intent, 0);
+ if (list.size() == 0) return null;
+
+ final ResolveInfo resolveInfo = list.get(0);
+
+ if (resolveInfo == null) return null;
+
+ final ActivityInfo activityInfo = resolveInfo.activityInfo;
+
+ return activityInfo.loadIcon(pm);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean getShowPasswordSetting() {
+ try {
+ final int showPassword =
+ Settings.System.getInt(
+ getApplicationContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1);
+ return (showPassword > 0);
+ } catch (final Exception e) {
+ return true;
+ }
+ }
+
+ private static Context sApplicationContext;
+ private static Boolean sIs24HourFormat = true;
+
+ @WrapForJNI
+ public static Context getApplicationContext() {
+ return sApplicationContext;
+ }
+
+ public static void setApplicationContext(final Context context) {
+ sApplicationContext = context;
+ }
+
+ /*
+ * Battery API related methods.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableBatteryNotifications() {
+ GeckoBatteryManager.enableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableBatteryNotifications() {
+ GeckoBatteryManager.disableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static double[] getCurrentBatteryInformation() {
+ return GeckoBatteryManager.getCurrentInformation();
+ }
+
+ /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */
+ @WrapForJNI(calledFrom = "gecko")
+ @RobocopTarget
+ public static boolean isTablet() {
+ return HardwareUtils.isTablet(getApplicationContext());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static double[] getCurrentNetworkInformation() {
+ return GeckoNetworkManager.getInstance().getCurrentInformation();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableNetworkNotifications() {
+ ThreadUtils.runOnUiThread(() -> GeckoNetworkManager.getInstance().enableNotifications());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableNetworkNotifications() {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoNetworkManager.getInstance().disableNotifications();
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static short getScreenOrientation() {
+ return GeckoScreenOrientation.getInstance().getScreenOrientation().value;
+ }
+
+ /* package */ static int getRotation() {
+ return sScreenCompat.getRotation();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getScreenAngle() {
+ return GeckoScreenOrientation.getInstance().getAngle();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void notifyWakeLockChanged(final String topic, final String state) {
+ final int intState;
+ if ("unlocked".equals(state)) {
+ intState = WAKE_LOCK_STATE_UNLOCKED;
+ } else if ("locked-foreground".equals(state)) {
+ intState = WAKE_LOCK_STATE_LOCKED_FOREGROUND;
+ } else if ("locked-background".equals(state)) {
+ intState = WAKE_LOCK_STATE_LOCKED_BACKGROUND;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ setWakeLockState(topic, intState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String getProxyForURI(
+ final String spec, final String scheme, final String host, final int port) {
+ final ProxySelector ps = new ProxySelector();
+
+ final Proxy proxy = ps.select(scheme, host);
+ if (Proxy.NO_PROXY.equals(proxy)) {
+ return "DIRECT";
+ }
+
+ switch (proxy.type()) {
+ case HTTP:
+ return "PROXY " + proxy.address().toString();
+ case SOCKS:
+ return "SOCKS " + proxy.address().toString();
+ }
+
+ return "DIRECT";
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getMaxTouchPoints() {
+ final PackageManager pm = getApplicationContext().getPackageManager();
+ if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_JAZZHAND)) {
+ // at least, 5+ fingers.
+ return 5;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT)) {
+ // at least, 2+ fingers.
+ return 2;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH)) {
+ // 2 fingers
+ return 2;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) {
+ // 1 finger
+ return 1;
+ }
+ return 0;
+ }
+
+ /*
+ * Keep in sync with PointerCapabilities in ServoTypes.h
+ */
+ private static final int NO_POINTER = 0x00000000;
+ private static final int COARSE_POINTER = 0x00000001;
+ private static final int FINE_POINTER = 0x00000002;
+ private static final int HOVER_CAPABLE_POINTER = 0x00000004;
+
+ private static int getPointerCapabilities(final InputDevice inputDevice) {
+ int result = NO_POINTER;
+ final int sources = inputDevice.getSources();
+
+ // Blink checks fine pointer at first, then it check coarse pointer.
+ // So, we should use same order for compatibility.
+ // Also, if using Chrome OS, source may be SOURCE_MOUSE | SOURCE_TOUCHSCREEN | SOURCE_STYLUS
+ // even if no touch screen. So we shouldn't check TOUCHSCREEN at first.
+
+ if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_STYLUS)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)) {
+ result |= FINE_POINTER;
+ } else if (hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHSCREEN)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) {
+ result |= COARSE_POINTER;
+ }
+
+ if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) {
+ result |= HOVER_CAPABLE_POINTER;
+ }
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ // For any-pointer and any-hover media queries features.
+ private static int getAllPointerCapabilities() {
+ int result = NO_POINTER;
+
+ for (final int deviceId : InputDevice.getDeviceIds()) {
+ final InputDevice inputDevice = InputDevice.getDevice(deviceId);
+ if (inputDevice == null || !InputDeviceUtils.isPointerTypeDevice(inputDevice)) {
+ continue;
+ }
+
+ result |= getPointerCapabilities(inputDevice);
+ }
+
+ return result;
+ }
+
+ private static boolean hasInputDeviceSource(final int sources, final int inputDeviceSource) {
+ return (sources & inputDeviceSource) == inputDeviceSource;
+ }
+
+ public static synchronized void setScreenSizeOverride(final Rect size) {
+ sScreenSizeOverride = size;
+ }
+
+ static final ScreenCompat sScreenCompat;
+
+ private interface ScreenCompat {
+ Rect getScreenSize();
+
+ int getRotation();
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private static class JellyBeanScreenCompat implements ScreenCompat {
+ public Rect getScreenSize() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Display disp = wm.getDefaultDisplay();
+ return new Rect(0, 0, disp.getWidth(), disp.getHeight());
+ }
+
+ public int getRotation() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ return wm.getDefaultDisplay().getRotation();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+ private static class JellyBeanMR1ScreenCompat implements ScreenCompat {
+ public Rect getScreenSize() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Display disp = wm.getDefaultDisplay();
+ final Point size = new Point();
+ disp.getRealSize(size);
+ return new Rect(0, 0, size.x, size.y);
+ }
+
+ public int getRotation() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ return wm.getDefaultDisplay().getRotation();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.S)
+ private static class AndroidSScreenCompat implements ScreenCompat {
+ @SuppressLint("StaticFieldLeak")
+ private static Context sWindowContext;
+
+ private static Context getWindowContext() {
+ if (sWindowContext == null) {
+ final DisplayManager displayManager =
+ (DisplayManager) getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+ final Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+ sWindowContext =
+ getApplicationContext()
+ .createWindowContext(display, WindowManager.LayoutParams.TYPE_APPLICATION, null);
+ }
+ return sWindowContext;
+ }
+
+ public Rect getScreenSize() {
+ final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class);
+ return windowManager.getCurrentWindowMetrics().getBounds();
+ }
+
+ public int getRotation() {
+ final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class);
+ return windowManager.getDefaultDisplay().getRotation();
+ }
+ }
+
+ static {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ sScreenCompat = new AndroidSScreenCompat();
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ sScreenCompat = new JellyBeanMR1ScreenCompat();
+ } else {
+ sScreenCompat = new JellyBeanScreenCompat();
+ }
+ }
+
+ /* package */ static Rect getScreenSizeIgnoreOverride() {
+ return sScreenCompat.getScreenSize();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized Rect getScreenSize() {
+ if (sScreenSizeOverride != null) {
+ return sScreenSizeOverride;
+ }
+
+ return getScreenSizeIgnoreOverride();
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static int getAudioOutputFramesPerBuffer() {
+ final int DEFAULT = 512;
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return DEFAULT;
+ }
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return DEFAULT;
+ }
+ final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER);
+ if (prop == null) {
+ return DEFAULT;
+ }
+ return Integer.parseInt(prop);
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static int getAudioOutputSampleRate() {
+ final int DEFAULT = 44100;
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return DEFAULT;
+ }
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return DEFAULT;
+ }
+ final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
+ if (prop == null) {
+ return DEFAULT;
+ }
+ return Integer.parseInt(prop);
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static void setCommunicationAudioModeOn(final boolean on) {
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return;
+ }
+
+ try {
+ if (on) {
+ Log.e(LOGTAG, "Setting communication mode ON");
+ // This shouldn't throw, but does throw NullPointerException on a very
+ // small number of devices.
+ am.startBluetoothSco();
+ am.setBluetoothScoOn(true);
+ } else {
+ Log.e(LOGTAG, "Setting communication mode OFF");
+ am.stopBluetoothSco();
+ am.setBluetoothScoOn(false);
+ }
+ } catch (final SecurityException | NullPointerException e) {
+ Log.e(LOGTAG, "could not set communication mode", e);
+ }
+ }
+
+ private static String getLanguageTag(final Locale locale) {
+ final StringBuilder out = new StringBuilder(locale.getLanguage());
+ final String country = locale.getCountry();
+ final String variant = locale.getVariant();
+ if (!TextUtils.isEmpty(country)) {
+ out.append('-').append(country);
+ }
+ if (!TextUtils.isEmpty(variant)) {
+ out.append('-').append(variant);
+ }
+ // e.g. "en", "en-US", or "en-US-POSIX".
+ return out.toString();
+ }
+
+ @WrapForJNI
+ public static String[] getDefaultLocales() {
+ // XXX We may have to convert some language codes such as "id" vs "in".
+ if (Build.VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ final String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ final String[] locales = new String[1];
+ final Locale locale = Locale.getDefault();
+ if (Build.VERSION.SDK_INT >= 21) {
+ locales[0] = locale.toLanguageTag();
+ return locales;
+ }
+
+ locales[0] = getLanguageTag(locale);
+ return locales;
+ }
+
+ public static void setIs24HourFormat(final Boolean is24HourFormat) {
+ sIs24HourFormat = is24HourFormat;
+ }
+
+ @WrapForJNI
+ public static boolean getIs24HourFormat() {
+ return sIs24HourFormat;
+ }
+
+ @WrapForJNI
+ public static String getAppName() {
+ final Context context = getApplicationContext();
+ final ApplicationInfo info = context.getApplicationInfo();
+ final int id = info.labelRes;
+ return id == 0 ? info.nonLocalizedLabel.toString() : context.getString(id);
+ }
+
+ @WrapForJNI
+ public static native boolean isParentProcess();
+
+ /**
+ * Returns a GeckoResult that will be completed to true if the GPU process is enabled and false if
+ * it is disabled.
+ */
+ @WrapForJNI
+ public static native GeckoResult<Boolean> isGpuProcessEnabled();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java
new file mode 100644
index 0000000000..19f489b399
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java
@@ -0,0 +1,200 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.BatteryManager;
+import android.os.Build;
+import android.os.SystemClock;
+import android.util.Log;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public class GeckoBatteryManager extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoBatteryManager";
+
+ // Those constants should be keep in sync with the ones in:
+ // dom/battery/Constants.h
+ private static final double kDefaultLevel = 1.0;
+ private static final boolean kDefaultCharging = true;
+ private static final double kDefaultRemainingTime = 0.0;
+ private static final double kUnknownRemainingTime = -1.0;
+
+ private static long sLastLevelChange;
+ private static boolean sNotificationsEnabled;
+ private static double sLevel = kDefaultLevel;
+ private static boolean sCharging = kDefaultCharging;
+ private static double sRemainingTime = kDefaultRemainingTime;
+
+ private static final GeckoBatteryManager sInstance = new GeckoBatteryManager();
+
+ private final IntentFilter mFilter;
+ private Context mApplicationContext;
+ private boolean mIsEnabled;
+
+ public static GeckoBatteryManager getInstance() {
+ return sInstance;
+ }
+
+ private GeckoBatteryManager() {
+ mFilter = new IntentFilter();
+ mFilter.addAction(Intent.ACTION_BATTERY_CHANGED);
+ }
+
+ public synchronized void start(final Context context) {
+ if (mIsEnabled) {
+ Log.w(LOGTAG, "Already started!");
+ return;
+ }
+
+ mApplicationContext = context.getApplicationContext();
+ // registerReceiver will return null if registering fails.
+ if (mApplicationContext.registerReceiver(this, mFilter) == null) {
+ Log.e(LOGTAG, "Registering receiver failed");
+ } else {
+ mIsEnabled = true;
+ }
+ }
+
+ public synchronized void stop() {
+ if (!mIsEnabled) {
+ Log.w(LOGTAG, "Already stopped!");
+ return;
+ }
+
+ mApplicationContext.unregisterReceiver(this);
+ mApplicationContext = null;
+ mIsEnabled = false;
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ private static native void onBatteryChange(double level, boolean charging, double remainingTime);
+
+ @Override
+ public void onReceive(final Context context, final Intent intent) {
+ if (!intent.getAction().equals(Intent.ACTION_BATTERY_CHANGED)) {
+ Log.e(LOGTAG, "Got an unexpected intent!");
+ return;
+ }
+
+ final boolean previousCharging = isCharging();
+ final double previousLevel = getLevel();
+
+ // NOTE: it might not be common (in 2012) but technically, Android can run
+ // on a device that has no battery so we want to make sure it's not the case
+ // before bothering checking for battery state.
+ // However, the Galaxy Nexus phone advertises itself as battery-less which
+ // force us to special-case the logic.
+ // See the Google bug: https://code.google.com/p/android/issues/detail?id=22035
+ if (intent.getBooleanExtra(BatteryManager.EXTRA_PRESENT, false)
+ || Build.MODEL.equals("Galaxy Nexus")) {
+ final int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1);
+ if (plugged == -1) {
+ sCharging = kDefaultCharging;
+ Log.e(LOGTAG, "Failed to get the plugged status!");
+ } else {
+ // Likely, if plugged > 0, it's likely plugged and charging but the doc
+ // isn't clear about that.
+ sCharging = plugged != 0;
+ }
+
+ if (sCharging != previousCharging) {
+ sRemainingTime = kUnknownRemainingTime;
+ // The new remaining time is going to take some time to show up but
+ // it's the best way to show a not too wrong value.
+ sLastLevelChange = 0;
+ }
+
+ // We need two doubles because sLevel is a double.
+ final double current = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+ final double max = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
+ if (current == -1 || max == -1) {
+ Log.e(LOGTAG, "Failed to get battery level!");
+ sLevel = kDefaultLevel;
+ } else {
+ sLevel = current / max;
+ }
+
+ if (sLevel == 1.0 && sCharging) {
+ sRemainingTime = kDefaultRemainingTime;
+ } else if (sLevel != previousLevel) {
+ // Estimate remaining time.
+ if (sLastLevelChange != 0) {
+ // Use elapsedRealtime() because we want to track time across device sleeps.
+ final long currentTime = SystemClock.elapsedRealtime();
+ final long dt = (currentTime - sLastLevelChange) / 1000;
+ final double dLevel = sLevel - previousLevel;
+
+ if (sCharging) {
+ if (dLevel < 0) {
+ sRemainingTime = kUnknownRemainingTime;
+ } else {
+ sRemainingTime = Math.round(dt / dLevel * (1.0 - sLevel));
+ }
+ } else {
+ if (dLevel > 0) {
+ Log.w(LOGTAG, "When discharging, level should decrease!");
+ sRemainingTime = kUnknownRemainingTime;
+ } else {
+ sRemainingTime = Math.round(dt / -dLevel * sLevel);
+ }
+ }
+
+ sLastLevelChange = currentTime;
+ } else {
+ // That's the first time we got an update, we can't do anything.
+ sLastLevelChange = SystemClock.elapsedRealtime();
+ }
+ }
+ } else {
+ sLevel = kDefaultLevel;
+ sCharging = kDefaultCharging;
+ sRemainingTime = kDefaultRemainingTime;
+ }
+
+ /*
+ * We want to inform listeners if the following conditions are fulfilled:
+ * - we have at least one observer;
+ * - the charging state or the level has changed.
+ *
+ * Note: no need to check for a remaining time change given that it's only
+ * updated if there is a level change or a charging change.
+ *
+ * The idea is to prevent doing all the way to the DOM code in the child
+ * process to finally not send an event.
+ */
+ if (sNotificationsEnabled
+ && (previousCharging != isCharging() || previousLevel != getLevel())) {
+ onBatteryChange(getLevel(), isCharging(), getRemainingTime());
+ }
+ }
+
+ public static boolean isCharging() {
+ return sCharging;
+ }
+
+ public static double getLevel() {
+ return sLevel;
+ }
+
+ public static double getRemainingTime() {
+ return sRemainingTime;
+ }
+
+ public static void enableNotifications() {
+ sNotificationsEnabled = true;
+ }
+
+ public static void disableNotifications() {
+ sNotificationsEnabled = false;
+ }
+
+ public static double[] getCurrentInformation() {
+ return new double[] {getLevel(), isCharging() ? 1.0 : 0.0, getRemainingTime()};
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java
new file mode 100644
index 0000000000..8a76548c1d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java
@@ -0,0 +1,456 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.graphics.RectF;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import android.view.KeyEvent;
+import androidx.annotation.Nullable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * GeckoEditableChild implements the Gecko-facing side of IME operation. Each nsWindow in the main
+ * process and each PuppetWidget in each child content process has an instance of
+ * GeckoEditableChild, which communicates with the GeckoEditableParent instance in the main process.
+ */
+public final class GeckoEditableChild extends JNIObject implements IGeckoEditableChild {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoEditableChild";
+
+ private static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;
+
+ private final class RemoteChild extends IGeckoEditableChild.Stub {
+ @Override // IGeckoEditableChild
+ public void transferParent(final IGeckoEditableParent editableParent) {
+ GeckoEditableChild.this.transferParent(editableParent);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onKeyEvent(
+ final int action,
+ final int keyCode,
+ final int scanCode,
+ final int metaState,
+ final int keyPressMetaState,
+ final long time,
+ final int domPrintableKeyValue,
+ final int repeatCount,
+ final int flags,
+ final boolean isSynthesizedImeKey,
+ final KeyEvent event) {
+ GeckoEditableChild.this.onKeyEvent(
+ action,
+ keyCode,
+ scanCode,
+ metaState,
+ keyPressMetaState,
+ time,
+ domPrintableKeyValue,
+ repeatCount,
+ flags,
+ isSynthesizedImeKey,
+ event);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeSynchronize() {
+ GeckoEditableChild.this.onImeSynchronize();
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeReplaceText(final int start, final int end, final String text) {
+ GeckoEditableChild.this.onImeReplaceText(start, end, text);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeInsertImage(final byte[] data, final String mimeType) {
+ GeckoEditableChild.this.onImeInsertImage(data, mimeType);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeAddCompositionRange(
+ final int start,
+ final int end,
+ final int rangeType,
+ final int rangeStyles,
+ final int rangeLineStyle,
+ final boolean rangeBoldLine,
+ final int rangeForeColor,
+ final int rangeBackColor,
+ final int rangeLineColor) {
+ GeckoEditableChild.this.onImeAddCompositionRange(
+ start,
+ end,
+ rangeType,
+ rangeStyles,
+ rangeLineStyle,
+ rangeBoldLine,
+ rangeForeColor,
+ rangeBackColor,
+ rangeLineColor);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeUpdateComposition(final int start, final int end, final int flags) {
+ GeckoEditableChild.this.onImeUpdateComposition(start, end, flags);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeRequestCursorUpdates(final int requestMode) {
+ GeckoEditableChild.this.onImeRequestCursorUpdates(requestMode);
+ }
+
+ @Override // IGeckoEditableChild
+ public void onImeRequestCommit() {
+ GeckoEditableChild.this.onImeRequestCommit();
+ }
+ }
+
+ private final IGeckoEditableChild mEditableChild;
+ private final boolean mIsDefault;
+
+ private IGeckoEditableParent mEditableParent;
+ private int mCurrentTextLength; // Used by Gecko thread
+
+ @WrapForJNI(calledFrom = "gecko")
+ private GeckoEditableChild(
+ @Nullable final IGeckoEditableParent editableParent, final boolean isDefault) {
+ mIsDefault = isDefault;
+
+ if (editableParent != null
+ && editableParent.asBinder().queryLocalInterface(IGeckoEditableParent.class.getName())
+ != null) {
+ // IGeckoEditableParent is local; i.e. we're in the main process.
+ mEditableChild = this;
+ } else {
+ // IGeckoEditableParent is remote; i.e. we're in a content process.
+ mEditableChild = new RemoteChild();
+ }
+
+ if (editableParent != null) {
+ setParent(editableParent);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void setParent(final IGeckoEditableParent editableParent) {
+ mEditableParent = editableParent;
+
+ if (mIsDefault) {
+ // Tell the parent we're the default child.
+ try {
+ editableParent.setDefaultChild(mEditableChild);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Failed to set default child", e);
+ }
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void transferParent(IGeckoEditableParent editableParent);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onKeyEvent(
+ int action,
+ int keyCode,
+ int scanCode,
+ int metaState,
+ int keyPressMetaState,
+ long time,
+ int domPrintableKeyValue,
+ int repeatCount,
+ int flags,
+ boolean isSynthesizedImeKey,
+ KeyEvent event);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeSynchronize();
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeReplaceText(int start, int end, String text);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeAddCompositionRange(
+ int start,
+ int end,
+ int rangeType,
+ int rangeStyles,
+ int rangeLineStyle,
+ boolean rangeBoldLine,
+ int rangeForeColor,
+ int rangeBackColor,
+ int rangeLineColor);
+
+ // Don't update to the new composition if it's different than the current composition.
+ @WrapForJNI public static final int FLAG_KEEP_CURRENT_COMPOSITION = 1;
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeUpdateComposition(int start, int end, int flags);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeRequestCursorUpdates(int requestMode);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeRequestCommit();
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // IGeckoEditableChild
+ public native void onImeInsertImage(byte[] data, String mimeType);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private boolean hasEditableParent() {
+ if (mEditableParent != null) {
+ return true;
+ }
+ Log.w(LOGTAG, "No editable parent");
+ return false;
+ }
+
+ @Override // IInterface
+ public IBinder asBinder() {
+ // Return the GeckoEditableParent's binder as fallback for comparison purposes.
+ return mEditableChild != this
+ ? mEditableChild.asBinder()
+ : hasEditableParent() ? mEditableParent.asBinder() : null;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void notifyIME(final int type) {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ Log.d(LOGTAG, "notifyIME(" + type + ")");
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+ if (type == NOTIFY_IME_TO_CANCEL_COMPOSITION) {
+ // Composition should have been canceled on the parent side through text
+ // update notifications. We cannot verify that here because we don't
+ // keep track of spans on the child side, but it's simple to add the
+ // check to the parent side if ever needed.
+ return;
+ }
+
+ try {
+ mEditableParent.notifyIME(mEditableChild, type);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ return;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void notifyIMEContext(
+ final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ final int flags) {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ final StringBuilder sb = new StringBuilder("notifyIMEContext(");
+ sb.append(state)
+ .append(", \"")
+ .append(typeHint)
+ .append("\", \"")
+ .append(modeHint)
+ .append("\", \"")
+ .append(actionHint)
+ .append("\", \"")
+ .append(autocapitalize)
+ .append("\", 0x")
+ .append(Integer.toHexString(flags))
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+
+ try {
+ mEditableParent.notifyIMEContext(
+ mEditableChild.asBinder(), state, typeHint, modeHint, actionHint, autocapitalize, flags);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore")
+ private void onSelectionChange(
+ final int start, final int end, final boolean causedOnlyByComposition)
+ throws RemoteException {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ final StringBuilder sb = new StringBuilder("onSelectionChange(");
+ sb.append(start)
+ .append(", ")
+ .append(end)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+
+ final int currentLength = mCurrentTextLength;
+ if (start < 0 || start > currentLength || end < 0 || end > currentLength) {
+ Log.e(
+ LOGTAG,
+ "invalid selection notification range: "
+ + start
+ + " to "
+ + end
+ + ", length: "
+ + currentLength);
+ throw new IllegalArgumentException("invalid selection notification range");
+ }
+
+ mEditableParent.onSelectionChange(
+ mEditableChild.asBinder(), start, end, causedOnlyByComposition);
+ }
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore")
+ private void onTextChange(
+ final CharSequence text,
+ final int start,
+ final int unboundedOldEnd,
+ final int unboundedNewEnd,
+ final boolean causedOnlyByComposition)
+ throws RemoteException {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ final StringBuilder sb = new StringBuilder("onTextChange(");
+ sb.append(text)
+ .append(", ")
+ .append(start)
+ .append(", ")
+ .append(unboundedOldEnd)
+ .append(", ")
+ .append(unboundedNewEnd)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+
+ if (start < 0 || start > unboundedOldEnd) {
+ Log.e(LOGTAG, "invalid text notification range: " + start + " to " + unboundedOldEnd);
+ throw new IllegalArgumentException("invalid text notification range");
+ }
+
+ /* For the "end" parameters, Gecko can pass in a large
+ number to denote "end of the text". Fix that here */
+ final int currentLength = mCurrentTextLength;
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ // new end should always match text
+ if (unboundedOldEnd <= currentLength && unboundedNewEnd != (start + text.length())) {
+ Log.e(
+ LOGTAG,
+ "newEnd does not match text: " + unboundedNewEnd + " vs " + (start + text.length()));
+ throw new IllegalArgumentException("newEnd does not match text");
+ }
+
+ mCurrentTextLength += start + text.length() - oldEnd;
+ // Need unboundedOldEnd so GeckoEditable can distinguish changed text vs cleared text.
+ if (text.length() == 0) {
+ // Remove text in range.
+ mEditableParent.onTextChange(
+ mEditableChild.asBinder(), text, start, unboundedOldEnd, causedOnlyByComposition);
+ return;
+ }
+ // Using large text causes TransactionTooLargeException, so split text data.
+ int offset = 0;
+ int newUnboundedOldEnd = unboundedOldEnd;
+ while (offset < text.length()) {
+ final int end = Math.min(offset + 1024 * 64 /* 64KB */, text.length());
+ mEditableParent.onTextChange(
+ mEditableChild.asBinder(),
+ text.subSequence(offset, end),
+ start + offset,
+ newUnboundedOldEnd,
+ causedOnlyByComposition);
+ offset = end;
+ newUnboundedOldEnd = start + offset;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onDefaultKeyEvent(final KeyEvent event) {
+ if (DEBUG) {
+ // GeckoEditableListener methods should all be called from the Gecko thread
+ ThreadUtils.assertOnGeckoThread();
+ final StringBuilder sb = new StringBuilder("onDefaultKeyEvent(");
+ sb.append("action=")
+ .append(event.getAction())
+ .append(", ")
+ .append("keyCode=")
+ .append(event.getKeyCode())
+ .append(", ")
+ .append("metaState=")
+ .append(event.getMetaState())
+ .append(", ")
+ .append("time=")
+ .append(event.getEventTime())
+ .append(", ")
+ .append("repeatCount=")
+ .append(event.getRepeatCount())
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+
+ try {
+ mEditableParent.onDefaultKeyEvent(mEditableChild.asBinder(), event);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void updateCompositionRects(final RectF[] rects, final RectF caretRect) {
+ if (DEBUG) {
+ // GeckoEditableListener methods should all be called from the Gecko thread
+ ThreadUtils.assertOnGeckoThread();
+ Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")");
+ }
+ if (!hasEditableParent()) {
+ return;
+ }
+
+ try {
+ mEditableParent.updateCompositionRects(mEditableChild.asBinder(), rects, caretRect);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java
new file mode 100644
index 0000000000..c81e1678c7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java
@@ -0,0 +1,801 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.os.Build;
+import android.os.Looper;
+import android.os.Process;
+import android.os.SystemClock;
+import android.util.Log;
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.Set;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.GeckoResult;
+
+/**
+ * Takes samples and adds markers for Java threads for the Gecko profiler.
+ *
+ * <p>This class is thread safe because it uses synchronized on accesses to its mutable state. One
+ * exception is {@link #isProfilerActive()}: see the javadoc for details.
+ */
+public class GeckoJavaSampler {
+ private static final String LOGTAG = "GeckoJavaSampler";
+
+ /**
+ * The thread ID to use for the main thread instead of its true thread ID.
+ *
+ * <p>The main thread is sampled twice: once for native code and once on the JVM. The native
+ * version uses the thread's id so we replace it to avoid a collision. We use this thread ID
+ * because it's unlikely any other thread currently has it. We can't use 0 because 0 is considered
+ * "unspecified" in native code:
+ * https://searchfox.org/mozilla-central/rev/d4ebb53e719b913afdbcf7c00e162f0e96574701/mozglue/baseprofiler/public/BaseProfilerUtils.h#194
+ */
+ private static final long REPLACEMENT_MAIN_THREAD_ID = 1;
+ /**
+ * The thread name to use for the main thread instead of its true thread name. The name is "main",
+ * which is ambiguous with the JS main thread, so we rename it to match the C++ replacement. We
+ * expect our code to later add a suffix to avoid a collision with the C++ thread name. See {@link
+ * #REPLACEMENT_MAIN_THREAD_ID} for related details.
+ */
+ private static final String REPLACEMENT_MAIN_THREAD_NAME = "AndroidUI";
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private static SamplingRunnable sSamplingRunnable;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private static ScheduledExecutorService sSamplingScheduler;
+
+ // See isProfilerActive for details on the AtomicReference.
+ @GuardedBy("GeckoJavaSampler.class")
+ private static final AtomicReference<ScheduledFuture<?>> sSamplingFuture =
+ new AtomicReference<>();
+
+ private static final MarkerStorage sMarkerStorage = new MarkerStorage();
+
+ /**
+ * Returns true if profiler is running and unpaused at the moment which means it's allowed to add
+ * a marker.
+ *
+ * <p>Thread policy: we want this method to be inexpensive (i.e. non-blocking) because we want to
+ * be able to use it in performance-sensitive code. That's why we rely on an AtomicReference. If
+ * this requirement didn't exist, the AtomicReference could be removed because the class thread
+ * policy is to call synchronized on mutable state access.
+ */
+ public static boolean isProfilerActive() {
+ // This value will only be present if the profiler is started and not paused.
+ return sSamplingFuture.get() != null;
+ }
+
+ // Use the same timer primitive as the profiler
+ // to get a perfect sample syncing.
+ @WrapForJNI
+ private static native double getProfilerTime();
+
+ /** Try to get the profiler time. Returns null if profiler is not running. */
+ public static @Nullable Double tryToGetProfilerTime() {
+ if (!isProfilerActive()) {
+ // Android profiler hasn't started yet.
+ return null;
+ }
+ if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ return null;
+ }
+
+ return getProfilerTime();
+ }
+
+ /**
+ * A data container for a profiler sample. This class is effectively immutable (i.e. technically
+ * mutable but never mutated after construction) so is thread safe *if it is safely published*
+ * (see Java Concurrency in Practice, 2nd Ed., Section 3.5.3 for safe publication idioms).
+ */
+ private static class Sample {
+ public final long mThreadId;
+ public final Frame[] mFrames;
+ public final double mTime;
+ public final long mJavaTime; // non-zero if Android system time is used
+
+ public Sample(final long aThreadId, final StackTraceElement[] aStack) {
+ mThreadId = aThreadId;
+ mFrames = new Frame[aStack.length];
+ mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+
+ for (int i = 0; i < aStack.length; i++) {
+ mFrames[aStack.length - 1 - i] =
+ new Frame(aStack[i].getMethodName(), aStack[i].getClassName());
+ }
+ }
+ }
+
+ /**
+ * A container for the metadata around a call in a stack. This class is thread safe by being
+ * immutable.
+ */
+ private static class Frame {
+ public final String methodName;
+ public final String className;
+
+ private Frame(final String methodName, final String className) {
+ this.methodName = methodName;
+ this.className = className;
+ }
+ }
+
+ /** A data container for thread metadata. */
+ private static class ThreadInfo {
+ private final long mId;
+ private final String mName;
+
+ public ThreadInfo(final long mId, final String mName) {
+ this.mId = mId;
+ this.mName = mName;
+ }
+
+ @WrapForJNI
+ public long getId() {
+ return mId;
+ }
+
+ @WrapForJNI
+ public String getName() {
+ return mName;
+ }
+ }
+
+ /**
+ * A data container for metadata around a marker. This class is thread safe by being immutable.
+ */
+ private static class Marker extends JNIObject {
+ /** The id of the thread this marker was captured on. */
+ private final long mThreadId;
+
+ /** Name of the marker */
+ private final String mMarkerName;
+ /** Either start time for the duration markers or time for a point-in-time markers. */
+ private final double mTime;
+ /**
+ * A fallback field of {@link #mTime} but it only exists when {@link #getProfilerTime()} is
+ * failed. It is non-zero if Android time is used.
+ */
+ private final long mJavaTime;
+ /** End time for the duration markers. It's zero for point-in-time markers. */
+ private final double mEndTime;
+ /**
+ * A fallback field of {@link #mEndTime} but it only exists when {@link #getProfilerTime()} is
+ * failed. It is non-zero if Android time is used.
+ */
+ private final long mEndJavaTime;
+ /** A nullable additional information field for the marker. */
+ private @Nullable final String mText;
+
+ /**
+ * Constructor for the Marker class. It initializes different kinds of markers depending on the
+ * parameters. Here are some combinations to create different kinds of markers:
+ *
+ * <p>If you want to create a marker that points a single point in time: <code>
+ * new Marker("name", null, null, null)</code> to implicitly get the time when this marker is
+ * added, or <code>new Marker("name", null, endTime, null)</code> to use an explicit time as an
+ * end time retrieved from {@link #tryToGetProfilerTime()}.
+ *
+ * <p>If you want to create a marker that has a start and end time: <code>
+ * new Marker("name", startTime, null, null)</code> to implicitly get the end time when this
+ * marker is added, or <code>new Marker("name", startTime, endTime, null)</code> to explicitly
+ * give the marker start and end time retrieved from {@link #tryToGetProfilerTime()}.
+ *
+ * <p>Last parameter is optional and can be given with any combination. This gives users the
+ * ability to add more context into a marker.
+ *
+ * @param aThreadId The id of the thread this marker was captured on.
+ * @param aMarkerName Identifier of the marker as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public Marker(
+ final long aThreadId,
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ mThreadId = getAdjustedThreadId(aThreadId);
+ mMarkerName = aMarkerName;
+ mText = aText;
+
+ if (aStartTime != null) {
+ // Start time is provided. This is an interval marker.
+ mTime = aStartTime;
+ mJavaTime = 0;
+ if (aEndTime != null) {
+ // End time is also provided.
+ mEndTime = aEndTime;
+ mEndJavaTime = 0;
+ } else {
+ // End time is not provided. Get the profiler time now and use it.
+ mEndTime =
+ GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mEndTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mEndJavaTime = mEndTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+ }
+
+ } else {
+ // Start time is not provided. This is point-in-time marker.
+ mEndTime = 0;
+ mEndJavaTime = 0;
+
+ if (aEndTime != null) {
+ // End time is also provided. Use that to point the time.
+ mTime = aEndTime;
+ mJavaTime = 0;
+ } else {
+ mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+ }
+ }
+ }
+
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ @WrapForJNI
+ public double getStartTime() {
+ if (mJavaTime != 0) {
+ return (mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return mTime;
+ }
+
+ @WrapForJNI
+ public double getEndTime() {
+ if (mEndJavaTime != 0) {
+ return (mEndJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return mEndTime;
+ }
+
+ @WrapForJNI
+ public long getThreadId() {
+ return mThreadId;
+ }
+
+ @WrapForJNI
+ public @NonNull String getMarkerName() {
+ return mMarkerName;
+ }
+
+ @WrapForJNI
+ public @Nullable String getMarkerText() {
+ return mText;
+ }
+ }
+
+ /**
+ * Public method to add a new marker to Gecko profiler. This can be used to add a marker *inside*
+ * the geckoview code, but ideally ProfilerController methods should be used instead.
+ *
+ * @see Marker#Marker(long, String, Double, Double, String) for information about the parameter
+ * options.
+ */
+ public static void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ sMarkerStorage.addMarker(aMarkerName, aStartTime, aEndTime, aText);
+ }
+
+ /**
+ * A routine to store profiler samples. This class is thread safe because it synchronizes access
+ * to its mutable state.
+ */
+ private static class SamplingRunnable implements Runnable {
+ private final long mMainThreadId = Looper.getMainLooper().getThread().getId();
+
+ // Sampling interval that is used by start and unpause
+ public final int mInterval;
+ private final int mSampleCount;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private boolean mBufferOverflowed = false;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private @NonNull final List<Thread> mThreadsToProfile;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private final Sample[] mSamples;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private int mSamplePos;
+
+ public SamplingRunnable(
+ @NonNull final List<Thread> aThreadsToProfile,
+ final int aInterval,
+ final int aSampleCount) {
+ mThreadsToProfile = aThreadsToProfile;
+ // Sanity check of sampling interval.
+ mInterval = Math.max(1, aInterval);
+ mSampleCount = aSampleCount;
+ mSamples = new Sample[mSampleCount];
+ mSamplePos = 0;
+ }
+
+ @Override
+ public void run() {
+ synchronized (GeckoJavaSampler.class) {
+ // To minimize allocation in the critical section, we use a traditional for loop instead of
+ // a for each (i.e. `elem : coll`) loop because that allocates an iterator.
+ //
+ // We won't capture threads that are started during profiling because we iterate through an
+ // unchanging list of threads (bug 1759550).
+ for (int i = 0; i < mThreadsToProfile.size(); i++) {
+ final Thread thread = mThreadsToProfile.get(i);
+
+ // getStackTrace will return an empty trace if the thread is not alive: we call continue
+ // to avoid wasting space in the buffer for an empty sample.
+ final StackTraceElement[] stackTrace = thread.getStackTrace();
+ if (stackTrace.length == 0) {
+ continue;
+ }
+
+ mSamples[mSamplePos] = new Sample(thread.getId(), stackTrace);
+ mSamplePos += 1;
+ if (mSamplePos == mSampleCount) {
+ // Sample array is full now, go back to start of
+ // the array and override old samples
+ mSamplePos = 0;
+ mBufferOverflowed = true;
+ }
+ }
+ }
+ }
+
+ private Sample getSample(final int aSampleId) {
+ synchronized (GeckoJavaSampler.class) {
+ if (aSampleId >= mSampleCount) {
+ // Return early because there is no more sample left.
+ return null;
+ }
+
+ int samplePos = aSampleId;
+ if (mBufferOverflowed) {
+ // This is a circular buffer and the buffer is overflowed. Start
+ // of the buffer is mSamplePos now. Calculate the real index.
+ samplePos = (samplePos + mSamplePos) % mSampleCount;
+ }
+
+ // Since the array elements are initialized to null, it will return
+ // null whenever we access to an element that's not been written yet.
+ // We want it to return null in that case, so it's okay.
+ return mSamples[samplePos];
+ }
+ }
+ }
+
+ /**
+ * Returns the sample with the given sample ID.
+ *
+ * <p>Thread safety code smell: this method call is synchronized but this class returns a
+ * reference to an effectively immutable object so that the reference is accessible after
+ * synchronization ends. It's unclear if this is thread safe. However, this is safe with the
+ * current callers (because they are all synchronized and don't leak the Sample) so we don't
+ * investigate it further.
+ */
+ private static synchronized Sample getSample(final int aSampleId) {
+ return sSamplingRunnable.getSample(aSampleId);
+ }
+
+ @WrapForJNI
+ public static Marker pollNextMarker() {
+ return sMarkerStorage.pollNextMarker();
+ }
+
+ @WrapForJNI
+ public static synchronized int getRegisteredThreadCount() {
+ return sSamplingRunnable.mThreadsToProfile.size();
+ }
+
+ @WrapForJNI
+ public static synchronized ThreadInfo getRegisteredThreadInfo(final int aIndex) {
+ final Thread thread = sSamplingRunnable.mThreadsToProfile.get(aIndex);
+
+ // See REPLACEMENT_MAIN_THREAD_NAME for why we do this.
+ String adjustedThreadName =
+ thread.getId() == sSamplingRunnable.mMainThreadId
+ ? REPLACEMENT_MAIN_THREAD_NAME
+ : thread.getName();
+
+ // To distinguish JVM threads from native threads, we append a JVM-specific suffix.
+ adjustedThreadName += " (JVM)";
+ return new ThreadInfo(getAdjustedThreadId(thread.getId()), adjustedThreadName);
+ }
+
+ @WrapForJNI
+ public static synchronized long getThreadId(final int aSampleId) {
+ final Sample sample = getSample(aSampleId);
+ return getAdjustedThreadId(sample != null ? sample.mThreadId : 0);
+ }
+
+ private static synchronized long getAdjustedThreadId(final long threadId) {
+ // See REPLACEMENT_MAIN_THREAD_ID for why we do this.
+ return threadId == sSamplingRunnable.mMainThreadId ? REPLACEMENT_MAIN_THREAD_ID : threadId;
+ }
+
+ @WrapForJNI
+ public static synchronized double getSampleTime(final int aSampleId) {
+ final Sample sample = getSample(aSampleId);
+ if (sample != null) {
+ if (sample.mJavaTime != 0) {
+ return (sample.mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return sample.mTime;
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public static synchronized String getFrameName(final int aSampleId, final int aFrameId) {
+ final Sample sample = getSample(aSampleId);
+ if (sample != null && aFrameId < sample.mFrames.length) {
+ final Frame frame = sample.mFrames[aFrameId];
+ if (frame == null) {
+ return null;
+ }
+ return frame.className + "." + frame.methodName + "()";
+ }
+ return null;
+ }
+
+ /**
+ * A start/stop-aware container for storing profiler markers.
+ *
+ * <p>This class is thread safe: see {@link #mMarkers} and other member variables for the
+ * threading policy. Start/stop are guaranteed to execute in the order they are called but other
+ * methods do not have such ordering guarantees.
+ */
+ private static class MarkerStorage {
+ /**
+ * The underlying storage for the markers. This field maintains thread safety without using
+ * synchronized everywhere by:
+ * <li>- using volatile to allow non-blocking reads
+ * <li>- leveraging a thread safe collection when accessing the underlying data
+ * <li>- looping until success for compound read-write operations
+ */
+ private volatile Queue<Marker> mMarkers;
+
+ /**
+ * The thread ids of the threads we're profiling. This field maintains thread safety by writing
+ * a read-only value to this volatile field before concurrency begins and only reading it during
+ * concurrent sections.
+ */
+ private volatile Set<Long> mProfiledThreadIds = Collections.emptySet();
+
+ MarkerStorage() {}
+
+ public synchronized void start(final int aMarkerCount, final List<Thread> aProfiledThreads) {
+ if (this.mMarkers != null) {
+ return;
+ }
+ this.mMarkers = new LinkedBlockingQueue<>(aMarkerCount);
+
+ final Set<Long> profiledThreadIds = new HashSet<>(aProfiledThreads.size());
+ for (final Thread thread : aProfiledThreads) {
+ profiledThreadIds.add(thread.getId());
+ }
+
+ // We use a temporary collection, rather than mutating the collection within the member
+ // variable, to ensure the collection is fully written before the state is made available to
+ // all threads via the volatile write into the member variable. This collection must be
+ // read-only for it to remain thread safe.
+ mProfiledThreadIds = Collections.unmodifiableSet(profiledThreadIds);
+ }
+
+ public synchronized void stop() {
+ if (this.mMarkers == null) {
+ return;
+ }
+ this.mMarkers = null;
+ mProfiledThreadIds = Collections.emptySet();
+ }
+
+ private void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ final Queue<Marker> markersQueue = this.mMarkers;
+ if (markersQueue == null) {
+ // Profiler is not active.
+ return;
+ }
+
+ final long threadId = Thread.currentThread().getId();
+ if (!mProfiledThreadIds.contains(threadId)) {
+ return;
+ }
+
+ final Marker newMarker = new Marker(threadId, aMarkerName, aStartTime, aEndTime, aText);
+ boolean successful = markersQueue.offer(newMarker);
+ while (!successful) {
+ // Marker storage is full, remove the head and add again.
+ markersQueue.poll();
+ successful = markersQueue.offer(newMarker);
+ }
+ }
+
+ private Marker pollNextMarker() {
+ final Queue<Marker> markersQueue = this.mMarkers;
+ if (markersQueue == null) {
+ // Profiler is not active.
+ return null;
+ }
+ // Retrieve and return the head of this queue.
+ // Returns null if the queue is empty.
+ return markersQueue.poll();
+ }
+ }
+
+ @WrapForJNI
+ public static void start(
+ @NonNull final Object[] aFilters, final int aInterval, final int aEntryCount) {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable != null) {
+ return;
+ }
+
+ final ScheduledFuture<?> future = sSamplingFuture.get();
+ if (future != null && !future.isDone()) {
+ return;
+ }
+
+ Log.i(LOGTAG, "Profiler starting. Calling thread: " + Thread.currentThread().getName());
+
+ // Setting a limit of 120000 (2 mins with 1ms interval) for samples and markers for now
+ // to make sure we are not allocating too much.
+ final int limitedEntryCount = Math.min(aEntryCount, 120000);
+
+ final List<Thread> threadsToProfile = getThreadsToProfile(aFilters);
+ if (threadsToProfile.size() < 1) {
+ throw new IllegalStateException("Expected >= 1 thread to profile (main thread).");
+ }
+ Log.i(LOGTAG, "Number of threads to profile: " + threadsToProfile.size());
+
+ sSamplingRunnable = new SamplingRunnable(threadsToProfile, aInterval, limitedEntryCount);
+ sMarkerStorage.start(limitedEntryCount, threadsToProfile);
+ sSamplingScheduler = Executors.newSingleThreadScheduledExecutor();
+ sSamplingFuture.set(
+ sSamplingScheduler.scheduleAtFixedRate(
+ sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS));
+ }
+ }
+
+ private static @NonNull List<Thread> getThreadsToProfile(final Object[] aFilters) {
+ // Clean up filters.
+ final List<String> cleanedFilters = new ArrayList<>();
+ for (final Object rawFilter : aFilters) {
+ // aFilters is a String[] but jni can only accept Object[] so we're forced to cast.
+ //
+ // We could pass the lowercased filters from native code but it may not handle lowercasing the
+ // same way Java does so we lower case here so it's consistent later when we lower case the
+ // thread name and compare against it.
+ final String filter = ((String) rawFilter).trim().toLowerCase(Locale.US);
+
+ // If the filter is empty, it's not meaningful: skip.
+ if (filter.isEmpty()) {
+ continue;
+ }
+
+ cleanedFilters.add(filter);
+ }
+
+ final ThreadGroup rootThreadGroup = getRootThreadGroup();
+ final Thread[] activeThreads = getActiveThreads(rootThreadGroup);
+ final Thread mainThread = Looper.getMainLooper().getThread();
+
+ // We model these catch-all filters after the C++ code (which we should eventually deduplicate):
+ // https://searchfox.org/mozilla-central/rev/b0779bcc485dc1c04334dfb9ea024cbfff7b961a/tools/profiler/core/platform.cpp#778-801
+ if (cleanedFilters.contains("*") || doAnyFiltersMatchPid(cleanedFilters, Process.myPid())) {
+ final List<Thread> activeThreadList = new ArrayList<>();
+ Collections.addAll(activeThreadList, activeThreads);
+ if (!activeThreadList.contains(mainThread)) {
+ activeThreadList.add(mainThread); // see below for why this is necessary.
+ }
+ return activeThreadList;
+ }
+
+ // We always want to profile the main thread. We're not certain getActiveThreads returns
+ // all active threads since we've observed that getActiveThreads doesn't include the main thread
+ // during xpcshell tests even though it's alive (bug 1760716). We intentionally don't rely on
+ // that method to add the main thread here.
+ final List<Thread> threadsToProfile = new ArrayList<>();
+ threadsToProfile.add(mainThread);
+
+ for (final Thread thread : activeThreads) {
+ if (shouldProfileThread(thread, cleanedFilters, mainThread)) {
+ threadsToProfile.add(thread);
+ }
+ }
+ return threadsToProfile;
+ }
+
+ private static boolean shouldProfileThread(
+ final Thread aThread, final List<String> aFilters, final Thread aMainThread) {
+ final String threadName = aThread.getName().trim().toLowerCase(Locale.US);
+ if (threadName.isEmpty()) {
+ return false; // We can't match against a thread with no name: skip.
+ }
+
+ if (aThread.equals(aMainThread)) {
+ return false; // We've already added the main thread outside of this method.
+ }
+
+ for (final String filter : aFilters) {
+ // In order to generically support thread pools with thread names like "arch_disk_io_0" (the
+ // kotlin IO dispatcher), we check if the filter is inside the thread name (e.g. a filter of
+ // "io" will match all of the threads in that pool) rather than an equality check.
+ if (threadName.contains(filter)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean doAnyFiltersMatchPid(
+ @NonNull final List<String> aFilters, final long aPid) {
+ final String prefix = "pid:";
+ for (final String filter : aFilters) {
+ if (!filter.startsWith(prefix)) {
+ continue;
+ }
+
+ try {
+ final long filterPid = Long.parseLong(filter.substring(prefix.length()));
+ if (filterPid == aPid) {
+ return true;
+ }
+ } catch (final NumberFormatException e) {
+ /* do nothing. */
+ }
+ }
+
+ return false;
+ }
+
+ private static @NonNull Thread[] getActiveThreads(final @NonNull ThreadGroup rootThreadGroup) {
+ // We need the root thread group to get all of the active threads because of how
+ // ThreadGroup.enumerate works.
+ //
+ // ThreadGroup.enumerate is inherently racey so we loop until we capture all of the active
+ // threads. We can only detect if we didn't capture all of the threads if the number of threads
+ // found (the value returned by enumerate) is smaller than the array we're capturing them in.
+ // Therefore, we make the array slightly larger than the known number of threads.
+ Thread[] allThreads;
+ int threadsFound;
+ do {
+ allThreads = new Thread[rootThreadGroup.activeCount() + 15];
+ threadsFound = rootThreadGroup.enumerate(allThreads, /* recurse */ true);
+ } while (threadsFound >= allThreads.length);
+
+ // There will be more indices in the array than threads and these will be set to null. We remove
+ // the null values to minimize bugs.
+ return Arrays.copyOfRange(allThreads, 0, threadsFound);
+ }
+
+ private static @NonNull ThreadGroup getRootThreadGroup() {
+ // Assert non-null: getThreadGroup only returns null for dead threads but the current thread
+ // can't be dead.
+ ThreadGroup parentGroup = Objects.requireNonNull(Thread.currentThread().getThreadGroup());
+
+ ThreadGroup group = null;
+ while (parentGroup != null) {
+ group = parentGroup;
+ parentGroup = group.getParent();
+ }
+ return group;
+ }
+
+ @WrapForJNI
+ public static void pauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ final ScheduledFuture<?> future = sSamplingFuture.getAndSet(null);
+ future.cancel(false /* mayInterruptIfRunning */);
+ }
+ }
+
+ @WrapForJNI
+ public static void unpauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingFuture.get() != null) {
+ return;
+ }
+ sSamplingFuture.set(
+ sSamplingScheduler.scheduleAtFixedRate(
+ sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS));
+ }
+ }
+
+ @WrapForJNI
+ public static void stop() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable == null) {
+ return;
+ }
+
+ Log.i(
+ LOGTAG,
+ "Profiler stopping. Sample array position: "
+ + sSamplingRunnable.mSamplePos
+ + ". Overflowed? "
+ + sSamplingRunnable.mBufferOverflowed);
+
+ try {
+ sSamplingScheduler.shutdown();
+ // 1s is enough to wait shutdown.
+ sSamplingScheduler.awaitTermination(1000, TimeUnit.MILLISECONDS);
+ } catch (final InterruptedException e) {
+ Log.e(LOGTAG, "Sampling scheduler isn't terminated. Last sampling data might be broken.");
+ sSamplingScheduler.shutdownNow();
+ }
+ sSamplingScheduler = null;
+ sSamplingRunnable = null;
+ sSamplingFuture.set(null);
+ sMarkerStorage.stop();
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "StartProfiler")
+ private static native void startProfilerNative(String[] aFilters, String[] aFeaturesArr);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "StopProfiler")
+ private static native void stopProfilerNative(GeckoResult<byte[]> aResult);
+
+ public static void startProfiler(final String[] aFilters, final String[] aFeaturesArr) {
+ startProfilerNative(aFilters, aFeaturesArr);
+ }
+
+ public static GeckoResult<byte[]> stopProfiler() {
+ final GeckoResult<byte[]> result = new GeckoResult<byte[]>();
+ stopProfilerNative(result);
+ return result;
+ }
+
+ /** Returns the device brand and model as a string. */
+ @WrapForJNI
+ public static String getDeviceInformation() {
+ final StringBuilder sb = new StringBuilder(Build.BRAND);
+ sb.append(" ");
+ sb.append(Build.MODEL);
+ return sb.toString();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java
new file mode 100644
index 0000000000..02ed848f6b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java
@@ -0,0 +1,413 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.DhcpInfo;
+import android.net.wifi.WifiManager;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.NetworkUtils;
+import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType;
+import org.mozilla.gecko.util.NetworkUtils.ConnectionType;
+import org.mozilla.gecko.util.NetworkUtils.NetworkStatus;
+
+/**
+ * Provides connection type, subtype and general network status (up/down).
+ *
+ * <p>According to spec of Network Information API version 3, connection types include: bluetooth,
+ * cellular, ethernet, none, wifi and other. The objective of providing such general connection is
+ * due to some security concerns. In short, we don't want to expose exact network type, especially
+ * the cellular network type.
+ *
+ * <p>Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets.
+ *
+ * <p>Logic is implemented as a state machine, so see the transition matrix to figure out what
+ * happens when. This class depends on access to the context, so only use after GeckoAppShell has
+ * been initialized.
+ */
+public class GeckoNetworkManager extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoNetworkManager";
+
+ // If network configuration and/or status changed, we send details of what changed.
+ // If we received a "check out new network state!" intent from the OS but nothing in it looks
+ // different, we ignore it. See Bug 1330836 for some relevant details.
+ private static final String LINK_DATA_CHANGED = "changed";
+
+ private static GeckoNetworkManager instance;
+
+ // We hackishly (yet harmlessly, in this case) keep a Context reference passed in via the start
+ // method.
+ // See context handling notes in handleManagerEvent, and Bug 1277333.
+ private Context mContext;
+
+ public static void destroy() {
+ if (instance != null) {
+ instance.onDestroy();
+ instance = null;
+ }
+ }
+
+ public enum ManagerState {
+ OffNoListeners,
+ OffWithListeners,
+ OnNoListeners,
+ OnWithListeners
+ }
+
+ public enum ManagerEvent {
+ start,
+ stop,
+ enableNotifications,
+ disableNotifications,
+ receivedUpdate
+ }
+
+ private ManagerState mCurrentState = ManagerState.OffNoListeners;
+ private ConnectionType mCurrentConnectionType = ConnectionType.NONE;
+ private ConnectionType mPreviousConnectionType = ConnectionType.NONE;
+ private ConnectionSubType mCurrentConnectionSubtype = ConnectionSubType.UNKNOWN;
+ private ConnectionSubType mPreviousConnectionSubtype = ConnectionSubType.UNKNOWN;
+ private NetworkStatus mCurrentNetworkStatus = NetworkStatus.UNKNOWN;
+ private NetworkStatus mPreviousNetworkStatus = NetworkStatus.UNKNOWN;
+
+ private GeckoNetworkManager() {}
+
+ private void onDestroy() {
+ handleManagerEvent(ManagerEvent.stop);
+ }
+
+ public static GeckoNetworkManager getInstance() {
+ if (instance == null) {
+ instance = new GeckoNetworkManager();
+ }
+
+ return instance;
+ }
+
+ public double[] getCurrentInformation() {
+ final Context applicationContext = GeckoAppShell.getApplicationContext();
+ final ConnectionType connectionType = mCurrentConnectionType;
+ return new double[] {
+ connectionType.value,
+ connectionType == ConnectionType.WIFI ? 1.0 : 0.0,
+ connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0
+ };
+ }
+
+ @Override
+ public void onReceive(final Context aContext, final Intent aIntent) {
+ handleManagerEvent(ManagerEvent.receivedUpdate);
+ }
+
+ public void start(final Context context) {
+ mContext = context;
+ handleManagerEvent(ManagerEvent.start);
+ }
+
+ public void stop() {
+ handleManagerEvent(ManagerEvent.stop);
+ }
+
+ public void enableNotifications() {
+ handleManagerEvent(ManagerEvent.enableNotifications);
+ }
+
+ public void disableNotifications() {
+ handleManagerEvent(ManagerEvent.disableNotifications);
+ }
+
+ /**
+ * For a given event, figure out the next state, run any transition by-product actions, and switch
+ * current state to the next state. If event is invalid for the current state, this is a no-op.
+ *
+ * @param event Incoming event
+ * @return Boolean indicating if transition was performed.
+ */
+ private synchronized boolean handleManagerEvent(final ManagerEvent event) {
+ final ManagerState nextState = getNextState(mCurrentState, event);
+
+ Log.d(LOGTAG, "Incoming event " + event + " for state " + mCurrentState + " -> " + nextState);
+ if (nextState == null) {
+ Log.w(LOGTAG, "Invalid event " + event + " for state " + mCurrentState);
+ return false;
+ }
+
+ // We're being deliberately careful about handling context here; it's possible that in some
+ // rare cases and possibly related to timing of when this is called (seems to be early in the
+ // startup phase),
+ // GeckoAppShell.getApplicationContext() will be null, and .start() wasn't called yet,
+ // so we don't have a local Context reference either. If both of these are true, we have to drop
+ // the event.
+ // NB: this is hacky (and these checks attempt to isolate the hackiness), and root cause
+ // seems to be how this class fits into the larger ecosystem and general flow of events.
+ // See Bug 1277333.
+ final Context contextForAction;
+ if (mContext != null) {
+ contextForAction = mContext;
+ } else {
+ contextForAction = GeckoAppShell.getApplicationContext();
+ }
+
+ if (contextForAction == null) {
+ Log.w(
+ LOGTAG,
+ "Context is not available while processing event "
+ + event
+ + " for state "
+ + mCurrentState);
+ return false;
+ }
+
+ performActionsForStateEvent(contextForAction, mCurrentState, event);
+ mCurrentState = nextState;
+
+ return true;
+ }
+
+ /**
+ * Defines a transition matrix for our state machine. For a given state/event pair, returns
+ * nextState.
+ *
+ * @param currentState Current state against which we have an incoming event
+ * @param event Incoming event for which we'd like to figure out the next state
+ * @return State into which we should transition as result of given event
+ */
+ @Nullable
+ public static ManagerState getNextState(
+ final @NonNull ManagerState currentState, final @NonNull ManagerEvent event) {
+ switch (currentState) {
+ case OffNoListeners:
+ switch (event) {
+ case start:
+ return ManagerState.OnNoListeners;
+ case enableNotifications:
+ return ManagerState.OffWithListeners;
+ default:
+ return null;
+ }
+ case OnNoListeners:
+ switch (event) {
+ case stop:
+ return ManagerState.OffNoListeners;
+ case enableNotifications:
+ return ManagerState.OnWithListeners;
+ case receivedUpdate:
+ return ManagerState.OnNoListeners;
+ default:
+ return null;
+ }
+ case OnWithListeners:
+ switch (event) {
+ case stop:
+ return ManagerState.OffWithListeners;
+ case disableNotifications:
+ return ManagerState.OnNoListeners;
+ case receivedUpdate:
+ return ManagerState.OnWithListeners;
+ default:
+ return null;
+ }
+ case OffWithListeners:
+ switch (event) {
+ case start:
+ return ManagerState.OnWithListeners;
+ case disableNotifications:
+ return ManagerState.OffNoListeners;
+ default:
+ return null;
+ }
+ default:
+ throw new IllegalStateException("Unknown current state: " + currentState.name());
+ }
+ }
+
+ /**
+ * For a given state/event combination, run any actions which are by-products of leaving the state
+ * because of a given event. Since this is a deterministic state machine, we can easily do that
+ * without any additional information.
+ *
+ * @param currentState State which we are leaving
+ * @param event Event which is causing us to leave the state
+ */
+ private void performActionsForStateEvent(
+ final Context context, final ManagerState currentState, final ManagerEvent event) {
+ // NB: network state might be queried via getCurrentInformation at any time; pre-rewrite
+ // behaviour was
+ // that network state was updated whenever enableNotifications was called. To avoid deviating
+ // from previous behaviour and causing weird side-effects, we call
+ // updateNetworkStateAndConnectionType
+ // whenever notifications are enabled.
+ switch (currentState) {
+ case OffNoListeners:
+ if (event == ManagerEvent.start) {
+ updateNetworkStateAndConnectionType(context);
+ registerBroadcastReceiver(context, this);
+ }
+ if (event == ManagerEvent.enableNotifications) {
+ updateNetworkStateAndConnectionType(context);
+ }
+ break;
+ case OnNoListeners:
+ if (event == ManagerEvent.receivedUpdate) {
+ updateNetworkStateAndConnectionType(context);
+ sendNetworkStateToListeners(context);
+ }
+ if (event == ManagerEvent.enableNotifications) {
+ updateNetworkStateAndConnectionType(context);
+ registerBroadcastReceiver(context, this);
+ }
+ if (event == ManagerEvent.stop) {
+ unregisterBroadcastReceiver(context, this);
+ }
+ break;
+ case OnWithListeners:
+ if (event == ManagerEvent.receivedUpdate) {
+ updateNetworkStateAndConnectionType(context);
+ sendNetworkStateToListeners(context);
+ }
+ if (event == ManagerEvent.stop) {
+ unregisterBroadcastReceiver(context, this);
+ }
+ /* no-op event: ManagerEvent.disableNotifications */
+ break;
+ case OffWithListeners:
+ if (event == ManagerEvent.start) {
+ registerBroadcastReceiver(context, this);
+ }
+ /* no-op event: ManagerEvent.disableNotifications */
+ break;
+ default:
+ throw new IllegalStateException("Unknown current state: " + currentState.name());
+ }
+ }
+
+ /** Update current network state and connection types. */
+ private void updateNetworkStateAndConnectionType(final Context context) {
+ final ConnectivityManager connectivityManager =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ // Type/status getters below all have a defined behaviour for when connectivityManager == null
+ if (connectivityManager == null) {
+ Log.e(LOGTAG, "ConnectivityManager does not exist.");
+ }
+ mCurrentConnectionType = NetworkUtils.getConnectionType(connectivityManager);
+ mCurrentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager);
+ mCurrentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager);
+ Log.d(
+ LOGTAG,
+ "New network state: "
+ + mCurrentNetworkStatus
+ + ", "
+ + mCurrentConnectionType
+ + ", "
+ + mCurrentConnectionSubtype);
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void onConnectionChanged(
+ int type, String subType, boolean isWifi, int dhcpGateway);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void onStatusChanged(String status);
+
+ /** Send current network state and connection type to whomever is listening. */
+ private void sendNetworkStateToListeners(final Context context) {
+ final boolean connectionTypeOrSubtypeChanged =
+ mCurrentConnectionType != mPreviousConnectionType
+ || mCurrentConnectionSubtype != mPreviousConnectionSubtype;
+ if (connectionTypeOrSubtypeChanged) {
+ mPreviousConnectionType = mCurrentConnectionType;
+ mPreviousConnectionSubtype = mCurrentConnectionSubtype;
+
+ final boolean isWifi = mCurrentConnectionType == ConnectionType.WIFI;
+ final int gateway = !isWifi ? 0 : wifiDhcpGatewayAddress(context);
+
+ if (GeckoThread.isRunning()) {
+ onConnectionChanged(
+ mCurrentConnectionType.value, mCurrentConnectionSubtype.value, isWifi, gateway);
+ } else {
+ GeckoThread.queueNativeCall(
+ GeckoNetworkManager.class,
+ "onConnectionChanged",
+ mCurrentConnectionType.value,
+ String.class,
+ mCurrentConnectionSubtype.value,
+ isWifi,
+ gateway);
+ }
+ }
+
+ // If neither network status nor network configuration changed, do nothing.
+ if (mCurrentNetworkStatus == mPreviousNetworkStatus && !connectionTypeOrSubtypeChanged) {
+ return;
+ }
+
+ // If network status remains the same, send "changed". Otherwise, send new network status.
+ // See Bug 1330836 for relevant details.
+ final String status;
+ if (mCurrentNetworkStatus == mPreviousNetworkStatus) {
+ status = LINK_DATA_CHANGED;
+ } else {
+ mPreviousNetworkStatus = mCurrentNetworkStatus;
+ status = mCurrentNetworkStatus.value;
+ }
+
+ if (GeckoThread.isRunning()) {
+ onStatusChanged(status);
+ } else {
+ GeckoThread.queueNativeCall(
+ GeckoNetworkManager.class, "onStatusChanged", String.class, status);
+ }
+ }
+
+ /** Stop listening for network state updates. */
+ private static void unregisterBroadcastReceiver(
+ final Context context, final BroadcastReceiver receiver) {
+ context.unregisterReceiver(receiver);
+ }
+
+ /** Start listening for network state updates. */
+ private static void registerBroadcastReceiver(
+ final Context context, final BroadcastReceiver receiver) {
+ final IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
+ context.registerReceiver(receiver, filter);
+ }
+
+ private static int wifiDhcpGatewayAddress(final Context context) {
+ if (context == null) {
+ return 0;
+ }
+
+ try {
+ final WifiManager mgr =
+ (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (mgr == null) {
+ return 0;
+ }
+
+ @SuppressLint("MissingPermission")
+ final DhcpInfo d = mgr.getDhcpInfo();
+ if (d == null) {
+ return 0;
+ }
+
+ return d.gateway;
+
+ } catch (final Exception ex) {
+ // getDhcpInfo() is not documented to require any permissions, but on some devices
+ // requires android.permission.ACCESS_WIFI_STATE. Just catch the generic exception
+ // here and returning 0. Not logging because this could be noisy.
+ return 0;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java
new file mode 100644
index 0000000000..3c84edbc96
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java
@@ -0,0 +1,71 @@
+/* -*- Mode: Java; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Build;
+import android.util.Log;
+import android.view.Display;
+
+@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+public class GeckoScreenChangeListener implements DisplayManager.DisplayListener {
+ private static final String LOGTAG = "ScreenChangeListener";
+ private static final boolean DEBUG = false;
+
+ public GeckoScreenChangeListener() {}
+
+ @Override
+ public void onDisplayAdded(final int displayId) {}
+
+ @Override
+ public void onDisplayRemoved(final int displayId) {}
+
+ @Override
+ public void onDisplayChanged(final int displayId) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onDisplayChanged");
+ }
+
+ // Even if onDisplayChanged is called, Configuration may not updated yet.
+ // So we use Display's data instead.
+ if (displayId != Display.DEFAULT_DISPLAY) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Primary display is only supported");
+ }
+ return;
+ }
+
+ final DisplayManager displayManager = getDisplayManager();
+ if (displayManager == null) {
+ return;
+ }
+
+ GeckoScreenOrientation.getInstance().update(displayManager.getDisplay(displayId));
+ }
+
+ private static DisplayManager getDisplayManager() {
+ return (DisplayManager)
+ GeckoAppShell.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+ }
+
+ public void enable() {
+ final DisplayManager displayManager = getDisplayManager();
+ if (displayManager == null) {
+ return;
+ }
+ displayManager.registerDisplayListener(this, null);
+ }
+
+ public void disable() {
+ final DisplayManager displayManager = getDisplayManager();
+ if (displayManager == null) {
+ return;
+ }
+ displayManager.unregisterDisplayListener(this);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java
new file mode 100644
index 0000000000..bdb7b4b331
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java
@@ -0,0 +1,273 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
+import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.Display;
+import android.view.Surface;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/*
+ * Updates, locks and unlocks the screen orientation.
+ *
+ * Note: Replaces the OnOrientationChangeListener to avoid redundant rotation
+ * event handling.
+ */
+public class GeckoScreenOrientation {
+ private static final String LOGTAG = "GeckoScreenOrientation";
+
+ // Make sure that any change in hal/HalScreenConfiguration.h happens here too.
+ public enum ScreenOrientation {
+ NONE(0),
+ PORTRAIT_PRIMARY(1 << 0),
+ PORTRAIT_SECONDARY(1 << 1),
+ PORTRAIT(PORTRAIT_PRIMARY.value | PORTRAIT_SECONDARY.value),
+ LANDSCAPE_PRIMARY(1 << 2),
+ LANDSCAPE_SECONDARY(1 << 3),
+ LANDSCAPE(LANDSCAPE_PRIMARY.value | LANDSCAPE_SECONDARY.value),
+ ANY(
+ PORTRAIT_PRIMARY.value
+ | PORTRAIT_SECONDARY.value
+ | LANDSCAPE_PRIMARY.value
+ | LANDSCAPE_SECONDARY.value),
+ DEFAULT(1 << 4);
+
+ public final short value;
+
+ private ScreenOrientation(final int value) {
+ this.value = (short) value;
+ }
+
+ private static final ScreenOrientation[] sValues = ScreenOrientation.values();
+
+ public static ScreenOrientation get(final int value) {
+ for (final ScreenOrientation orient : sValues) {
+ if (orient.value == value) {
+ return orient;
+ }
+ }
+ return NONE;
+ }
+ }
+
+ // Singleton instance.
+ private static GeckoScreenOrientation sInstance;
+ // Default rotation, used when device rotation is unknown.
+ private static final int DEFAULT_ROTATION = Surface.ROTATION_0;
+ // Last updated screen orientation with Gecko value space.
+ private ScreenOrientation mScreenOrientation = ScreenOrientation.PORTRAIT_PRIMARY;
+
+ public interface OrientationChangeListener {
+ void onScreenOrientationChanged(ScreenOrientation newOrientation);
+ }
+
+ private final List<OrientationChangeListener> mListeners;
+
+ public static GeckoScreenOrientation getInstance() {
+ if (sInstance == null) {
+ sInstance = new GeckoScreenOrientation();
+ }
+ return sInstance;
+ }
+
+ private GeckoScreenOrientation() {
+ mListeners = new ArrayList<>();
+ update();
+ }
+
+ /** Add a listener that will be notified when the screen orientation has changed. */
+ public void addListener(final OrientationChangeListener aListener) {
+ ThreadUtils.assertOnUiThread();
+ mListeners.add(aListener);
+ }
+
+ /** Remove a OrientationChangeListener again. */
+ public void removeListener(final OrientationChangeListener aListener) {
+ ThreadUtils.assertOnUiThread();
+ mListeners.remove(aListener);
+ }
+
+ /*
+ * Update screen orientation.
+ * Retrieve orientation and rotation via GeckoAppShell.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update() {
+ // Check whether we have the application context for fenix/a-c unit test.
+ final Context appContext = GeckoAppShell.getApplicationContext();
+ if (appContext == null) {
+ return false;
+ }
+ final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride();
+ final int orientation =
+ rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT;
+ return update(getScreenOrientation(orientation, getRotation()));
+ }
+
+ /*
+ * Update screen orientation.
+ * Retrieve orientation and rotation via Display.
+ *
+ * @param aDisplay The Display that has screen orientation information
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update(final Display aDisplay) {
+ return update(getScreenOrientation(aDisplay));
+ }
+
+ /*
+ * Update screen orientation given the android orientation.
+ * Retrieve rotation via GeckoAppShell.
+ *
+ * @param aAndroidOrientation
+ * Android screen orientation from Configuration.orientation.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update(final int aAndroidOrientation) {
+ return update(getScreenOrientation(aAndroidOrientation, getRotation()));
+ }
+
+ /*
+ * Update screen orientation given the screen orientation.
+ *
+ * @param aScreenOrientation
+ * Gecko screen orientation based on android orientation and rotation.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public synchronized boolean update(final ScreenOrientation aScreenOrientation) {
+ // Gecko expects a definite screen orientation, so we default to the
+ // primary orientations.
+ final ScreenOrientation screenOrientation;
+ if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_PRIMARY.value) != 0) {
+ screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_SECONDARY.value) != 0) {
+ screenOrientation = ScreenOrientation.PORTRAIT_SECONDARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_PRIMARY.value) != 0) {
+ screenOrientation = ScreenOrientation.LANDSCAPE_PRIMARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_SECONDARY.value) != 0) {
+ screenOrientation = ScreenOrientation.LANDSCAPE_SECONDARY;
+ } else {
+ screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY;
+ }
+ if (mScreenOrientation == screenOrientation) {
+ return false;
+ }
+ mScreenOrientation = screenOrientation;
+ Log.d(LOGTAG, "updating to new orientation " + mScreenOrientation);
+ notifyListeners(mScreenOrientation);
+ ScreenManagerHelper.refreshScreenInfo();
+ return true;
+ }
+
+ private void notifyListeners(final ScreenOrientation newOrientation) {
+ final Runnable notifier =
+ new Runnable() {
+ @Override
+ public void run() {
+ for (final OrientationChangeListener listener : mListeners) {
+ listener.onScreenOrientationChanged(newOrientation);
+ }
+ }
+ };
+
+ if (ThreadUtils.isOnUiThread()) {
+ notifier.run();
+ } else {
+ ThreadUtils.runOnUiThread(notifier);
+ }
+ }
+
+ /*
+ * @return The Gecko screen orientation derived from Android orientation and
+ * rotation.
+ */
+ public ScreenOrientation getScreenOrientation() {
+ return mScreenOrientation;
+ }
+
+ /*
+ * Combine the Android orientation and rotation to the Gecko orientation.
+ *
+ * @param aAndroidOrientation
+ * Android orientation from Configuration.orientation.
+ * @param aRotation
+ * Device rotation from Display.getRotation().
+ *
+ * @return Gecko screen orientation.
+ */
+ private ScreenOrientation getScreenOrientation(
+ final int aAndroidOrientation, final int aRotation) {
+ final boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90;
+ if (aAndroidOrientation == ORIENTATION_PORTRAIT) {
+ if (isPrimary) {
+ // Non-rotated portrait device or landscape device rotated
+ // to primary portrait mode counter-clockwise.
+ return ScreenOrientation.PORTRAIT_PRIMARY;
+ }
+ return ScreenOrientation.PORTRAIT_SECONDARY;
+ }
+ if (aAndroidOrientation == ORIENTATION_LANDSCAPE) {
+ if (isPrimary) {
+ // Non-rotated landscape device or portrait device rotated
+ // to primary landscape mode counter-clockwise.
+ return ScreenOrientation.LANDSCAPE_PRIMARY;
+ }
+ return ScreenOrientation.LANDSCAPE_SECONDARY;
+ }
+ return ScreenOrientation.NONE;
+ }
+
+ /*
+ * Get the Gecko orientation from Display.
+ *
+ * @param aDisplay The display that has orientation information.
+ *
+ * @return Gecko screen orientation.
+ */
+ private ScreenOrientation getScreenOrientation(final Display aDisplay) {
+ final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride();
+ final int orientation =
+ rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT;
+ return getScreenOrientation(orientation, aDisplay.getRotation());
+ }
+
+ /*
+ * @return Device rotation converted to an angle.
+ */
+ public short getAngle() {
+ switch (getRotation()) {
+ case Surface.ROTATION_0:
+ return 0;
+ case Surface.ROTATION_90:
+ return 90;
+ case Surface.ROTATION_180:
+ return 180;
+ case Surface.ROTATION_270:
+ return 270;
+ default:
+ Log.w(LOGTAG, "getAngle: unexpected rotation value");
+ return 0;
+ }
+ }
+
+ /*
+ * @return Device rotation.
+ */
+ private int getRotation() {
+ return GeckoAppShell.getRotation();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java
new file mode 100644
index 0000000000..375d6c91c2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java
@@ -0,0 +1,161 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.database.ContentObserver;
+import android.hardware.input.InputManager;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.Settings;
+import android.util.Log;
+import android.view.InputDevice;
+import androidx.annotation.RequiresApi;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.InputDeviceUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class GeckoSystemStateListener implements InputManager.InputDeviceListener {
+ private static final String LOGTAG = "SystemStateListener";
+
+ private static final GeckoSystemStateListener listenerInstance = new GeckoSystemStateListener();
+
+ private boolean mInitialized;
+ private ContentObserver mContentObserver;
+ private static Context sApplicationContext;
+ private InputManager mInputManager;
+ private boolean mIsNightMode;
+
+ public static GeckoSystemStateListener getInstance() {
+ return listenerInstance;
+ }
+
+ private GeckoSystemStateListener() {}
+
+ public synchronized void initialize(final Context context) {
+ if (mInitialized) {
+ Log.w(LOGTAG, "Already initialized!");
+ return;
+ }
+ mInputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ mInputManager.registerInputDeviceListener(listenerInstance, ThreadUtils.getUiHandler());
+
+ sApplicationContext = context;
+ final ContentResolver contentResolver = sApplicationContext.getContentResolver();
+ final Uri animationSetting = Settings.System.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE);
+ mContentObserver =
+ new ContentObserver(new Handler(Looper.getMainLooper())) {
+ @Override
+ public void onChange(final boolean selfChange) {
+ onDeviceChanged();
+ }
+ };
+ contentResolver.registerContentObserver(animationSetting, false, mContentObserver);
+
+ mIsNightMode =
+ (sApplicationContext.getResources().getConfiguration().uiMode
+ & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_YES;
+
+ mInitialized = true;
+ }
+
+ public synchronized void shutdown() {
+ if (!mInitialized) {
+ Log.w(LOGTAG, "Already shut down!");
+ return;
+ }
+
+ if (mInputManager != null) {
+ Log.e(LOGTAG, "mInputManager should be valid!");
+ return;
+ }
+
+ mInputManager.unregisterInputDeviceListener(listenerInstance);
+
+ final ContentResolver contentResolver = sApplicationContext.getContentResolver();
+ contentResolver.unregisterContentObserver(mContentObserver);
+
+ mInitialized = false;
+ mInputManager = null;
+ mContentObserver = null;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
+ @WrapForJNI(calledFrom = "gecko")
+ /**
+ * For prefers-reduced-motion media queries feature.
+ *
+ * <p>Uses `Settings.Global` which was introduced in API version 17.
+ */
+ private static boolean prefersReducedMotion() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return false;
+ }
+
+ final ContentResolver contentResolver = sApplicationContext.getContentResolver();
+
+ return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1)
+ == 0.0f;
+ }
+
+ /** For prefers-color-scheme media queries feature. */
+ public boolean isNightMode() {
+ return mIsNightMode;
+ }
+
+ public void updateNightMode(final int newUIMode) {
+ final boolean isNightMode =
+ (newUIMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
+ if (isNightMode == mIsNightMode) {
+ return;
+ }
+ mIsNightMode = isNightMode;
+ onDeviceChanged();
+ }
+
+ @WrapForJNI(stubName = "OnDeviceChanged", calledFrom = "any", dispatchTo = "gecko")
+ private static native void nativeOnDeviceChanged();
+
+ public static void onDeviceChanged() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeOnDeviceChanged();
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, GeckoSystemStateListener.class, "nativeOnDeviceChanged");
+ }
+ }
+
+ private void notifyDeviceChanged(final int deviceId) {
+ final InputDevice device = InputDevice.getDevice(deviceId);
+ if (device == null || !InputDeviceUtils.isPointerTypeDevice(device)) {
+ return;
+ }
+ onDeviceChanged();
+ }
+
+ @Override
+ public void onInputDeviceAdded(final int deviceId) {
+ notifyDeviceChanged(deviceId);
+ }
+
+ @Override
+ public void onInputDeviceRemoved(final int deviceId) {
+ // Call onDeviceChanged directly without checking device source types
+ // since we can no longer get a valid `InputDevice` in the case of
+ // device removal.
+ onDeviceChanged();
+ }
+
+ @Override
+ public void onInputDeviceChanged(final int deviceId) {
+ notifyDeviceChanged(deviceId);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
new file mode 100644
index 0000000000..8860c1cd42
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
@@ -0,0 +1,985 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.StringTokenizer;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.gecko.process.GeckoProcessManager;
+import org.mozilla.gecko.process.GeckoProcessType;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoResult;
+
+public class GeckoThread extends Thread {
+ private static final String LOGTAG = "GeckoThread";
+
+ public enum State implements NativeQueue.State {
+ // After being loaded by class loader.
+ @WrapForJNI
+ INITIAL(0),
+ // After launching Gecko thread
+ @WrapForJNI
+ LAUNCHED(1),
+ // After loading the mozglue library.
+ @WrapForJNI
+ MOZGLUE_READY(2),
+ // After loading the libxul library.
+ @WrapForJNI
+ LIBS_READY(3),
+ // After initializing nsAppShell and JNI calls.
+ @WrapForJNI
+ JNI_READY(4),
+ // After initializing profile and prefs.
+ @WrapForJNI
+ PROFILE_READY(5),
+ // After initializing frontend JS
+ @WrapForJNI
+ RUNNING(6),
+ // After granting request to shutdown
+ @WrapForJNI
+ EXITING(3),
+ // After granting request to restart
+ @WrapForJNI
+ RESTARTING(3),
+ // After failed lib extraction due to corrupted APK
+ CORRUPT_APK(2),
+ // After exiting GeckoThread (corresponding to "Gecko:Exited" event)
+ @WrapForJNI
+ EXITED(0);
+
+ /* The rank is an arbitrary value reflecting the amount of components or features
+ * that are available for use. During startup and up to the RUNNING state, the
+ * rank value increases because more components are initialized and available for
+ * use. During shutdown and up to the EXITED state, the rank value decreases as
+ * components are shut down and become unavailable. EXITING has the same rank as
+ * LIBS_READY because both states have a similar amount of components available.
+ */
+ private final int mRank;
+
+ private State(final int rank) {
+ mRank = rank;
+ }
+
+ @Override
+ public boolean is(final NativeQueue.State other) {
+ return this == other;
+ }
+
+ @Override
+ public boolean isAtLeast(final NativeQueue.State other) {
+ if (other instanceof State) {
+ return mRank >= ((State) other).mRank;
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return name();
+ }
+ }
+
+ // -1 denotes an invalid or missing File Descriptor
+ private static final int INVALID_FD = -1;
+
+ private static final NativeQueue sNativeQueue = new NativeQueue(State.INITIAL, State.RUNNING);
+
+ /* package */ static NativeQueue getNativeQueue() {
+ return sNativeQueue;
+ }
+
+ public static final State MIN_STATE = State.INITIAL;
+ public static final State MAX_STATE = State.EXITED;
+
+ private static final Runnable UI_THREAD_CALLBACK =
+ new Runnable() {
+ @Override
+ public void run() {
+ ThreadUtils.assertOnUiThread();
+ final long nextDelay = runUiThreadCallback();
+ if (nextDelay >= 0) {
+ ThreadUtils.getUiHandler().postDelayed(this, nextDelay);
+ }
+ }
+ };
+
+ private static final GeckoThread INSTANCE = new GeckoThread();
+
+ @WrapForJNI private static final ClassLoader clsLoader = GeckoThread.class.getClassLoader();
+ @WrapForJNI private static MessageQueue msgQueue;
+ @WrapForJNI private static int uiThreadId;
+
+ private static TelemetryUtils.Timer sInitTimer;
+ private static LinkedList<StateGeckoResult> sStateListeners = new LinkedList<>();
+
+ // Main process parameters
+ public static final int FLAG_DEBUGGING = 1 << 0; // Debugging mode.
+ public static final int FLAG_PRELOAD_CHILD = 1 << 1; // Preload child during main thread start.
+ public static final int FLAG_ENABLE_NATIVE_CRASHREPORTER =
+ 1 << 2; // Enable native crash reporting.
+
+ /* package */ static final String EXTRA_ARGS = "args";
+
+ private boolean mInitialized;
+ private InitInfo mInitInfo;
+
+ public static final class ParcelFileDescriptors {
+ public final @Nullable ParcelFileDescriptor prefs;
+ public final @Nullable ParcelFileDescriptor prefMap;
+ public final @NonNull ParcelFileDescriptor ipc;
+ public final @Nullable ParcelFileDescriptor crashReporter;
+ public final @Nullable ParcelFileDescriptor crashAnnotation;
+
+ private ParcelFileDescriptors(final Builder builder) {
+ prefs = builder.prefs;
+ prefMap = builder.prefMap;
+ ipc = builder.ipc;
+ crashReporter = builder.crashReporter;
+ crashAnnotation = builder.crashAnnotation;
+ }
+
+ public FileDescriptors detach() {
+ return FileDescriptors.builder()
+ .prefs(detach(prefs))
+ .prefMap(detach(prefMap))
+ .ipc(detach(ipc))
+ .crashReporter(detach(crashReporter))
+ .crashAnnotation(detach(crashAnnotation))
+ .build();
+ }
+
+ private static int detach(final ParcelFileDescriptor pfd) {
+ if (pfd == null) {
+ return INVALID_FD;
+ }
+ return pfd.detachFd();
+ }
+
+ public void close() {
+ close(prefs, prefMap, ipc, crashReporter, crashAnnotation);
+ }
+
+ private static void close(final ParcelFileDescriptor... pfds) {
+ for (final ParcelFileDescriptor pfd : pfds) {
+ if (pfd != null) {
+ try {
+ pfd.close();
+ } catch (final IOException ex) {
+ // Nothing we can do about this really.
+ Log.w(LOGTAG, "Failed to close File Descriptors.", ex);
+ }
+ }
+ }
+ }
+
+ public static ParcelFileDescriptors from(final FileDescriptors fds) {
+ return ParcelFileDescriptors.builder()
+ .prefs(from(fds.prefs))
+ .prefMap(from(fds.prefMap))
+ .ipc(from(fds.ipc))
+ .crashReporter(from(fds.crashReporter))
+ .crashAnnotation(from(fds.crashAnnotation))
+ .build();
+ }
+
+ private static ParcelFileDescriptor from(final int fd) {
+ if (fd == INVALID_FD) {
+ return null;
+ }
+ try {
+ return ParcelFileDescriptor.fromFd(fd);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ ParcelFileDescriptor prefs;
+ ParcelFileDescriptor prefMap;
+ ParcelFileDescriptor ipc;
+ ParcelFileDescriptor crashReporter;
+ ParcelFileDescriptor crashAnnotation;
+
+ private Builder() {}
+
+ public ParcelFileDescriptors build() {
+ return new ParcelFileDescriptors(this);
+ }
+
+ public Builder prefs(final ParcelFileDescriptor prefs) {
+ this.prefs = prefs;
+ return this;
+ }
+
+ public Builder prefMap(final ParcelFileDescriptor prefMap) {
+ this.prefMap = prefMap;
+ return this;
+ }
+
+ public Builder ipc(final ParcelFileDescriptor ipc) {
+ this.ipc = ipc;
+ return this;
+ }
+
+ public Builder crashReporter(final ParcelFileDescriptor crashReporter) {
+ this.crashReporter = crashReporter;
+ return this;
+ }
+
+ public Builder crashAnnotation(final ParcelFileDescriptor crashAnnotation) {
+ this.crashAnnotation = crashAnnotation;
+ return this;
+ }
+ }
+ }
+
+ public static final class FileDescriptors {
+ final int prefs;
+ final int prefMap;
+ final int ipc;
+ final int crashReporter;
+ final int crashAnnotation;
+
+ private FileDescriptors(final Builder builder) {
+ prefs = builder.prefs;
+ prefMap = builder.prefMap;
+ ipc = builder.ipc;
+ crashReporter = builder.crashReporter;
+ crashAnnotation = builder.crashAnnotation;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ int prefs = INVALID_FD;
+ int prefMap = INVALID_FD;
+ int ipc = INVALID_FD;
+ int crashReporter = INVALID_FD;
+ int crashAnnotation = INVALID_FD;
+
+ private Builder() {}
+
+ public FileDescriptors build() {
+ return new FileDescriptors(this);
+ }
+
+ public Builder prefs(final int prefs) {
+ this.prefs = prefs;
+ return this;
+ }
+
+ public Builder prefMap(final int prefMap) {
+ this.prefMap = prefMap;
+ return this;
+ }
+
+ public Builder ipc(final int ipc) {
+ this.ipc = ipc;
+ return this;
+ }
+
+ public Builder crashReporter(final int crashReporter) {
+ this.crashReporter = crashReporter;
+ return this;
+ }
+
+ public Builder crashAnnotation(final int crashAnnotation) {
+ this.crashAnnotation = crashAnnotation;
+ return this;
+ }
+ }
+ }
+
+ public static class InitInfo {
+ public final String[] args;
+ public final Bundle extras;
+ public final int flags;
+ public final Map<String, Object> prefs;
+ public final String userSerialNumber;
+
+ public final boolean xpcshell;
+ public final String outFilePath;
+
+ public final FileDescriptors fds;
+
+ private InitInfo(final Builder builder) {
+ final List<String> result = new ArrayList<>(builder.mArgs.length);
+
+ boolean xpcshell = false;
+ for (final String argument : builder.mArgs) {
+ if ("-xpcshell".equals(argument)) {
+ xpcshell = true;
+ } else {
+ result.add(argument);
+ }
+ }
+ this.xpcshell = xpcshell;
+
+ args = result.toArray(new String[0]);
+
+ extras = builder.mExtras != null ? new Bundle(builder.mExtras) : new Bundle(3);
+ flags = builder.mFlags;
+ prefs = builder.mPrefs;
+ userSerialNumber = builder.mUserSerialNumber;
+
+ outFilePath = xpcshell ? builder.mOutFilePath : null;
+
+ if (builder.mFds != null) {
+ fds = builder.mFds;
+ } else {
+ fds = FileDescriptors.builder().build();
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String[] mArgs;
+ private Bundle mExtras;
+ private int mFlags;
+ private Map<String, Object> mPrefs;
+ private String mUserSerialNumber;
+
+ private String mOutFilePath;
+
+ private FileDescriptors mFds;
+
+ // Prevent direct instantiation
+ private Builder() {}
+
+ public InitInfo build() {
+ return new InitInfo(this);
+ }
+
+ public Builder args(final String[] args) {
+ mArgs = args;
+ return this;
+ }
+
+ public Builder extras(final Bundle extras) {
+ mExtras = extras;
+ return this;
+ }
+
+ public Builder flags(final int flags) {
+ mFlags = flags;
+ return this;
+ }
+
+ public Builder prefs(final Map<String, Object> prefs) {
+ mPrefs = prefs;
+ return this;
+ }
+
+ public Builder userSerialNumber(final String userSerialNumber) {
+ mUserSerialNumber = userSerialNumber;
+ return this;
+ }
+
+ public Builder outFilePath(final String outFilePath) {
+ mOutFilePath = outFilePath;
+ return this;
+ }
+
+ public Builder fds(final FileDescriptors fds) {
+ mFds = fds;
+ return this;
+ }
+ }
+ }
+
+ private static class StateGeckoResult extends GeckoResult<Void> {
+ final State state;
+
+ public StateGeckoResult(final State state) {
+ this.state = state;
+ }
+ }
+
+ GeckoThread() {
+ // Request more (virtual) stack space to avoid overflows in the CSS frame
+ // constructor. 8 MB matches desktop.
+ super(null, null, "Gecko", 8 * 1024 * 1024);
+ }
+
+ @WrapForJNI
+ private static boolean isChildProcess() {
+ final InitInfo info = INSTANCE.mInitInfo;
+ return info != null && info.fds.ipc != INVALID_FD;
+ }
+
+ public static boolean init(final InitInfo info) {
+ return INSTANCE.initInternal(info);
+ }
+
+ private synchronized boolean initInternal(final InitInfo info) {
+ ThreadUtils.assertOnUiThread();
+ uiThreadId = Process.myTid();
+
+ if (mInitialized) {
+ return false;
+ }
+
+ sInitTimer = new TelemetryUtils.UptimeTimer("GV_STARTUP_RUNTIME_MS");
+
+ mInitInfo = info;
+ mInitialized = true;
+ notifyAll();
+ return true;
+ }
+
+ public static boolean launch() {
+ ThreadUtils.assertOnUiThread();
+
+ if (checkAndSetState(State.INITIAL, State.LAUNCHED)) {
+ INSTANCE.start();
+ return true;
+ }
+ return false;
+ }
+
+ public static boolean isLaunched() {
+ return !isState(State.INITIAL);
+ }
+
+ @RobocopTarget
+ public static boolean isRunning() {
+ return isState(State.RUNNING);
+ }
+
+ private static void loadGeckoLibs(final Context context) {
+ GeckoLoader.loadSQLiteLibs(context);
+ GeckoLoader.loadNSSLibs(context);
+ GeckoLoader.loadGeckoLibs(context);
+ setState(State.LIBS_READY);
+ }
+
+ private static void initGeckoEnvironment() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Locale locale = Locale.getDefault();
+ final Resources res = context.getResources();
+ if (locale.toString().equalsIgnoreCase("zh_hk")) {
+ final Locale mappedLocale = Locale.TRADITIONAL_CHINESE;
+ Locale.setDefault(mappedLocale);
+ final Configuration config = res.getConfiguration();
+ config.locale = mappedLocale;
+ res.updateConfiguration(config, null);
+ }
+
+ if (!isChildProcess()) {
+ GeckoSystemStateListener.getInstance().initialize(context);
+ }
+
+ loadGeckoLibs(context);
+ }
+
+ private String[] getMainProcessArgs() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final ArrayList<String> args = new ArrayList<>();
+
+ // argv[0] is the program name, which for us is the package name.
+ args.add(context.getPackageName());
+
+ if (!mInitInfo.xpcshell) {
+ args.add("-greomni");
+ args.add(context.getPackageResourcePath());
+ }
+
+ if (mInitInfo.args != null) {
+ args.addAll(Arrays.asList(mInitInfo.args));
+ }
+
+ // Legacy "args" parameter
+ final String extraArgs = mInitInfo.extras.getString(EXTRA_ARGS, null);
+ if (extraArgs != null) {
+ final StringTokenizer st = new StringTokenizer(extraArgs);
+ while (st.hasMoreTokens()) {
+ args.add(st.nextToken());
+ }
+ }
+
+ // "argX" parameters
+ for (int i = 0; mInitInfo.extras.containsKey("arg" + i); i++) {
+ final String arg = mInitInfo.extras.getString("arg" + i);
+ args.add(arg);
+ }
+
+ return args.toArray(new String[0]);
+ }
+
+ public static @Nullable Bundle getActiveExtras() {
+ synchronized (INSTANCE) {
+ if (!INSTANCE.mInitialized) {
+ return null;
+ }
+ return new Bundle(INSTANCE.mInitInfo.extras);
+ }
+ }
+
+ public static int getActiveFlags() {
+ synchronized (INSTANCE) {
+ if (!INSTANCE.mInitialized) {
+ return 0;
+ }
+
+ return INSTANCE.mInitInfo.flags;
+ }
+ }
+
+ private static ArrayList<String> getEnvFromExtras(final Bundle extras) {
+ if (extras == null) {
+ return new ArrayList<>();
+ }
+
+ final ArrayList<String> result = new ArrayList<>();
+ if (extras != null) {
+ String env = extras.getString("env0");
+ for (int c = 1; env != null; c++) {
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(LOGTAG, "env var: " + env);
+ }
+ result.add(env);
+ env = extras.getString("env" + c);
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ public void run() {
+ Log.i(LOGTAG, "preparing to run Gecko");
+
+ Looper.prepare();
+ GeckoThread.msgQueue = Looper.myQueue();
+ ThreadUtils.sGeckoThread = this;
+ ThreadUtils.sGeckoHandler = new Handler();
+
+ // Preparation for pumpMessageLoop()
+ final MessageQueue.IdleHandler idleHandler =
+ new MessageQueue.IdleHandler() {
+ @Override
+ public boolean queueIdle() {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+ final Message idleMsg = Message.obtain(geckoHandler);
+ // Use |Message.obj == GeckoHandler| to identify our "queue is empty" message
+ idleMsg.obj = geckoHandler;
+ geckoHandler.sendMessageAtFrontOfQueue(idleMsg);
+ // Keep this IdleHandler
+ return true;
+ }
+ };
+ Looper.myQueue().addIdleHandler(idleHandler);
+
+ // Wait until initialization before preparing environment.
+ synchronized (this) {
+ while (!mInitialized) {
+ try {
+ wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ final List<String> env = getEnvFromExtras(mInitInfo.extras);
+
+ // In Gecko, the native crash reporter is enabled by default in opt builds, and
+ // disabled by default in debug builds.
+ if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) == 0 && !BuildConfig.DEBUG_BUILD) {
+ env.add(0, "MOZ_CRASHREPORTER_DISABLE=1");
+ } else if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) != 0
+ && BuildConfig.DEBUG_BUILD) {
+ env.add(0, "MOZ_CRASHREPORTER=1");
+ }
+
+ if (mInitInfo.userSerialNumber != null) {
+ env.add(0, "MOZ_ANDROID_USER_SERIAL_NUMBER=" + mInitInfo.userSerialNumber);
+ }
+
+ // Start the profiler before even loading mozglue, so we can capture more
+ // things that are happening on the JVM side.
+ maybeStartGeckoProfiler(env);
+
+ GeckoLoader.loadMozGlue(context);
+ setState(State.MOZGLUE_READY);
+
+ final boolean isChildProcess = isChildProcess();
+
+ GeckoLoader.setupGeckoEnvironment(
+ context,
+ isChildProcess,
+ context.getFilesDir().getPath(),
+ env,
+ mInitInfo.prefs,
+ mInitInfo.xpcshell);
+
+ initGeckoEnvironment();
+
+ if ((mInitInfo.flags & FLAG_PRELOAD_CHILD) != 0) {
+ // Preload the content ("tab") child process.
+ GeckoProcessManager.getInstance().preload(GeckoProcessType.CONTENT);
+ }
+
+ if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) {
+ try {
+ Thread.sleep(5 * 1000 /* 5 seconds */);
+ } catch (final InterruptedException e) {
+ }
+ }
+
+ Log.w(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - runGecko");
+
+ final String[] args = isChildProcess ? mInitInfo.args : getMainProcessArgs();
+
+ if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) {
+ Log.i(LOGTAG, "RunGecko - args = " + TextUtils.join(" ", args));
+ }
+
+ // And go.
+ GeckoLoader.nativeRun(
+ args,
+ mInitInfo.fds.prefs,
+ mInitInfo.fds.prefMap,
+ mInitInfo.fds.ipc,
+ mInitInfo.fds.crashReporter,
+ mInitInfo.fds.crashAnnotation,
+ isChildProcess ? false : mInitInfo.xpcshell,
+ isChildProcess ? null : mInitInfo.outFilePath);
+
+ // And... we're done.
+ final boolean restarting = isState(State.RESTARTING);
+ setState(State.EXITED);
+
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putBoolean("restart", restarting);
+ EventDispatcher.getInstance().dispatch("Gecko:Exited", data);
+
+ // Remove pumpMessageLoop() idle handler
+ Looper.myQueue().removeIdleHandler(idleHandler);
+
+ if (isChildProcess) {
+ // The child process is completely controlled by Gecko so we don't really need to keep
+ // it alive after Gecko exits.
+ System.exit(0);
+ }
+ }
+
+ // This may start the gecko profiler early by looking at the environment variables.
+ // Refer to the platform side for more information about the environment variables:
+ // https://searchfox.org/mozilla-central/rev/2f9eacd9d3d995c937b4251a5557d95d494c9be1/tools/profiler/core/platform.cpp#2969-3072
+ private static void maybeStartGeckoProfiler(final @NonNull List<String> env) {
+ final String startupEnv = "MOZ_PROFILER_STARTUP=";
+ final String intervalEnv = "MOZ_PROFILER_STARTUP_INTERVAL=";
+ final String capacityEnv = "MOZ_PROFILER_STARTUP_ENTRIES=";
+ final String filtersEnv = "MOZ_PROFILER_STARTUP_FILTERS=";
+ boolean isStartupProfiling = false;
+ // Putting default values for now, but they can be overwritten.
+ // Keep these values in sync with profiler defaults.
+ int interval = 1;
+ // 8M entries. Keep this in sync with `PROFILER_DEFAULT_STARTUP_ENTRIES`.
+ int capacity = 8 * 1024 * 1024;
+ // We have a default 8M of entries but user can actually put less entries
+ // with environment variables. But even though user can put anything, we
+ // have a hard cap on the minimum value count, because if it's lower than
+ // this value, profiler could not capture anything meaningful.
+ // This value is kept in `scMinimumBufferEntries` variable in the cpp side:
+ // https://searchfox.org/mozilla-central/rev/fa7f47027917a186fb2052dee104cd06c21dd76f/tools/profiler/core/platform.cpp#749
+ // This number is not clear in the cpp code at first, so lets calculate:
+ // scMinimumBufferEntries = scMinimumBufferSize / scBytesPerEntry
+ // expands into
+ // scMinimumNumberOfChunks * 2 * scExpectedMaximumStackSize / scBytesPerEntry
+ // and this is: 4 * 2 * 64 * 1024 / 8 = 65536 (~512 kb)
+ final int minCapacity = 65536;
+
+ // Set the default value of no filters - an empty array - which is safer than using null.
+ // If we find a user provided value, this will be overwritten.
+ String[] filters = new String[0];
+
+ // Looping the environment variable list to check known variable names.
+ for (final String envItem : env) {
+ if (envItem == null) {
+ continue;
+ }
+
+ if (envItem.startsWith(startupEnv)) {
+ // Check the environment variable value to see if it's positive.
+ final String value = envItem.substring(startupEnv.length());
+ if (value.isEmpty() || value.equals("0") || value.equals("n") || value.equals("N")) {
+ // ''/'0'/'n'/'N' values mean do not start the startup profiler.
+ // There's no need to inspect other environment variables,
+ // so let's break out of the loop
+ break;
+ }
+
+ isStartupProfiling = true;
+ } else if (envItem.startsWith(intervalEnv)) {
+ // Parse the interval environment variable if present
+ final String value = envItem.substring(intervalEnv.length());
+
+ try {
+ final int intValue = Integer.parseInt(value);
+ interval = Math.max(intValue, interval);
+ } catch (final NumberFormatException err) {
+ // Failed to parse. Do nothing and just use the default value.
+ }
+ } else if (envItem.startsWith(capacityEnv)) {
+ // Parse the capacity environment variable if present
+ final String value = envItem.substring(capacityEnv.length());
+
+ try {
+ final int intValue = Integer.parseInt(value);
+ // See `scMinimumBufferEntries` variable for this value on the platform side.
+ capacity = Math.max(intValue, minCapacity);
+ } catch (final NumberFormatException err) {
+ // Failed to parse. Do nothing and just use the default value.
+ }
+ } else if (envItem.startsWith(filtersEnv)) {
+ filters = envItem.substring(filtersEnv.length()).split(",");
+ }
+ }
+
+ if (isStartupProfiling) {
+ GeckoJavaSampler.start(filters, interval, capacity);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean pumpMessageLoop(final Message msg) {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+
+ if (msg.obj == geckoHandler && msg.getTarget() == geckoHandler) {
+ // Our "queue is empty" message; see runGecko()
+ return false;
+ }
+
+ if (msg.getTarget() == null) {
+ Looper.myLooper().quit();
+ } else {
+ msg.getTarget().dispatchMessage(msg);
+ }
+
+ return true;
+ }
+
+ /**
+ * Check that the current Gecko thread state matches the given state.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isState(final State state) {
+ return sNativeQueue.getState().is(state);
+ }
+
+ /**
+ * Check that the current Gecko thread state is at the given state or further along, according to
+ * the order defined in the State enum.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateAtLeast(final State state) {
+ return sNativeQueue.getState().isAtLeast(state);
+ }
+
+ /**
+ * Check that the current Gecko thread state is at the given state or prior, according to the
+ * order defined in the State enum.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateAtMost(final State state) {
+ return state.isAtLeast(sNativeQueue.getState());
+ }
+
+ /**
+ * Check that the current Gecko thread state falls into an inclusive range of states, according to
+ * the order defined in the State enum.
+ *
+ * @param minState Lower range of allowable states
+ * @param maxState Upper range of allowable states
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateBetween(final State minState, final State maxState) {
+ return isStateAtLeast(minState) && isStateAtMost(maxState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void setState(final State newState) {
+ checkAndSetState(null, newState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean checkAndSetState(final State expectedState, final State newState) {
+ final boolean result = sNativeQueue.checkAndSetState(expectedState, newState);
+ if (result) {
+ Log.d(LOGTAG, "State changed to " + newState);
+
+ if (sInitTimer != null && isRunning()) {
+ sInitTimer.stop();
+ sInitTimer = null;
+ }
+
+ notifyStateListeners();
+ }
+ return result;
+ }
+
+ @WrapForJNI(stubName = "SpeculativeConnect")
+ private static native void speculativeConnectNative(String uri);
+
+ public static void speculativeConnect(final String uri) {
+ // This is almost always called before Gecko loads, so we don't
+ // bother checking here if Gecko is actually loaded or not.
+ // Speculative connection depends on proxy settings,
+ // so the earliest it can happen is after profile is ready.
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "speculativeConnectNative", uri);
+ }
+
+ @UiThread
+ public static GeckoResult<Void> waitForState(final State state) {
+ final StateGeckoResult result = new StateGeckoResult(state);
+ if (isStateAtLeast(state)) {
+ result.complete(null);
+ return result;
+ }
+
+ synchronized (sStateListeners) {
+ sStateListeners.add(result);
+ }
+ return result;
+ }
+
+ private static void notifyStateListeners() {
+ synchronized (sStateListeners) {
+ final LinkedList<StateGeckoResult> newListeners = new LinkedList<>();
+ for (final StateGeckoResult result : sStateListeners) {
+ if (!isStateAtLeast(result.state)) {
+ newListeners.add(result);
+ continue;
+ }
+
+ result.complete(null);
+ }
+
+ sStateListeners = newListeners;
+ }
+ }
+
+ @WrapForJNI(stubName = "OnPause", dispatchTo = "gecko")
+ private static native void nativeOnPause();
+
+ public static void onPause() {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeOnPause();
+ } else {
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnPause");
+ }
+ }
+
+ @WrapForJNI(stubName = "OnResume", dispatchTo = "gecko")
+ private static native void nativeOnResume();
+
+ public static void onResume() {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeOnResume();
+ } else {
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnResume");
+ }
+ }
+
+ @WrapForJNI(stubName = "CreateServices", dispatchTo = "gecko")
+ private static native void nativeCreateServices(String category, String data);
+
+ public static void createServices(final String category, final String data) {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeCreateServices(category, data);
+ } else {
+ queueNativeCallUntil(
+ State.PROFILE_READY,
+ GeckoThread.class,
+ "nativeCreateServices",
+ String.class,
+ category,
+ String.class,
+ data);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ /* package */ static native long runUiThreadCallback();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public static native void forceQuit();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public static native void crash();
+
+ @WrapForJNI
+ private static void requestUiThreadCallback(final long delay) {
+ ThreadUtils.getUiHandler().postDelayed(UI_THREAD_CALLBACK, delay);
+ }
+
+ /** Queue a call to the given static method until Gecko is in the RUNNING state. */
+ public static void queueNativeCall(
+ final Class<?> cls, final String methodName, final Object... args) {
+ sNativeQueue.queueUntilReady(cls, methodName, args);
+ }
+
+ /** Queue a call to the given instance method until Gecko is in the RUNNING state. */
+ public static void queueNativeCall(
+ final Object obj, final String methodName, final Object... args) {
+ sNativeQueue.queueUntilReady(obj, methodName, args);
+ }
+
+ /** Queue a call to the given instance method until Gecko is in the RUNNING state. */
+ public static void queueNativeCallUntil(
+ final State state, final Object obj, final String methodName, final Object... args) {
+ sNativeQueue.queueUntil(state, obj, methodName, args);
+ }
+
+ /** Queue a call to the given static method until Gecko is in the RUNNING state. */
+ public static void queueNativeCallUntil(
+ final State state, final Class<?> cls, final String methodName, final Object... args) {
+ sNativeQueue.queueUntil(state, cls, methodName, args);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java
new file mode 100644
index 0000000000..5689944717
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java
@@ -0,0 +1,106 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.os.Build;
+import android.provider.Settings.Secure;
+import android.view.View;
+import android.view.inputmethod.InputMethodInfo;
+import android.view.inputmethod.InputMethodManager;
+import java.util.Collection;
+
+public final class InputMethods {
+ public static final String METHOD_ANDROID_LATINIME = "com.android.inputmethod.latin/.LatinIME";
+ // ATOK has a lot of package names since they release custom versions.
+ public static final String METHOD_ATOK_PREFIX = "com.justsystems.atokmobile";
+ public static final String METHOD_ATOK_OEM_PREFIX = "com.atok.mobile.";
+ public static final String METHOD_GOOGLE_JAPANESE_INPUT =
+ "com.google.android.inputmethod.japanese/.MozcService";
+ public static final String METHOD_ATOK_OEM_SOFTBANK =
+ "com.mobiroo.n.justsystems.atok/.AtokInputMethodService";
+ public static final String METHOD_GOOGLE_LATINIME =
+ "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME";
+ public static final String METHOD_HTC_TOUCH_INPUT = "com.htc.android.htcime/.HTCIMEService";
+ public static final String METHOD_IWNN =
+ "jp.co.omronsoft.iwnnime.ml/.standardcommon.IWnnLanguageSwitcher";
+ public static final String METHOD_OPENWNN_PLUS = "com.owplus.ime.openwnnplus/.OpenWnnJAJP";
+ public static final String METHOD_SAMSUNG = "com.sec.android.inputmethod/.SamsungKeypad";
+ public static final String METHOD_SIMEJI = "com.adamrocker.android.input.simeji/.OpenWnnSimeji";
+ public static final String METHOD_SONY =
+ "com.sonyericsson.textinput.uxp/.glue.InputMethodServiceGlue";
+ public static final String METHOD_SWIFTKEY =
+ "com.touchtype.swiftkey/com.touchtype.KeyboardService";
+ public static final String METHOD_SWYPE = "com.swype.android.inputmethod/.SwypeInputMethod";
+ public static final String METHOD_SWYPE_BETA = "com.nuance.swype.input/.IME";
+ public static final String METHOD_TOUCHPAL_KEYBOARD =
+ "com.cootek.smartinputv5/com.cootek.smartinput5.TouchPalIME";
+
+ private InputMethods() {}
+
+ public static String getCurrentInputMethod(final Context context) {
+ final String inputMethod =
+ Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD);
+ return (inputMethod != null ? inputMethod : "");
+ }
+
+ public static InputMethodInfo getInputMethodInfo(
+ final Context context, final String inputMethod) {
+ final InputMethodManager imm = getInputMethodManager(context);
+ final Collection<InputMethodInfo> infos = imm.getEnabledInputMethodList();
+ for (final InputMethodInfo info : infos) {
+ if (info.getId().equals(inputMethod)) {
+ return info;
+ }
+ }
+ return null;
+ }
+
+ public static InputMethodManager getInputMethodManager(final Context context) {
+ return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ public static void restartInput(final Context context, final View view) {
+ final InputMethodManager imm = getInputMethodManager(context);
+ if (imm != null) {
+ imm.restartInput(view);
+ }
+ }
+
+ public static boolean needsSoftResetWorkaround(final String inputMethod) {
+ // Stock latin IME on Android 4.2 and above
+ return Build.VERSION.SDK_INT >= 17
+ && (METHOD_ANDROID_LATINIME.equals(inputMethod)
+ || METHOD_GOOGLE_LATINIME.equals(inputMethod));
+ }
+
+ /**
+ * Check input method if we require a workaround to remove composition in {@link
+ * android.view.inputmethod.InputMethodManager.updateSelection}.
+ *
+ * @param inputMethod The input method name by {@link #getCurrentInputMethod}.
+ * @return true if {@link android.view.inputmethod.InputMethodManager.updateSelection} doesn't
+ * remove the composition, use {@link
+ * android.view.inputmethod.InputMehtodManager.restartInput} to remove it in this case.
+ */
+ public static boolean needsRestartInput(final String inputMethod) {
+ return inputMethod.startsWith(METHOD_ATOK_PREFIX)
+ || inputMethod.startsWith(METHOD_ATOK_OEM_PREFIX)
+ || METHOD_ATOK_OEM_SOFTBANK.equals(inputMethod);
+ }
+
+ public static boolean shouldCommitCharAsKey(final String inputMethod) {
+ return METHOD_HTC_TOUCH_INPUT.equals(inputMethod);
+ }
+
+ public static boolean needsRestartOnReplaceRemove(final Context context) {
+ final String inputMethod = getCurrentInputMethod(context);
+ return METHOD_SONY.equals(inputMethod);
+ }
+
+ // TODO: Replace usages by definition in EditorInfoCompat once available (bug 1385726).
+ public static final int IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java
new file mode 100644
index 0000000000..2003abcc6f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java
@@ -0,0 +1,137 @@
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+/**
+ * A {@link android.view.SurfaceView} which allows a {@link android.widget.Magnifier} widget to
+ * magnify a custom {@link android.view.Surface} rather than the SurfaceView's default Surface.
+ */
+public class MagnifiableSurfaceView extends SurfaceView {
+ private static final String LOGTAG = "MagnifiableSurfaceView";
+
+ private SurfaceHolderWrapper mHolder;
+
+ public MagnifiableSurfaceView(final Context context) {
+ super(context);
+ }
+
+ @Override
+ public SurfaceHolder getHolder() {
+ if (mHolder != null) {
+ // Only return our custom holder if we are being called from the Magnifier class.
+ // Throwable.getStackTrace() is faster than Thread.getStackTrace(), but still has a cost,
+ // hence why we only check the caller if we have set an override Surface.
+ final StackTraceElement[] stackTrace = new Throwable().getStackTrace();
+ if (stackTrace.length >= 2
+ && stackTrace[1].getClassName().equals("android.widget.Magnifier")) {
+ return mHolder;
+ }
+ }
+ return super.getHolder();
+ }
+
+ /**
+ * Sets the Surface that should be magnified by a Magnifier widget.
+ *
+ * <p>This should be set immediately before calling {@link android.widget.Magnifier#show()} or
+ * {@link android.widget.Magnifier#update()}, and unset immediately afterwards.
+ *
+ * @param surface The Surface to be magnified. If null, the SurfaceView's default Surface will be
+ * used.
+ */
+ public void setMagnifierSurface(final Surface surface) {
+ if (surface != null) {
+ mHolder = new SurfaceHolderWrapper(getHolder(), surface);
+ } else {
+ mHolder = null;
+ }
+ }
+
+ /**
+ * A {@link android.view.SurfaceHolder} implementation that simply forwards all methods to a
+ * provided SurfaceHolder instance, except for getSurface() which returns a custom Surface.
+ */
+ private class SurfaceHolderWrapper implements SurfaceHolder {
+ private final SurfaceHolder mHolder;
+ private final Surface mSurface;
+
+ public SurfaceHolderWrapper(final SurfaceHolder holder, final Surface surface) {
+ mHolder = holder;
+ mSurface = surface;
+ }
+
+ @Override
+ public void addCallback(final Callback callback) {
+ mHolder.addCallback(callback);
+ }
+
+ @Override
+ public void removeCallback(final Callback callback) {
+ mHolder.removeCallback(callback);
+ }
+
+ @Override
+ public boolean isCreating() {
+ return mHolder.isCreating();
+ }
+
+ @Override
+ public void setType(final int type) {
+ mHolder.setType(type);
+ }
+
+ @Override
+ public void setFixedSize(final int width, final int height) {
+ mHolder.setFixedSize(width, height);
+ }
+
+ @Override
+ public void setSizeFromLayout() {
+ mHolder.setSizeFromLayout();
+ }
+
+ @Override
+ public void setFormat(final int format) {
+ mHolder.setFormat(format);
+ }
+
+ @Override
+ public void setKeepScreenOn(final boolean screenOn) {
+ mHolder.setKeepScreenOn(screenOn);
+ }
+
+ @Override
+ public Canvas lockCanvas() {
+ return mHolder.lockCanvas();
+ }
+
+ @Override
+ public Canvas lockCanvas(final Rect dirty) {
+ return mHolder.lockCanvas(dirty);
+ }
+
+ @Override
+ public void unlockCanvasAndPost(final Canvas canvas) {
+ mHolder.unlockCanvasAndPost(canvas);
+ }
+
+ @Override
+ public Rect getSurfaceFrame() {
+ return mHolder.getSurfaceFrame();
+ }
+
+ @Override
+ public Surface getSurface() {
+ return mSurface;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
new file mode 100644
index 0000000000..ff26d99dea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
@@ -0,0 +1,186 @@
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Defines a map that holds a collection of values against each key.
+ *
+ * @param <K> Key type
+ * @param <T> Value type
+ */
+public class MultiMap<K, T> {
+ private HashMap<K, List<T>> mMap;
+ private final List<T> mEmptyList = Collections.unmodifiableList(new ArrayList<>());
+
+ /**
+ * Creates a MultiMap with specified initial capacity.
+ *
+ * @param count Initial capacity
+ */
+ public MultiMap(final int count) {
+ mMap = new HashMap<>(count);
+ }
+
+ /** Creates a MultiMap with default initial capacity. */
+ public MultiMap() {
+ mMap = new HashMap<>();
+ }
+
+ private void ensure(final K key) {
+ if (!mMap.containsKey(key)) {
+ mMap.put(key, new ArrayList<>());
+ }
+ }
+
+ /**
+ * @return A map of key to the list of values associated to it
+ */
+ public Map<K, List<T>> asMap() {
+ return mMap;
+ }
+
+ /**
+ * @return The number of keys present in this map
+ */
+ public int size() {
+ return mMap.size();
+ }
+
+ /**
+ * @return whether this map is empty or not
+ */
+ public boolean isEmpty() {
+ return mMap.isEmpty();
+ }
+
+ /**
+ * Checks if a key is present in this map.
+ *
+ * @param key the key to check
+ * @return True if the map contains this key, false otherwise.
+ */
+ public boolean containsKey(final @Nullable K key) {
+ return mMap.containsKey(key);
+ }
+
+ /**
+ * Checks if a (key, value) pair is present in this map.
+ *
+ * @param key the key to check
+ * @param value the value to check
+ * @return true if there is a value associated to the given key, false otherwise
+ */
+ public boolean containsEntry(final @Nullable K key, final @Nullable T value) {
+ if (!mMap.containsKey(key)) {
+ return false;
+ }
+
+ return mMap.get(key).contains(value);
+ }
+
+ /**
+ * Gets the values associated with the given key.
+ *
+ * @param key the key to check
+ * @return the list of values associated with keys, an empty list if no values are associated with
+ * key.
+ */
+ @NonNull
+ public List<T> get(final @Nullable K key) {
+ if (!mMap.containsKey(key)) {
+ return mEmptyList;
+ }
+
+ return mMap.get(key);
+ }
+
+ /**
+ * Add a (key, value) mapping to this map.
+ *
+ * @param key the key to add
+ * @param value the value to add
+ */
+ @Nullable
+ public void add(final @NonNull K key, final @NonNull T value) {
+ ensure(key);
+ mMap.get(key).add(value);
+ }
+
+ /**
+ * Add a list of values to the given key.
+ *
+ * @param key the key to add
+ * @param values the list of values to add
+ * @return the final list of values or null if no value was added
+ */
+ @Nullable
+ public List<T> addAll(final @NonNull K key, final @NonNull List<T> values) {
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+
+ ensure(key);
+
+ final List<T> result = mMap.get(key);
+ result.addAll(values);
+ return result;
+ }
+
+ /**
+ * Remove all mappings for the given key.
+ *
+ * @param key the key
+ * @return values associated with the key or null if no values was present.
+ */
+ @Nullable
+ public List<T> remove(final @Nullable K key) {
+ return mMap.remove(key);
+ }
+
+ /**
+ * Remove a (key, value) mapping from this map
+ *
+ * @param key the key to remove
+ * @param value the value to remove
+ * @return true if the (key, value) mapping was present, false otherwise
+ */
+ @Nullable
+ public boolean remove(final @Nullable K key, final @Nullable T value) {
+ if (!mMap.containsKey(key)) {
+ return false;
+ }
+
+ final List<T> values = mMap.get(key);
+ final boolean wasPresent = values.remove(value);
+
+ if (values.isEmpty()) {
+ mMap.remove(key);
+ }
+
+ return wasPresent;
+ }
+
+ /** Remove all mappings from this map. */
+ public void clear() {
+ mMap.clear();
+ }
+
+ /**
+ * @return a set with all the keys for this map.
+ */
+ @NonNull
+ public Set<K> keySet() {
+ return mMap.keySet();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java
new file mode 100644
index 0000000000..7932e6c839
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java
@@ -0,0 +1,225 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+
+public class NativeQueue {
+ private static final String LOGTAG = "GeckoNativeQueue";
+
+ public interface State {
+ boolean is(final State other);
+
+ boolean isAtLeast(final State other);
+ }
+
+ private volatile State mState;
+ private final State mReadyState;
+
+ public NativeQueue(final State initial, final State ready) {
+ mState = initial;
+ mReadyState = ready;
+ }
+
+ public boolean isReady() {
+ return getState().isAtLeast(mReadyState);
+ }
+
+ public State getState() {
+ return mState;
+ }
+
+ public boolean setState(final State newState) {
+ return checkAndSetState(null, newState);
+ }
+
+ public synchronized boolean checkAndSetState(final State expectedState, final State newState) {
+ if (expectedState != null && !mState.is(expectedState)) {
+ return false;
+ }
+ flushQueuedLocked(newState);
+ mState = newState;
+ return true;
+ }
+
+ private static class QueuedCall {
+ public Method method;
+ public Object target;
+ public Object[] args;
+ public State state;
+
+ public QueuedCall(
+ final Method method, final Object target, final Object[] args, final State state) {
+ this.method = method;
+ this.target = target;
+ this.args = args;
+ this.state = state;
+ }
+ }
+
+ private static final int QUEUED_CALLS_COUNT = 16;
+ /* package */ final ArrayList<QueuedCall> mQueue = new ArrayList<>(QUEUED_CALLS_COUNT);
+
+ // Invoke the given Method and handle checked Exceptions.
+ private static void invokeMethod(final Method method, final Object obj, final Object[] args) {
+ try {
+ method.setAccessible(true);
+ method.invoke(obj, args);
+ } catch (final IllegalAccessException e) {
+ throw new IllegalStateException("Unexpected exception", e);
+ } catch (final InvocationTargetException e) {
+ throw new UnsupportedOperationException("Cannot make call", e.getCause());
+ }
+ }
+
+ // Queue a call to the given method.
+ private void queueNativeCallLocked(
+ final Class<?> cls,
+ final String methodName,
+ final Object obj,
+ final Object[] args,
+ final State state) {
+ final ArrayList<Class<?>> argTypes = new ArrayList<>(args.length);
+ final ArrayList<Object> argValues = new ArrayList<>(args.length);
+
+ for (int i = 0; i < args.length; i++) {
+ if (args[i] instanceof Class) {
+ argTypes.add((Class<?>) args[i]);
+ argValues.add(args[++i]);
+ continue;
+ }
+ Class<?> argType = args[i].getClass();
+ if (argType == Boolean.class) argType = Boolean.TYPE;
+ else if (argType == Byte.class) argType = Byte.TYPE;
+ else if (argType == Character.class) argType = Character.TYPE;
+ else if (argType == Double.class) argType = Double.TYPE;
+ else if (argType == Float.class) argType = Float.TYPE;
+ else if (argType == Integer.class) argType = Integer.TYPE;
+ else if (argType == Long.class) argType = Long.TYPE;
+ else if (argType == Short.class) argType = Short.TYPE;
+ argTypes.add(argType);
+ argValues.add(args[i]);
+ }
+ final Method method;
+ try {
+ method = cls.getDeclaredMethod(methodName, argTypes.toArray(new Class<?>[argTypes.size()]));
+ } catch (final NoSuchMethodException e) {
+ throw new IllegalArgumentException("Cannot find method", e);
+ }
+
+ if (!Modifier.isNative(method.getModifiers())) {
+ // As a precaution, we disallow queuing non-native methods. Queuing non-native
+ // methods is dangerous because the method could end up being called on either
+ // the original thread or the Gecko thread depending on timing. Native methods
+ // usually handle this by posting an event to the Gecko thread automatically,
+ // but there is no automatic mechanism for non-native methods.
+ throw new UnsupportedOperationException("Not allowed to queue non-native methods");
+ }
+
+ if (getState().isAtLeast(state)) {
+ invokeMethod(method, obj, argValues.toArray());
+ return;
+ }
+
+ mQueue.add(new QueuedCall(method, obj, argValues.toArray(), state));
+ }
+
+ /**
+ * Queue a call to the given instance method if the given current state does not satisfy the
+ * isReady condition.
+ *
+ * @param obj Object that declares the instance method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntilReady(
+ final Object obj, final String methodName, final Object... args) {
+ queueNativeCallLocked(obj.getClass(), methodName, obj, args, mReadyState);
+ }
+
+ /**
+ * Queue a call to the given static method if the given current state does not satisfy the isReady
+ * condition.
+ *
+ * @param cls Class that declares the static method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntilReady(
+ final Class<?> cls, final String methodName, final Object... args) {
+ queueNativeCallLocked(cls, methodName, null, args, mReadyState);
+ }
+
+ /**
+ * Queue a call to the given instance method if the given current state does not satisfy the given
+ * state.
+ *
+ * @param state The state in which the native call could be executed.
+ * @param obj Object that declares the instance method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntil(
+ final State state, final Object obj, final String methodName, final Object... args) {
+ queueNativeCallLocked(obj.getClass(), methodName, obj, args, state);
+ }
+
+ /**
+ * Queue a call to the given static method if the given current state does not satisfy the given
+ * state.
+ *
+ * @param state The state in which the native call could be executed.
+ * @param cls Class that declares the static method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntil(
+ final State state, final Class<?> cls, final String methodName, final Object... args) {
+ queueNativeCallLocked(cls, methodName, null, args, state);
+ }
+
+ // Run all queued methods
+ private void flushQueuedLocked(final State state) {
+ int lastSkipped = -1;
+ for (int i = 0; i < mQueue.size(); i++) {
+ final QueuedCall call = mQueue.get(i);
+ if (call == null) {
+ // We already handled the call.
+ continue;
+ }
+ if (!state.isAtLeast(call.state)) {
+ // The call is not ready yet; skip it.
+ lastSkipped = i;
+ continue;
+ }
+ // Mark as handled.
+ mQueue.set(i, null);
+
+ invokeMethod(call.method, call.target, call.args);
+ }
+ if (lastSkipped < 0) {
+ // We're done here; release the memory
+ mQueue.clear();
+ } else if (lastSkipped < mQueue.size() - 1) {
+ // We skipped some; free up null entries at the end,
+ // but keep all the previous entries for later.
+ mQueue.subList(lastSkipped + 1, mQueue.size()).clear();
+ }
+ }
+
+ public synchronized void reset(final State initial) {
+ mQueue.clear();
+ mState = initial;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java
new file mode 100644
index 0000000000..edd6c7418a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+class ScreenManagerHelper {
+
+ /** Trigger a refresh of the cached screen information held by Gecko. */
+ public static void refreshScreenInfo() {
+ // Screen data is initialised automatically on startup, so no need to queue the call if
+ // Gecko isn't running yet.
+ if (GeckoThread.isRunning()) {
+ nativeRefreshScreenInfo();
+ }
+ }
+
+ @WrapForJNI(stubName = "RefreshScreenInfo", dispatchTo = "gecko")
+ private static native void nativeRefreshScreenInfo();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java
new file mode 100644
index 0000000000..7c6f572edc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java
@@ -0,0 +1,230 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil -*- */
+/* vim: set ts=20 sts=4 et sw=4: */
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.os.Build;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.UtteranceProgressListener;
+import android.util.Log;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class SpeechSynthesisService {
+ private static final String LOGTAG = "GeckoSpeechSynthesis";
+ // Object type is used to make it easier to remove android.speech dependencies using Proguard.
+ private static Object sTTS;
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void initSynth() {
+ initSynthInternal();
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void initSynthInternal() {
+ if (sTTS != null) {
+ return;
+ }
+
+ final Context ctx = GeckoAppShell.getApplicationContext();
+
+ sTTS =
+ new TextToSpeech(
+ ctx,
+ new TextToSpeech.OnInitListener() {
+ @Override
+ public void onInit(final int status) {
+ if (status != TextToSpeech.SUCCESS) {
+ Log.w(LOGTAG, "Failed to initialize TextToSpeech");
+ return;
+ }
+
+ setUtteranceListener();
+ registerVoicesByLocale();
+ }
+ });
+ }
+
+ private static TextToSpeech getTTS() {
+ return (TextToSpeech) sTTS;
+ }
+
+ private static void registerVoicesByLocale() {
+ ThreadUtils.postToBackgroundThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ final TextToSpeech tss = getTTS();
+ if (tss == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+ final Locale defaultLocale =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2
+ ? tss.getDefaultLanguage()
+ : tss.getLanguage();
+ for (final Locale locale : getAvailableLanguages()) {
+ final Set<String> features = tss.getFeatures(locale);
+ final boolean isLocal =
+ features != null
+ && features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS);
+ final String localeStr = locale.toString();
+ registerVoice(
+ "moz-tts:android:" + localeStr,
+ locale.getDisplayName(),
+ localeStr.replace("_", "-"),
+ !isLocal,
+ defaultLocale == locale);
+ }
+ doneRegisteringVoices();
+ }
+ });
+ }
+
+ private static Set<Locale> getAvailableLanguages() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // While this method was introduced in 21, it seems that it
+ // has not been implemented in the speech service side until 23.
+ final Set<Locale> availableLanguages = getTTS().getAvailableLanguages();
+ if (availableLanguages != null) {
+ return availableLanguages;
+ }
+ }
+ final Set<Locale> locales = new HashSet<Locale>();
+ for (final Locale locale : Locale.getAvailableLocales()) {
+ if (locale.getVariant().isEmpty() && getTTS().isLanguageAvailable(locale) > 0) {
+ locales.add(locale);
+ }
+ }
+
+ return locales;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void registerVoice(
+ String uri, String name, String locale, boolean isNetwork, boolean isDefault);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void doneRegisteringVoices();
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String speak(
+ final String uri,
+ final String text,
+ final float rate,
+ final float pitch,
+ final float volume) {
+ final AtomicBoolean result = new AtomicBoolean(false);
+ final String utteranceId = UUID.randomUUID().toString();
+ speakInternal(uri, text, rate, pitch, volume, utteranceId, result);
+ return result.get() ? utteranceId : null;
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void speakInternal(
+ final String uri,
+ final String text,
+ final float rate,
+ final float pitch,
+ final float volume,
+ final String utteranceId,
+ final AtomicBoolean result) {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ final HashMap<String, String> params = new HashMap<String, String>();
+ params.put(TextToSpeech.Engine.KEY_PARAM_VOLUME, Float.toString(volume));
+ params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId);
+ final TextToSpeech tss = (TextToSpeech) sTTS;
+ tss.setLanguage(new Locale(uri.substring("moz-tts:android:".length())));
+ tss.setSpeechRate(rate);
+ tss.setPitch(pitch);
+ final int speakRes = tss.speak(text, TextToSpeech.QUEUE_FLUSH, params);
+ result.set(speakRes == TextToSpeech.SUCCESS);
+ }
+
+ private static void setUtteranceListener() {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ getTTS()
+ .setOnUtteranceProgressListener(
+ new UtteranceProgressListener() {
+ @Override
+ public void onDone(final String utteranceId) {
+ dispatchEnd(utteranceId);
+ }
+
+ @Override
+ public void onError(final String utteranceId) {
+ dispatchError(utteranceId);
+ }
+
+ @Override
+ public void onStart(final String utteranceId) {
+ dispatchStart(utteranceId);
+ }
+
+ @Override
+ public void onStop(final String utteranceId, final boolean interrupted) {
+ if (interrupted) {
+ dispatchEnd(utteranceId);
+ } else {
+ // utterance isn't started yet.
+ dispatchError(utteranceId);
+ }
+ }
+
+ public void onRangeStart(
+ final String utteranceId, final int start, final int end, final int frame) {
+ dispatchBoundary(utteranceId, start, end);
+ }
+ });
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchStart(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchEnd(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchError(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchBoundary(String utteranceId, int start, int end);
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void stop() {
+ stopInternal();
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void stopInternal() {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ getTTS().stop();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ // Android M has onStop method. If Android L or above, dispatch
+ // event
+ dispatchEnd(null);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java
new file mode 100644
index 0000000000..5ba7ba5310
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java
@@ -0,0 +1,220 @@
+/* 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/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.SurfaceTexture;
+import android.os.Build;
+import android.util.Log;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.TextureView;
+import android.view.View;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/** Provides transparent access to either a SurfaceView or TextureView */
+public class SurfaceViewWrapper {
+ private static final String LOGTAG = "SurfaceViewWrapper";
+
+ private ListenerWrapper mListenerWrapper;
+ private View mView;
+
+ // Only one of these will be non-null at any point in time
+ SurfaceView mSurfaceView;
+ TextureView mTextureView;
+
+ public SurfaceViewWrapper(final Context context) {
+ // By default, use SurfaceView
+ mListenerWrapper = new ListenerWrapper();
+ initSurfaceView(context);
+ }
+
+ private void initSurfaceView(final Context context) {
+ mSurfaceView = new MagnifiableSurfaceView(context);
+ mSurfaceView.setBackgroundColor(Color.TRANSPARENT);
+ mSurfaceView.getHolder().setFormat(PixelFormat.TRANSPARENT);
+ mView = mSurfaceView;
+ }
+
+ public void useSurfaceView(final Context context) {
+ if (mTextureView != null) {
+ mListenerWrapper.onSurfaceTextureDestroyed(mTextureView.getSurfaceTexture());
+ mTextureView = null;
+ }
+ mListenerWrapper.reset();
+ initSurfaceView(context);
+ }
+
+ public void useTextureView(final Context context) {
+ if (mSurfaceView != null) {
+ mListenerWrapper.surfaceDestroyed(mSurfaceView.getHolder());
+ mSurfaceView = null;
+ }
+ mListenerWrapper.reset();
+ mTextureView = new TextureView(context);
+ mTextureView.setSurfaceTextureListener(mListenerWrapper);
+ mView = mTextureView;
+ }
+
+ public void setBackgroundColor(final int color) {
+ if (mSurfaceView != null) {
+ mSurfaceView.setBackgroundColor(color);
+ } else {
+ Log.e(LOGTAG, "TextureView doesn't support background color.");
+ }
+ }
+
+ public void setListener(final Listener listener) {
+ mListenerWrapper.mListener = listener;
+ mSurfaceView.getHolder().addCallback(mListenerWrapper);
+ }
+
+ public int getWidth() {
+ if (mSurfaceView != null) {
+ return mSurfaceView.getHolder().getSurfaceFrame().right;
+ }
+ return mListenerWrapper.mWidth;
+ }
+
+ public int getHeight() {
+ if (mSurfaceView != null) {
+ return mSurfaceView.getHolder().getSurfaceFrame().bottom;
+ }
+ return mListenerWrapper.mHeight;
+ }
+
+ /**
+ * Returns the SurfaceControl associated with the SurfaceView, or null on unsupported SDK versions
+ * or when using the TextureView backend.
+ */
+ public SurfaceControl getSurfaceControl() {
+ if (mSurfaceView != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ return mSurfaceView.getSurfaceControl();
+ }
+
+ return null;
+ }
+
+ public Surface getSurface() {
+ if (mSurfaceView != null) {
+ return mSurfaceView.getHolder().getSurface();
+ }
+
+ return mListenerWrapper.mSurface;
+ }
+
+ public View getView() {
+ return mView;
+ }
+
+ /**
+ * Returns whether the Surface's underlying BufferQueue has been abandoned.
+ *
+ * <p>On some devices a Surface obtained during the surfaceChanged callback can become abandoned
+ * by the time we attempt to use it, despite surfaceDestroyed not being called. Attempting to
+ * render in to such a Surface will fail. This function checks whether a Surface is in such state,
+ * allowing us to request a new Surface if so.
+ */
+ public static boolean isSurfaceAbandoned(final Surface surface) {
+ // If JNI hasn't been loaded yet we cannot check whether the Surface has been abandoned.
+ // Just assume the Surface is okay.
+ if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ return false;
+ }
+
+ return isSurfaceAbandonedNative(surface);
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current", stubName = "IsSurfaceAbandoned")
+ private static native boolean isSurfaceAbandonedNative(final Surface surface);
+
+ /**
+ * Translates SurfaceTextureListener and SurfaceHolder.Callback into a common interface
+ * SurfaceViewWrapper.Listener
+ */
+ private class ListenerWrapper
+ implements TextureView.SurfaceTextureListener, SurfaceHolder.Callback {
+ private Listener mListener;
+
+ // TextureView doesn't provide getters for these so we keep track of them here
+ private Surface mSurface;
+ private int mWidth;
+ private int mHeight;
+
+ public void reset() {
+ mWidth = 0;
+ mHeight = 0;
+ mSurface = null;
+ }
+
+ // TextureView
+ @Override
+ public void onSurfaceTextureAvailable(
+ final SurfaceTexture surface, final int width, final int height) {
+ mSurface = new Surface(surface);
+ mWidth = width;
+ mHeight = height;
+ if (mListener != null) {
+ mListener.onSurfaceChanged(mSurface, null, width, height);
+ }
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(
+ final SurfaceTexture surface, final int width, final int height) {
+ mWidth = width;
+ mHeight = height;
+ if (mListener != null) {
+ mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight);
+ }
+ }
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(final SurfaceTexture surface) {
+ if (mListener != null) {
+ mListener.onSurfaceDestroyed();
+ }
+ mSurface = null;
+ return false;
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(final SurfaceTexture surface) {
+ mSurface = new Surface(surface);
+ if (mListener != null) {
+ mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight);
+ }
+ }
+
+ // SurfaceView
+ @Override
+ public void surfaceCreated(final SurfaceHolder holder) {}
+
+ @Override
+ public void surfaceChanged(
+ final SurfaceHolder holder, final int format, final int width, final int height) {
+ if (mListener != null) {
+ mListener.onSurfaceChanged(holder.getSurface(), getSurfaceControl(), width, height);
+ }
+ }
+
+ @Override
+ public void surfaceDestroyed(final SurfaceHolder holder) {
+ if (mListener != null) {
+ mListener.onSurfaceDestroyed();
+ }
+ }
+ }
+
+ public interface Listener {
+ void onSurfaceChanged(Surface surface, SurfaceControl surfaceControl, int width, int height);
+
+ void onSurfaceDestroyed();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java
new file mode 100644
index 0000000000..3c9c1f90a0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java
@@ -0,0 +1,102 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko;
+
+import android.os.SystemClock;
+import android.util.Log;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * All telemetry times are relative to one of two clocks:
+ *
+ * <p>* Real time since the device was booted, including deep sleep. Use this as a substitute for
+ * wall clock. * Uptime since the device was booted, excluding deep sleep. Use this to avoid timing
+ * a user activity when their phone is in their pocket!
+ *
+ * <p>The majority of methods in this class are defined in terms of real time.
+ */
+public class TelemetryUtils {
+ private static final String LOGTAG = "TelemetryUtils";
+
+ @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko")
+ private static native void nativeAddHistogram(String name, int value);
+
+ public static long uptime() {
+ return SystemClock.uptimeMillis();
+ }
+
+ public static long realtime() {
+ return SystemClock.elapsedRealtime();
+ }
+
+ // Define new histograms in:
+ // toolkit/components/telemetry/Histograms.json
+ public static void addToHistogram(final String name, final int value) {
+ if (GeckoThread.isRunning()) {
+ nativeAddHistogram(name, value);
+ } else {
+ GeckoThread.queueNativeCall(
+ TelemetryUtils.class, "nativeAddHistogram", String.class, name, value);
+ }
+ }
+
+ public abstract static class Timer {
+ private final long mStartTime;
+ private final String mName;
+
+ private volatile boolean mHasFinished;
+ private volatile long mElapsed = -1;
+
+ protected abstract long now();
+
+ public Timer(final String name) {
+ mName = name;
+ mStartTime = now();
+ }
+
+ public void cancel() {
+ mHasFinished = true;
+ }
+
+ public long getElapsed() {
+ return mElapsed;
+ }
+
+ public void stop() {
+ // Only the first stop counts.
+ if (mHasFinished) {
+ return;
+ }
+
+ mHasFinished = true;
+
+ final long elapsed = now() - mStartTime;
+ if (elapsed < 0) {
+ Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?");
+ return;
+ }
+
+ mElapsed = elapsed;
+ if (elapsed > Integer.MAX_VALUE) {
+ Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram.");
+ return;
+ }
+
+ addToHistogram(mName, (int) (elapsed));
+ }
+ }
+
+ public static class UptimeTimer extends Timer {
+ public UptimeTimer(final String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return TelemetryUtils.uptime();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java
new file mode 100644
index 0000000000..805e0a3f79
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java
@@ -0,0 +1,25 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation is used to tag classes that are conditionally built behind build flags. Any
+ * generated JNI bindings will incorporate the specified build flags.
+ */
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface BuildFlag {
+ /**
+ * Preprocessor macro for conditionally building the generated bindings. "MOZ_FOO" wraps generated
+ * bindings in "#ifdef MOZ_FOO / #endif" "!MOZ_FOO" wraps generated bindings in "#ifndef MOZ_FOO /
+ * #endif"
+ */
+ String value() default "";
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java
new file mode 100644
index 0000000000..d6140a1ffb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java
@@ -0,0 +1,14 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface JNITarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java
new file mode 100644
index 0000000000..e873ebeb96
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java
@@ -0,0 +1,18 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/*
+ * Used to indicate to ProGuard that this definition is accessed
+ * via reflection and should not be stripped from the source.
+ */
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface ReflectionTarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java
new file mode 100644
index 0000000000..e15875dc8b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java
@@ -0,0 +1,14 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface RobocopTarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java
new file mode 100644
index 0000000000..f58dea1487
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java
@@ -0,0 +1,14 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface WebRTCJNITarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java
new file mode 100644
index 0000000000..6a3fcfcb1c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java
@@ -0,0 +1,56 @@
+/* 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/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation is used to tag methods that are to have wrapper methods generated. Such methods
+ * will be protected from destruction by ProGuard, and allow us to avoid writing by hand large
+ * amounts of boring boilerplate.
+ */
+@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface WrapForJNI {
+ /** Skip this member when generating wrappers for a whole class. */
+ boolean skip() default false;
+
+ /**
+ * Optional parameter specifying the name of the generated method stub. If omitted, the
+ * capitalized name of the Java method will be used.
+ */
+ String stubName() default "";
+
+ /**
+ * Action to take if member access returns an exception. - "abort" will cause a crash if there is
+ * a pending exception. - "ignore" will not handle any pending exceptions; it is then the caller's
+ * responsibility to handle exceptions. - "nsresult" will clear any pending exceptions and return
+ * an error code; not supported for native methods.
+ */
+ String exceptionMode() default "abort";
+
+ /**
+ * The thread that the method will be called from. One of "any", "gecko", or "ui". Not supported
+ * for fields.
+ */
+ String calledFrom() default "any";
+
+ /**
+ * The thread that the method call will be dispatched to. - "current" indicates no dispatching;
+ * only supported value for fields, constructors, non-native methods, and non-void native methods.
+ * - "gecko" indicates dispatching to the Gecko XPCOM (nsThread) event queue. - "gecko_priority"
+ * indicates dispatching to the Gecko widget (nsAppShell) event queue; in most cases, events in
+ * the widget event queue (aka native event queue) are favored over events in the XPCOM event
+ * queue. - "proxy" indicates dispatching to a proxy function as a function object; see
+ * widget/jni/Natives.h.
+ */
+ String dispatchTo() default "current";
+
+ /** Generate a getter instead of a literal. Only supported for static final fields. */
+ boolean noLiteral() default false;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java
new file mode 100644
index 0000000000..c87bf466d0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java
@@ -0,0 +1,72 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/** This class receives HW vsync events through a {@link Choreographer}. */
+@WrapForJNI
+/* package */ final class AndroidVsync extends JNIObject implements Choreographer.FrameCallback {
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ private static final String LOGTAG = "AndroidVsync";
+
+ /* package */ Choreographer mChoreographer;
+ private volatile boolean mObservingVsync;
+
+ public AndroidVsync() {
+ final Handler mainHandler = new Handler(Looper.getMainLooper());
+ mainHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mChoreographer = Choreographer.getInstance();
+ if (mObservingVsync) {
+ mChoreographer.postFrameCallback(AndroidVsync.this);
+ }
+ }
+ });
+ }
+
+ @WrapForJNI(stubName = "NotifyVsync")
+ private native void nativeNotifyVsync(final long frameTimeNanos);
+
+ // Choreographer callback implementation.
+ public void doFrame(final long frameTimeNanos) {
+ if (mObservingVsync) {
+ mChoreographer.postFrameCallback(this);
+ nativeNotifyVsync(frameTimeNanos);
+ }
+ }
+
+ /**
+ * Start/stop observing Vsync event.
+ *
+ * @param enable true to start observing; false to stop.
+ * @return true if observing and false if not.
+ */
+ @WrapForJNI
+ public synchronized boolean observeVsync(final boolean enable) {
+ if (mObservingVsync != enable) {
+ mObservingVsync = enable;
+
+ if (mChoreographer != null) {
+ if (enable) {
+ mChoreographer.postFrameCallback(this);
+ } else {
+ mChoreographer.removeFrameCallback(this);
+ }
+ }
+ }
+ return mObservingVsync;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java
new file mode 100644
index 0000000000..1378a284b7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java
@@ -0,0 +1,26 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.RemoteException;
+import android.view.Surface;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class CompositorSurfaceManager {
+ private static final String LOGTAG = "CompSurfManager";
+
+ private ICompositorSurfaceManager mManager;
+
+ public CompositorSurfaceManager(final ICompositorSurfaceManager aManager) {
+ mManager = aManager;
+ }
+
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void onSurfaceChanged(final int widgetId, final Surface surface)
+ throws RemoteException {
+ mManager.onSurfaceChanged(widgetId, surface);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java
new file mode 100644
index 0000000000..6ff05f9555
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java
@@ -0,0 +1,147 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import static org.mozilla.geckoview.BuildConfig.DEBUG_BUILD;
+
+import android.graphics.SurfaceTexture;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.view.Surface;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class GeckoSurface extends Surface {
+ private static final String LOGTAG = "GeckoSurface";
+
+ private long mHandle;
+ private boolean mIsSingleBuffer;
+ private volatile boolean mIsAvailable;
+ private boolean mOwned = true;
+ private volatile boolean mIsReleased = false;
+
+ private int mMyPid;
+ // Locally allocated surface/texture. Do not pass it over IPC.
+ private GeckoSurface mSyncSurface;
+
+ @WrapForJNI(exceptionMode = "nsresult")
+ public GeckoSurface(final GeckoSurfaceTexture gst) {
+ super(gst);
+ mHandle = gst.getHandle();
+ mIsSingleBuffer = gst.isSingleBuffer();
+ mIsAvailable = true;
+ mMyPid = android.os.Process.myPid();
+ }
+
+ public GeckoSurface(final Parcel p, final SurfaceTexture dummy) {
+ // A no-arg constructor exists, but is hidden in the SDK. We need to create a dummy
+ // SurfaceTexture here in order to create the instance. This is used to transfer the
+ // GeckoSurface across binder.
+ super(dummy);
+
+ readFromParcel(p);
+ mHandle = p.readLong();
+ mIsSingleBuffer = p.readByte() == 1 ? true : false;
+ mIsAvailable = (p.readByte() == 1 ? true : false);
+ mMyPid = p.readInt();
+
+ dummy.release();
+ }
+
+ public static final Parcelable.Creator<GeckoSurface> CREATOR =
+ new Parcelable.Creator<GeckoSurface>() {
+ public GeckoSurface createFromParcel(final Parcel p) {
+ return new GeckoSurface(p, new SurfaceTexture(0));
+ }
+
+ public GeckoSurface[] newArray(final int size) {
+ return new GeckoSurface[size];
+ }
+ };
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+ if ((flags & Parcelable.PARCELABLE_WRITE_RETURN_VALUE) == 0) {
+ // GeckoSurface can be passed across processes as a return value or
+ // an argument, and should always tranfers its ownership (move) to
+ // the receiver of parcel. On the other hand, Surface is moved only
+ // when passed as a return value and releases itself when corresponding
+ // write flags is provided. (See Surface.writeToParcel().)
+ // The superclass method must be called here to ensure the local instance
+ // is truely forgotten.
+ super.release();
+ }
+ mOwned = false;
+
+ out.writeLong(mHandle);
+ out.writeByte((byte) (mIsSingleBuffer ? 1 : 0));
+ out.writeByte((byte) (mIsAvailable ? 1 : 0));
+ out.writeInt(mMyPid);
+ }
+
+ @Override
+ public void release() {
+ if (mIsReleased) {
+ return;
+ }
+ mIsReleased = true;
+
+ if (mSyncSurface != null) {
+ mSyncSurface.release();
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(mSyncSurface.getHandle());
+ if (gst != null) {
+ gst.decrementUse();
+ }
+ mSyncSurface = null;
+ }
+
+ if (mOwned) {
+ super.release();
+ }
+ }
+
+ @WrapForJNI
+ public long getHandle() {
+ return mHandle;
+ }
+
+ @WrapForJNI
+ public boolean getAvailable() {
+ return mIsAvailable;
+ }
+
+ @WrapForJNI
+ public boolean isReleased() {
+ return mIsReleased;
+ }
+
+ @WrapForJNI
+ public void setAvailable(final boolean available) {
+ mIsAvailable = available;
+ }
+
+ /* package */ boolean inProcess() {
+ return android.os.Process.myPid() == mMyPid;
+ }
+
+ /* package */ SyncConfig initSyncSurface(final int width, final int height) {
+ if (DEBUG_BUILD) {
+ if (inProcess()) {
+ throw new AssertionError("no need for sync when allocated in process");
+ }
+ }
+ if (GeckoSurfaceTexture.lookup(mHandle) != null) {
+ throw new AssertionError("texture#" + mHandle + " already in use.");
+ }
+ final GeckoSurfaceTexture texture =
+ GeckoSurfaceTexture.acquire(GeckoSurfaceTexture.isSingleBufferSupported(), mHandle);
+ texture.setDefaultBufferSize(width, height);
+ texture.track(mHandle);
+ mSyncSurface = new GeckoSurface(texture);
+
+ return new SyncConfig(mHandle, mSyncSurface, width, height);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java
new file mode 100644
index 0000000000..065fb8570a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java
@@ -0,0 +1,323 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.graphics.SurfaceTexture;
+import android.os.Build;
+import android.util.Log;
+import android.util.LongSparseArray;
+import androidx.annotation.RequiresApi;
+import java.util.LinkedList;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/* package */ final class GeckoSurfaceTexture extends SurfaceTexture {
+ private static final String LOGTAG = "GeckoSurfaceTexture";
+ private static final int MAX_SURFACE_TEXTURES = 200;
+ private static final LongSparseArray<GeckoSurfaceTexture> sSurfaceTextures =
+ new LongSparseArray<GeckoSurfaceTexture>();
+
+ private static LongSparseArray<LinkedList<GeckoSurfaceTexture>> sUnusedTextures =
+ new LongSparseArray<LinkedList<GeckoSurfaceTexture>>();
+
+ private long mHandle;
+ private boolean mIsSingleBuffer;
+
+ private long mAttachedContext;
+ private int mTexName;
+
+ private GeckoSurfaceTexture.Callbacks mListener;
+ private AtomicInteger mUseCount;
+ private boolean mFinalized;
+
+ private long mUpstream;
+ private NativeGLBlitHelper mBlitter;
+
+ private GeckoSurfaceTexture(final long handle) {
+ super(0);
+ init(handle, false);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.KITKAT)
+ private GeckoSurfaceTexture(final long handle, final boolean singleBufferMode) {
+ super(0, singleBufferMode);
+ init(handle, singleBufferMode);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ // We only want finalize() to be called once
+ if (mFinalized) {
+ return;
+ }
+
+ mFinalized = true;
+ super.finalize();
+ }
+
+ private void init(final long handle, final boolean singleBufferMode) {
+ mHandle = handle;
+ mIsSingleBuffer = singleBufferMode;
+ mUseCount = new AtomicInteger(1);
+
+ // Start off detached
+ detachFromGLContext();
+ }
+
+ @WrapForJNI
+ public long getHandle() {
+ return mHandle;
+ }
+
+ @WrapForJNI
+ public int getTexName() {
+ return mTexName;
+ }
+
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void attachToGLContext(final long context, final int texName) {
+ if (context == mAttachedContext && texName == mTexName) {
+ return;
+ }
+
+ attachToGLContext(texName);
+
+ mAttachedContext = context;
+ mTexName = texName;
+ }
+
+ @Override
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void detachFromGLContext() {
+ super.detachFromGLContext();
+
+ mAttachedContext = mTexName = 0;
+ }
+
+ @WrapForJNI
+ public synchronized boolean isAttachedToGLContext(final long context) {
+ return mAttachedContext == context;
+ }
+
+ @WrapForJNI
+ public boolean isSingleBuffer() {
+ return mIsSingleBuffer;
+ }
+
+ @Override
+ @WrapForJNI
+ public synchronized void updateTexImage() {
+ try {
+ if (mUpstream != 0) {
+ SurfaceAllocator.sync(mUpstream);
+ }
+ super.updateTexImage();
+ if (mListener != null) {
+ mListener.onUpdateTexImage();
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "updateTexImage() failed", e);
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ mUpstream = 0;
+ if (mBlitter != null) {
+ mBlitter.close();
+ }
+ try {
+ super.release();
+ synchronized (sSurfaceTextures) {
+ sSurfaceTextures.remove(mHandle);
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "release() failed", e);
+ }
+ }
+
+ @Override
+ @WrapForJNI
+ public synchronized void releaseTexImage() {
+ if (!mIsSingleBuffer) {
+ return;
+ }
+
+ try {
+ super.releaseTexImage();
+ if (mListener != null) {
+ mListener.onReleaseTexImage();
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "releaseTexImage() failed", e);
+ }
+ }
+
+ public synchronized void setListener(final GeckoSurfaceTexture.Callbacks listener) {
+ mListener = listener;
+ }
+
+ @WrapForJNI
+ public static boolean isSingleBufferSupported() {
+ return Build.VERSION.SDK_INT >= 19;
+ }
+
+ @WrapForJNI
+ public synchronized void incrementUse() {
+ mUseCount.incrementAndGet();
+ }
+
+ @WrapForJNI
+ public synchronized void decrementUse() {
+ final int useCount = mUseCount.decrementAndGet();
+
+ if (useCount == 0) {
+ setListener(null);
+
+ if (mAttachedContext == 0) {
+ release();
+ synchronized (sUnusedTextures) {
+ sSurfaceTextures.remove(mHandle);
+ }
+ return;
+ }
+
+ synchronized (sUnusedTextures) {
+ LinkedList<GeckoSurfaceTexture> list = sUnusedTextures.get(mAttachedContext);
+ if (list == null) {
+ list = new LinkedList<GeckoSurfaceTexture>();
+ sUnusedTextures.put(mAttachedContext, list);
+ }
+ list.addFirst(this);
+ }
+ }
+ }
+
+ @WrapForJNI
+ public static void destroyUnused(final long context) {
+ final LinkedList<GeckoSurfaceTexture> list;
+ synchronized (sUnusedTextures) {
+ list = sUnusedTextures.get(context);
+ sUnusedTextures.delete(context);
+ }
+
+ if (list == null) {
+ return;
+ }
+
+ for (final GeckoSurfaceTexture tex : list) {
+ try {
+ if (tex.isSingleBuffer()) {
+ tex.releaseTexImage();
+ }
+
+ tex.detachFromGLContext();
+ tex.release();
+
+ // We need to manually call finalize here, otherwise we can run out
+ // of file descriptors if the GC doesn't kick in soon enough. Bug 1416015.
+ try {
+ tex.finalize();
+ } catch (final Throwable t) {
+ Log.e(LOGTAG, "Failed to finalize SurfaceTexture", t);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to destroy SurfaceTexture", e);
+ }
+ }
+ }
+
+ public static GeckoSurfaceTexture acquire(final boolean singleBufferMode, final long handle) {
+ if (singleBufferMode && !isSingleBufferSupported()) {
+ throw new IllegalArgumentException("single buffer mode not supported on API version < 19");
+ }
+
+ synchronized (sSurfaceTextures) {
+ // We want to limit the maximum number of SurfaceTextures at any one time.
+ // This is because they use a large number of fds, and once the process' limit
+ // is reached bad things happen. See bug 1421586.
+ if (sSurfaceTextures.size() >= MAX_SURFACE_TEXTURES) {
+ return null;
+ }
+
+ if (sSurfaceTextures.indexOfKey(handle) >= 0) {
+ throw new IllegalArgumentException("Already have a GeckoSurfaceTexture with that handle");
+ }
+
+ final GeckoSurfaceTexture gst;
+ if (isSingleBufferSupported()) {
+ gst = new GeckoSurfaceTexture(handle, singleBufferMode);
+ } else {
+ gst = new GeckoSurfaceTexture(handle);
+ }
+
+ sSurfaceTextures.put(handle, gst);
+
+ return gst;
+ }
+ }
+
+ @WrapForJNI
+ public static GeckoSurfaceTexture lookup(final long handle) {
+ synchronized (sSurfaceTextures) {
+ return sSurfaceTextures.get(handle);
+ }
+ }
+
+ /* package */ synchronized void track(final long upstream) {
+ mUpstream = upstream;
+ }
+
+ /* package */ synchronized void configureSnapshot(
+ final GeckoSurface target, final int width, final int height) {
+ mBlitter = NativeGLBlitHelper.create(mHandle, target, width, height);
+ }
+
+ /* package */ synchronized void takeSnapshot() {
+ mBlitter.blit();
+ }
+
+ public interface Callbacks {
+ void onUpdateTexImage();
+
+ void onReleaseTexImage();
+ }
+
+ @WrapForJNI
+ public static final class NativeGLBlitHelper extends JNIObject {
+ public static NativeGLBlitHelper create(
+ final long textureHandle,
+ final GeckoSurface targetSurface,
+ final int width,
+ final int height) {
+ final NativeGLBlitHelper helper = nativeCreate(textureHandle, targetSurface, width, height);
+ helper.mTargetSurface = targetSurface; // Take ownership of surface.
+ return helper;
+ }
+
+ public static native NativeGLBlitHelper nativeCreate(
+ final long textureHandle,
+ final GeckoSurface targetSurface,
+ final int width,
+ final int height);
+
+ public native void blit();
+
+ public void close() {
+ disposeNative();
+ if (mTargetSurface != null) {
+ mTargetSurface.release();
+ mTargetSurface = null;
+ }
+ }
+
+ @Override
+ protected native void disposeNative();
+
+ private GeckoSurface mTargetSurface;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java
new file mode 100644
index 0000000000..b8ceb74f0b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java
@@ -0,0 +1,71 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.SystemClock;
+import android.util.Log;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+public final class PanningPerfAPI {
+ private static final String LOGTAG = "GeckoPanningPerfAPI";
+
+ // make this large enough to avoid having to resize the frame time
+ // list, as that may be expensive and impact the thing we're trying
+ // to measure.
+ private static final int EXPECTED_FRAME_COUNT = 2048;
+
+ private static boolean mRecordingFrames;
+ private static List<Long> mFrameTimes;
+ private static long mFrameStartTime;
+
+ private static void initialiseRecordingArrays() {
+ if (mFrameTimes == null) {
+ mFrameTimes = new ArrayList<Long>(EXPECTED_FRAME_COUNT);
+ } else {
+ mFrameTimes.clear();
+ }
+ }
+
+ @RobocopTarget
+ public static void startFrameTimeRecording() {
+ if (mRecordingFrames) {
+ Log.e(LOGTAG, "Error: startFrameTimeRecording() called while already recording!");
+ return;
+ }
+ mRecordingFrames = true;
+ initialiseRecordingArrays();
+ mFrameStartTime = SystemClock.uptimeMillis();
+ }
+
+ @RobocopTarget
+ public static List<Long> stopFrameTimeRecording() {
+ if (!mRecordingFrames) {
+ Log.e(LOGTAG, "Error: stopFrameTimeRecording() called when not recording!");
+ return null;
+ }
+ mRecordingFrames = false;
+ return mFrameTimes;
+ }
+
+ public static void recordFrameTime() {
+ // this will be called often, so try to make it as quick as possible
+ if (mRecordingFrames) {
+ mFrameTimes.add(SystemClock.uptimeMillis() - mFrameStartTime);
+ }
+ }
+
+ @RobocopTarget
+ public static void startCheckerboardRecording() {
+ throw new UnsupportedOperationException();
+ }
+
+ @RobocopTarget
+ public static List<Float> stopCheckerboardRecording() {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java
new file mode 100644
index 0000000000..3244519da1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java
@@ -0,0 +1,77 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class RemoteSurfaceAllocator extends ISurfaceAllocator.Stub {
+ private static final String LOGTAG = "RemoteSurfaceAllocator";
+
+ private static RemoteSurfaceAllocator mInstance;
+
+ private final int mAllocatorId;
+ /// Monotonically increasing counter used to generate unique handles
+ /// for each SurfaceTexture by combining with mAllocatorId.
+ private static AtomicInteger sNextHandle = new AtomicInteger(1);
+
+ /**
+ * Retrieves the singleton allocator instance for this process.
+ *
+ * @param allocatorId A unique ID identifying the process this instance belongs to, which must be
+ * 0 for the parent process instance.
+ */
+ public static synchronized RemoteSurfaceAllocator getInstance(final int allocatorId) {
+ if (mInstance == null) {
+ mInstance = new RemoteSurfaceAllocator(allocatorId);
+ }
+ return mInstance;
+ }
+
+ private RemoteSurfaceAllocator(final int allocatorId) {
+ mAllocatorId = allocatorId;
+ }
+
+ @Override
+ public GeckoSurface acquireSurface(
+ final int width, final int height, final boolean singleBufferMode) {
+ final long handle = ((long) mAllocatorId << 32) | sNextHandle.getAndIncrement();
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.acquire(singleBufferMode, handle);
+
+ if (gst == null) {
+ return null;
+ }
+
+ if (width > 0 && height > 0) {
+ gst.setDefaultBufferSize(width, height);
+ }
+
+ return new GeckoSurface(gst);
+ }
+
+ @Override
+ public void releaseSurface(final long handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.decrementUse();
+ }
+ }
+
+ @Override
+ public void configureSync(final SyncConfig config) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(config.sourceTextureHandle);
+ if (gst != null) {
+ gst.configureSnapshot(config.targetSurface, config.width, config.height);
+ }
+ }
+
+ @Override
+ public void sync(final long handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.takeSnapshot();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java
new file mode 100644
index 0000000000..f602959040
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java
@@ -0,0 +1,140 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.LongSparseArray;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.process.GeckoProcessManager;
+import org.mozilla.gecko.process.GeckoServiceChildProcess;
+
+/* package */ final class SurfaceAllocator {
+ private static final String LOGTAG = "SurfaceAllocator";
+
+ private static ISurfaceAllocator sAllocator;
+
+ // Keep a reference to all allocated Surfaces, so that we can release them if we lose the
+ // connection to the allocator service.
+ private static final LongSparseArray<GeckoSurface> sSurfaces =
+ new LongSparseArray<GeckoSurface>();
+
+ private static synchronized void ensureConnection() {
+ if (sAllocator != null) {
+ return;
+ }
+
+ try {
+ if (GeckoAppShell.isParentProcess()) {
+ sAllocator = GeckoProcessManager.getInstance().getSurfaceAllocator();
+ } else {
+ sAllocator = GeckoServiceChildProcess.getSurfaceAllocator();
+ }
+
+ if (sAllocator == null) {
+ Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator");
+ return;
+ }
+ sAllocator
+ .asBinder()
+ .linkToDeath(
+ new IBinder.DeathRecipient() {
+ @Override
+ public void binderDied() {
+ Log.w(LOGTAG, "RemoteSurfaceAllocator died");
+ synchronized (SurfaceAllocator.class) {
+ // Our connection to the remote allocator has died, so all our surfaces are
+ // invalid. Release them all now. When their owners attempt to render in to
+ // them they can detect they have been released and allocate new ones instead.
+ for (int i = 0; i < sSurfaces.size(); i++) {
+ sSurfaces.valueAt(i).release();
+ }
+ sSurfaces.clear();
+ sAllocator = null;
+ }
+ }
+ },
+ 0);
+ } catch (final RemoteException e) {
+ Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator", e);
+ sAllocator = null;
+ }
+ }
+
+ @WrapForJNI
+ public static synchronized GeckoSurface acquireSurface(
+ final int width, final int height, final boolean singleBufferMode) {
+ try {
+ ensureConnection();
+
+ if (sAllocator == null) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface: not connected");
+ return null;
+ }
+
+ if (singleBufferMode && !GeckoSurfaceTexture.isSingleBufferSupported()) {
+ return null;
+ }
+
+ final GeckoSurface surface = sAllocator.acquireSurface(width, height, singleBufferMode);
+ if (surface == null) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface: RemoteSurfaceAllocator returned null");
+ return null;
+ }
+ sSurfaces.put(surface.getHandle(), surface);
+
+ if (!surface.inProcess()) {
+ sAllocator.configureSync(surface.initSyncSurface(width, height));
+ }
+ return surface;
+ } catch (final RemoteException e) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI
+ public static synchronized void disposeSurface(final GeckoSurface surface) {
+ // If the surface has already been released (probably due to losing connection to the remote
+ // allocator) then there is nothing to do here.
+ if (surface.isReleased()) {
+ return;
+ }
+
+ sSurfaces.remove(surface.getHandle());
+
+ // Release our Surface
+ surface.release();
+
+ if (sAllocator == null) {
+ return;
+ }
+
+ // Release the SurfaceTexture on the other side. If we have lost connection then do nothing, as
+ // there is nothing on the other side to release.
+ try {
+ if (sAllocator != null) {
+ sAllocator.releaseSurface(surface.getHandle());
+ }
+ } catch (final RemoteException e) {
+ Log.w(LOGTAG, "Failed to release surface texture", e);
+ }
+ }
+
+ public static synchronized void sync(final long upstream) {
+ // Sync from the SurfaceTexture on the other side. If we have lost connection then do nothing,
+ // as there is nothing on the other side to sync from.
+ try {
+ if (sAllocator != null) {
+ sAllocator.sync(upstream);
+ }
+ } catch (final RemoteException e) {
+ Log.w(LOGTAG, "Failed to sync texture", e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java
new file mode 100644
index 0000000000..29da401da0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java
@@ -0,0 +1,93 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.Build;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import androidx.annotation.RequiresApi;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.WeakHashMap;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// A helper class that creates Surfaces from SurfaceControl objects, for the widget to render in to.
+// Unlike the Surfaces provided to the widget directly from the application, these are suitable for
+// use in the GPU process as well as the main process.
+//
+// The reason we must not render directly in to the Surface provided by the application from the GPU
+// process is because of a bug on Android versions 12 and later: when the GPU process dies the
+// Surface is not detached from the dead process' EGL surface, and any subsequent attempts to
+// attach another EGL surface to the Surface will fail.
+//
+// The application is therefore required to provide the SurfaceControl object to a GeckoDisplay
+// whenever rendering in to a SurfaceView. The widget will then obtain a Surface from that
+// SurfaceControl using getChildSurface(). Internally, this creates another SurfaceControl as a
+// child of the provided SurfaceControl, then creates the Surface from that child. If the GPU
+// process dies we are able to simply destroy and recreate the child SurfaceControl objects, thereby
+// avoiding the bug.
+public class SurfaceControlManager {
+ private static final String LOGTAG = "SurfaceControlManager";
+
+ private static final SurfaceControlManager sInstance = new SurfaceControlManager();
+
+ private WeakHashMap<SurfaceControl, SurfaceControl> mChildSurfaceControls = new WeakHashMap<>();
+
+ @WrapForJNI
+ public static SurfaceControlManager getInstance() {
+ return sInstance;
+ }
+
+ // Returns a Surface of the requested size that will be composited in to the specified
+ // SurfaceControl.
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ @WrapForJNI(exceptionMode = "abort")
+ public synchronized Surface getChildSurface(
+ final SurfaceControl parent, final int width, final int height) {
+ SurfaceControl child = mChildSurfaceControls.get(parent);
+ if (child == null) {
+ // We must periodically check if any of the SurfaceControls we are managing have been
+ // destroyed, as we are unable to directly listen to their SurfaceViews' surfaceDestroyed
+ // callbacks, and they may not be attached to any compositor when they are destroyed meaning
+ // we cannot perform cleanup in response to the compositor being paused.
+ // Doing so here, when we encounter a new SurfaceControl instance, is a reasonable guess as to
+ // when a previous instance may have been released.
+ final Iterator<Map.Entry<SurfaceControl, SurfaceControl>> it =
+ mChildSurfaceControls.entrySet().iterator();
+ while (it.hasNext()) {
+ final Map.Entry<SurfaceControl, SurfaceControl> entry = it.next();
+ if (!entry.getKey().isValid()) {
+ it.remove();
+ }
+ }
+
+ child = new SurfaceControl.Builder().setParent(parent).setName("GeckoSurface").build();
+ mChildSurfaceControls.put(parent, child);
+ }
+
+ new SurfaceControl.Transaction()
+ .setVisibility(child, true)
+ .setBufferSize(child, width, height)
+ .apply();
+
+ return new Surface(child);
+ }
+
+ // Must be called whenever the GPU process has died. This destroys all the child SurfaceControls
+ // that have been created, meaning subsequent calls to getChildSurface() will create new ones.
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ @WrapForJNI(exceptionMode = "abort")
+ public synchronized void onGpuProcessLoss() {
+ for (final SurfaceControl child : mChildSurfaceControls.values()) {
+ // We could reparent the child SurfaceControl to null here to immediately remove it from the
+ // tree. However, this will result in a black screen while we wait for the new compositor to
+ // be created. It's preferable for the user to see the old content instead, so simply call
+ // release().
+ child.release();
+ }
+ mChildSurfaceControls.clear();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java
new file mode 100644
index 0000000000..0ba79d1f42
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.graphics.SurfaceTexture;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/* package */ final class SurfaceTextureListener extends JNIObject
+ implements SurfaceTexture.OnFrameAvailableListener {
+ @WrapForJNI(calledFrom = "gecko")
+ private SurfaceTextureListener() {}
+
+ @WrapForJNI(dispatchTo = "gecko")
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ @Override
+ protected void finalize() {
+ disposeNative();
+ }
+
+ @WrapForJNI(stubName = "OnFrameAvailable")
+ private native void nativeOnFrameAvailable();
+
+ @Override // SurfaceTexture.OnFrameAvailableListener
+ public void onFrameAvailable(final SurfaceTexture surfaceTexture) {
+ try {
+ nativeOnFrameAvailable();
+ } catch (final NullPointerException e) {
+ // Ignore exceptions caused by a disposed object, i.e.
+ // getting a callback after this listener is no longer in use.
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java
new file mode 100644
index 0000000000..d8e2099ddc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java
@@ -0,0 +1,59 @@
+/* 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/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/* package */ final class SyncConfig implements Parcelable {
+ final long sourceTextureHandle;
+ final GeckoSurface targetSurface;
+ final int width;
+ final int height;
+
+ /* package */ SyncConfig(
+ final long sourceTextureHandle,
+ final GeckoSurface targetSurface,
+ final int width,
+ final int height) {
+ this.sourceTextureHandle = sourceTextureHandle;
+ this.targetSurface = targetSurface;
+ this.width = width;
+ this.height = height;
+ }
+
+ public static final Creator<SyncConfig> CREATOR =
+ new Creator<SyncConfig>() {
+ @Override
+ public SyncConfig createFromParcel(final Parcel parcel) {
+ return new SyncConfig(parcel);
+ }
+
+ @Override
+ public SyncConfig[] newArray(final int i) {
+ return new SyncConfig[i];
+ }
+ };
+
+ private SyncConfig(final Parcel parcel) {
+ sourceTextureHandle = parcel.readLong();
+ targetSurface = GeckoSurface.CREATOR.createFromParcel(parcel);
+ width = parcel.readInt();
+ height = parcel.readInt();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ parcel.writeLong(sourceTextureHandle);
+ targetSurface.writeToParcel(parcel, flags);
+ parcel.writeInt(width);
+ parcel.writeInt(height);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java
new file mode 100644
index 0000000000..bd37a6ae74
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java
@@ -0,0 +1,62 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Handler;
+import android.view.Surface;
+import java.nio.ByteBuffer;
+
+// A wrapper interface that mimics the new {@link android.media.MediaCodec}
+// asynchronous mode API in Lollipop.
+public interface AsyncCodec {
+ public interface Callbacks {
+ void onInputBufferAvailable(AsyncCodec codec, int index);
+
+ void onOutputBufferAvailable(AsyncCodec codec, int index, BufferInfo info);
+
+ void onError(AsyncCodec codec, int error);
+
+ void onOutputFormatChanged(AsyncCodec codec, MediaFormat format);
+ }
+
+ public abstract void setCallbacks(Callbacks callbacks, Handler handler);
+
+ public abstract void configure(
+ MediaFormat format, Surface surface, MediaCrypto crypto, int flags);
+
+ public abstract boolean isAdaptivePlaybackSupported(String mimeType);
+
+ public abstract boolean isTunneledPlaybackSupported(final String mimeType);
+
+ public abstract void start();
+
+ public abstract void stop();
+
+ public abstract void flush();
+ // Must be called after flush().
+ public abstract void resumeReceivingInputs();
+
+ public abstract void release();
+
+ public abstract ByteBuffer getInputBuffer(int index);
+
+ public abstract MediaFormat getInputFormat();
+
+ public abstract ByteBuffer getOutputBuffer(int index);
+
+ public abstract void queueInputBuffer(
+ int index, int offset, int size, long presentationTimeUs, int flags);
+
+ public abstract void setBitrate(int bps);
+
+ public abstract void queueSecureInputBuffer(
+ int index, int offset, CryptoInfo info, long presentationTimeUs, int flags);
+
+ public abstract void releaseOutputBuffer(int index, boolean render);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java
new file mode 100644
index 0000000000..3295919b91
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java
@@ -0,0 +1,19 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.os.Build;
+import java.io.IOException;
+
+public final class AsyncCodecFactory {
+ public static AsyncCodec create(final String name) throws IOException {
+ // A bug that getInputBuffer() could fail after flush() then start() wasn't fixed until MR1.
+ // See:
+ // https://android.googlesource.com/platform/frameworks/av/+/d9e0603a1be07dbb347c55050c7d4629ea7492e8
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1
+ ? new LollipopAsyncCodec(name)
+ : new JellyBeanAsyncCodec(name);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java
new file mode 100644
index 0000000000..d9556d545d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java
@@ -0,0 +1,104 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+public interface BaseHlsPlayer {
+
+ public enum TrackType {
+ UNDEFINED,
+ AUDIO,
+ VIDEO,
+ TEXT,
+ }
+
+ public enum ResourceError {
+ BASE(-100),
+ UNKNOWN(-101),
+ PLAYER(-102),
+ UNSUPPORTED(-103);
+
+ private int mNumVal;
+
+ private ResourceError(final int numVal) {
+ mNumVal = numVal;
+ }
+
+ public int code() {
+ return mNumVal;
+ }
+ }
+
+ public enum DemuxerError {
+ BASE(-200),
+ UNKNOWN(-201),
+ PLAYER(-202),
+ UNSUPPORTED(-203);
+
+ private int mNumVal;
+
+ private DemuxerError(final int numVal) {
+ mNumVal = numVal;
+ }
+
+ public int code() {
+ return mNumVal;
+ }
+ }
+
+ public interface DemuxerCallbacks {
+ void onInitialized(boolean hasAudio, boolean hasVideo);
+
+ void onError(int errorCode);
+ }
+
+ public interface ResourceCallbacks {
+ void onLoad(String mediaUrl);
+
+ void onDataArrived();
+
+ void onError(int errorCode);
+ }
+
+ // Used to identify player instance.
+ public int getId();
+
+ // =======================================================================
+ // API for GeckoHLSResourceWrapper
+ // =======================================================================
+ public void init(String url, ResourceCallbacks callback);
+
+ public boolean isLiveStream();
+
+ // =======================================================================
+ // API for GeckoHLSDemuxerWrapper
+ // =======================================================================
+ public void addDemuxerWrapperCallbackListener(DemuxerCallbacks callback);
+
+ public ConcurrentLinkedQueue<GeckoHLSSample> getSamples(TrackType trackType, int number);
+
+ public long getBufferedPosition();
+
+ public int getNumberOfTracks(TrackType trackType);
+
+ public GeckoVideoInfo getVideoInfo(int index);
+
+ public GeckoAudioInfo getAudioInfo(int index);
+
+ public boolean seek(long positionUs);
+
+ public long getNextKeyFrameTime();
+
+ public void suspend();
+
+ public void resume();
+
+ public void play();
+
+ public void pause();
+
+ public void release();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
new file mode 100644
index 0000000000..2d39e08406
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
@@ -0,0 +1,710 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.VideoCapabilities;
+import android.media.MediaCodecList;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import android.view.Surface;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+import org.mozilla.gecko.gfx.GeckoSurface;
+
+/* package */ final class Codec extends ICodec.Stub implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteCodec";
+ private static final boolean DEBUG = false;
+ public static final String SW_CODEC_PREFIX = "OMX.google.";
+
+ public enum Error {
+ DECODE,
+ FATAL
+ }
+
+ private final class Callbacks implements AsyncCodec.Callbacks {
+ @Override
+ public void onInputBufferAvailable(final AsyncCodec codec, final int index) {
+ mInputProcessor.onBuffer(index);
+ }
+
+ @Override
+ public void onOutputBufferAvailable(
+ final AsyncCodec codec, final int index, final MediaCodec.BufferInfo info) {
+ mOutputProcessor.onBuffer(index, info);
+ }
+
+ @Override
+ public void onError(final AsyncCodec codec, final int error) {
+ reportError(Error.FATAL, new Exception("codec error:" + error));
+ }
+
+ @Override
+ public void onOutputFormatChanged(final AsyncCodec codec, final MediaFormat format) {
+ mOutputProcessor.onFormatChanged(format);
+ }
+ }
+
+ private static final class Input {
+ public final Sample sample;
+ public boolean reported;
+
+ public Input(final Sample sample) {
+ this.sample = sample;
+ }
+ }
+
+ private final class InputProcessor {
+ private boolean mHasInputCapacitySet;
+ private Queue<Integer> mAvailableInputBuffers = new LinkedList<>();
+ private Queue<Sample> mDequeuedSamples = new LinkedList<>();
+ private Queue<Input> mInputSamples = new LinkedList<>();
+ private boolean mStopped;
+
+ private synchronized Sample onAllocate(final int size) {
+ final Sample sample = mSamplePool.obtainInput(size);
+ sample.session = mSession;
+ mDequeuedSamples.add(sample);
+ return sample;
+ }
+
+ private synchronized void onSample(final Sample sample) {
+ if (sample == null) {
+ // Ignore empty input.
+ mSamplePool.recycleInput(mDequeuedSamples.remove());
+ Log.w(LOGTAG, "WARN: empty input sample");
+ return;
+ }
+
+ if (sample.isEOS()) {
+ queueSample(sample);
+ return;
+ }
+
+ if (sample.session >= mSession) {
+ final Sample dequeued = mDequeuedSamples.remove();
+ dequeued.setBufferInfo(sample.info);
+ dequeued.setCryptoInfo(sample.cryptoInfo);
+ queueSample(dequeued);
+ }
+
+ sample.dispose();
+ }
+
+ private void queueSample(final Sample sample) {
+ if (!mInputSamples.offer(new Input(sample))) {
+ reportError(Error.FATAL, new Exception("FAIL: input sample queue is full"));
+ return;
+ }
+
+ try {
+ feedSampleToBuffer();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private synchronized void onBuffer(final int index) {
+ if (mStopped || !isValidBuffer(index)) {
+ return;
+ }
+
+ if (!mHasInputCapacitySet) {
+ final int capacity = mCodec.getInputBuffer(index).capacity();
+ if (capacity > 0) {
+ mSamplePool.setInputBufferSize(capacity);
+ mHasInputCapacitySet = true;
+ }
+ }
+
+ if (mAvailableInputBuffers.offer(index)) {
+ feedSampleToBuffer();
+ } else {
+ reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full"));
+ }
+ }
+
+ private boolean isValidBuffer(final int index) {
+ try {
+ return mCodec.getInputBuffer(index) != null;
+ } catch (final IllegalStateException e) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "invalid input buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private void feedSampleToBuffer() {
+ while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) {
+ final int index = mAvailableInputBuffers.poll();
+ if (!isValidBuffer(index)) {
+ continue;
+ }
+ int len = 0;
+ final Sample sample = mInputSamples.poll().sample;
+ final long pts = sample.info.presentationTimeUs;
+ final int flags = sample.info.flags;
+ final MediaCodec.CryptoInfo cryptoInfo = sample.cryptoInfo;
+ if (!sample.isEOS() && sample.bufferId != Sample.NO_BUFFER) {
+ len = sample.info.size;
+ final ByteBuffer buf = mCodec.getInputBuffer(index);
+ try {
+ mSamplePool
+ .getInputBuffer(sample.bufferId)
+ .writeToByteBuffer(buf, sample.info.offset, len);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ len = 0;
+ }
+ mSamplePool.recycleInput(sample);
+ }
+
+ try {
+ if (cryptoInfo != null && len > 0) {
+ mCodec.queueSecureInputBuffer(index, 0, cryptoInfo, pts, flags);
+ } else {
+ mCodec.queueInputBuffer(index, 0, len, pts, flags);
+ }
+ mCallbacks.onInputQueued(pts);
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ return;
+ }
+ }
+ reportPendingInputs();
+ }
+
+ private void reportPendingInputs() {
+ try {
+ for (final Input i : mInputSamples) {
+ if (!i.reported) {
+ i.reported = true;
+ mCallbacks.onInputPending(i.sample.info.presentationTimeUs);
+ }
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private synchronized void reset() {
+ for (final Input i : mInputSamples) {
+ if (!i.sample.isEOS()) {
+ mSamplePool.recycleInput(i.sample);
+ }
+ }
+ mInputSamples.clear();
+
+ for (final Sample s : mDequeuedSamples) {
+ mSamplePool.recycleInput(s);
+ }
+ mDequeuedSamples.clear();
+
+ mAvailableInputBuffers.clear();
+ }
+
+ private synchronized void start() {
+ if (!mStopped) {
+ return;
+ }
+ mStopped = false;
+ }
+
+ private synchronized void stop() {
+ if (mStopped) {
+ return;
+ }
+ mStopped = true;
+ reset();
+ }
+ }
+
+ private static final class Output {
+ public final Sample sample;
+ public final int index;
+
+ public Output(final Sample sample, final int index) {
+ this.sample = sample;
+ this.index = index;
+ }
+ }
+
+ private class OutputProcessor {
+ private final boolean mRenderToSurface;
+ private boolean mHasOutputCapacitySet;
+ private Queue<Output> mSentOutputs = new LinkedList<>();
+ private boolean mStopped;
+
+ private OutputProcessor(final boolean renderToSurface) {
+ mRenderToSurface = renderToSurface;
+ }
+
+ private synchronized void onBuffer(final int index, final MediaCodec.BufferInfo info) {
+ if (mStopped || !isValidBuffer(index)) {
+ return;
+ }
+
+ try {
+ final Sample output = obtainOutputSample(index, info);
+ mSentOutputs.add(new Output(output, index));
+ output.session = mSession;
+ mCallbacks.onOutput(output);
+ } catch (final Exception e) {
+ e.printStackTrace();
+ mCodec.releaseOutputBuffer(index, false);
+ }
+
+ final boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ if (DEBUG && eos) {
+ Log.d(LOGTAG, "output EOS");
+ }
+ }
+
+ private boolean isValidBuffer(final int index) {
+ try {
+ return (mCodec.getOutputBuffer(index) != null) || mRenderToSurface;
+ } catch (final IllegalStateException e) {
+ if (DEBUG) {
+ Log.e(LOGTAG, "invalid buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) {
+ final Sample sample = mSamplePool.obtainOutput(info);
+
+ if (mRenderToSurface) {
+ return sample;
+ }
+
+ final ByteBuffer output = mCodec.getOutputBuffer(index);
+ if (!mHasOutputCapacitySet) {
+ final int capacity = output.capacity();
+ if (capacity > 0) {
+ mSamplePool.setOutputBufferSize(capacity);
+ mHasOutputCapacitySet = true;
+ }
+ }
+
+ if (info.size > 0) {
+ try {
+ mSamplePool
+ .getOutputBuffer(sample.bufferId)
+ .readFromByteBuffer(output, info.offset, info.size);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage());
+ }
+ }
+
+ return sample;
+ }
+
+ private synchronized void onRelease(final Sample sample, final boolean render) {
+ final Output output = mSentOutputs.poll();
+ if (output != null) {
+ mCodec.releaseOutputBuffer(output.index, render);
+ mSamplePool.recycleOutput(output.sample);
+ } else if (DEBUG) {
+ Log.d(LOGTAG, sample + " already released");
+ }
+
+ sample.dispose();
+ }
+
+ private synchronized void onFormatChanged(final MediaFormat format) {
+ if (mStopped) {
+ return;
+ }
+ try {
+ mCallbacks.onOutputFormatChanged(new FormatParam(format));
+ } catch (final RemoteException re) {
+ // Dead recipient.
+ re.printStackTrace();
+ }
+ }
+
+ private synchronized void reset() {
+ for (final Output o : mSentOutputs) {
+ mCodec.releaseOutputBuffer(o.index, false);
+ mSamplePool.recycleOutput(o.sample);
+ }
+ mSentOutputs.clear();
+ }
+
+ private synchronized void start() {
+ if (!mStopped) {
+ return;
+ }
+ mStopped = false;
+ }
+
+ private synchronized void stop() {
+ if (mStopped) {
+ return;
+ }
+ mStopped = true;
+ reset();
+ }
+ }
+
+ private volatile ICodecCallbacks mCallbacks;
+ private GeckoSurface mSurface;
+ private AsyncCodec mCodec;
+ private InputProcessor mInputProcessor;
+ private OutputProcessor mOutputProcessor;
+ private long mSession;
+ private SamplePool mSamplePool;
+ // Values will be updated after configure called.
+ private volatile boolean mIsAdaptivePlaybackSupported = false;
+ private volatile boolean mIsHardwareAccelerated = false;
+ private boolean mIsTunneledPlaybackSupported = false;
+
+ public synchronized void setCallbacks(final ICodecCallbacks callbacks) throws RemoteException {
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Callbacks is dead");
+ try {
+ release();
+ } catch (final RemoteException e) {
+ // Nowhere to report the error.
+ }
+ }
+
+ @Override
+ public synchronized boolean configure(
+ final FormatParam format, final GeckoSurface surface, final int flags, final String drmStubId)
+ throws RemoteException {
+ if (mCallbacks == null) {
+ Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()");
+ return false;
+ }
+
+ if (mCodec != null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "release existing codec: " + mCodec);
+ }
+ mCodec.release();
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "configure " + this);
+ }
+
+ final MediaFormat fmt = format.asFormat();
+ final String mime = fmt.getString(MediaFormat.KEY_MIME);
+ if (mime == null || mime.isEmpty()) {
+ Log.e(LOGTAG, "invalid MIME type: " + mime);
+ return false;
+ }
+
+ final List<String> found =
+ findMatchingCodecNames(fmt, flags == MediaCodec.CONFIGURE_FLAG_ENCODE);
+ for (final String name : found) {
+ final AsyncCodec codec = configureCodec(name, fmt, surface, flags, drmStubId);
+ if (codec == null) {
+ Log.w(LOGTAG, "unable to configure " + name + ". Try next.");
+ continue;
+ }
+ mIsHardwareAccelerated = !name.startsWith(SW_CODEC_PREFIX);
+ mCodec = codec;
+ // Bug 1789846: Check if the Codec provides stride or height values to use.
+ if (flags == MediaCodec.CONFIGURE_FLAG_ENCODE && fmt.containsKey(MediaFormat.KEY_WIDTH)) {
+ final MediaFormat inputFormat = mCodec.getInputFormat();
+ if (inputFormat != null) {
+ if (inputFormat.containsKey(MediaFormat.KEY_STRIDE)) {
+ fmt.setInteger(MediaFormat.KEY_STRIDE, inputFormat.getInteger(MediaFormat.KEY_STRIDE));
+ }
+ if (inputFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ fmt.setInteger(
+ MediaFormat.KEY_SLICE_HEIGHT, inputFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT));
+ }
+ }
+ }
+ mInputProcessor = new InputProcessor();
+ final boolean renderToSurface = surface != null;
+ mOutputProcessor = new OutputProcessor(renderToSurface);
+ mSamplePool = new SamplePool(name, renderToSurface);
+ if (renderToSurface) {
+ mIsTunneledPlaybackSupported = mCodec.isTunneledPlaybackSupported(mime);
+ mSurface = surface; // Take ownership of surface.
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, codec.toString() + " created. Render to surface?" + renderToSurface);
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ private List<String> findMatchingCodecNames(final MediaFormat format, final boolean isEncoder) {
+ final String mimeType = format.getString(MediaFormat.KEY_MIME);
+ // Missing width and height value in format means audio;
+ // Video format should never has 0 width or height.
+ final int width =
+ format.containsKey(MediaFormat.KEY_WIDTH) ? format.getInteger(MediaFormat.KEY_WIDTH) : 0;
+ final int height =
+ format.containsKey(MediaFormat.KEY_HEIGHT) ? format.getInteger(MediaFormat.KEY_HEIGHT) : 0;
+
+ final int numCodecs = MediaCodecList.getCodecCount();
+ final List<String> found = new ArrayList<>();
+ for (int i = 0; i < numCodecs; i++) {
+ final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder() == !isEncoder) {
+ continue;
+ }
+
+ final String[] types = info.getSupportedTypes();
+ for (final String t : types) {
+ if (!t.equalsIgnoreCase(mimeType)) {
+ continue;
+ }
+ final String name = info.getName();
+ // API 21+ provide a method to query whether supplied size is supported. For
+ // older version, just avoid software video encoders.
+ if (isEncoder && width > 0 && height > 0) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ final VideoCapabilities c =
+ info.getCapabilitiesForType(mimeType).getVideoCapabilities();
+ if (c != null && !c.isSizeSupported(width, height)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, name + ": " + width + "x" + height + " not supported");
+ }
+ continue;
+ }
+ } else if (name.startsWith(SW_CODEC_PREFIX)) {
+ continue;
+ }
+ }
+
+ found.add(name);
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "found " + (isEncoder ? "encoder:" : "decoder:") + name + " for mime:" + mimeType);
+ }
+ }
+ }
+ return found;
+ }
+
+ private AsyncCodec configureCodec(
+ final String name,
+ final MediaFormat format,
+ final Surface surface,
+ final int flags,
+ final String drmStubId) {
+ try {
+ final AsyncCodec codec = AsyncCodecFactory.create(name);
+ codec.setCallbacks(new Callbacks(), null);
+
+ final MediaCrypto crypto = RemoteMediaDrmBridgeStub.getMediaCrypto(drmStubId);
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "configure mediacodec with crypto(" + (crypto != null) + ") / Id :" + drmStubId);
+ }
+
+ if (surface != null) {
+ setupAdaptivePlayback(codec, format);
+ }
+
+ codec.configure(format, surface, crypto, flags);
+ return codec;
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "codec creation error", e);
+ return null;
+ }
+ }
+
+ private void setupAdaptivePlayback(final AsyncCodec codec, final MediaFormat format) {
+ // Video decoder should config with adaptive playback capability.
+ mIsAdaptivePlaybackSupported =
+ codec.isAdaptivePlaybackSupported(format.getString(MediaFormat.KEY_MIME));
+ if (mIsAdaptivePlaybackSupported) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "codec supports adaptive playback = " + mIsAdaptivePlaybackSupported);
+ }
+ // TODO: may need to find a way to not use hard code to decide the max w/h.
+ format.setInteger(MediaFormat.KEY_MAX_WIDTH, 1920);
+ format.setInteger(MediaFormat.KEY_MAX_HEIGHT, 1080);
+ }
+ }
+
+ @Override
+ public synchronized boolean isAdaptivePlaybackSupported() {
+ return mIsAdaptivePlaybackSupported;
+ }
+
+ @Override
+ public synchronized boolean isHardwareAccelerated() {
+ return mIsHardwareAccelerated;
+ }
+
+ @Override
+ public synchronized boolean isTunneledPlaybackSupported() {
+ return mIsTunneledPlaybackSupported;
+ }
+
+ @Override
+ public synchronized void start() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "start " + this);
+ }
+ mInputProcessor.start();
+ mOutputProcessor.start();
+ try {
+ mCodec.start();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private void reportError(final Error error, final Exception e) {
+ if (e != null) {
+ e.printStackTrace();
+ }
+ try {
+ mCallbacks.onError(error == Error.FATAL);
+ } catch (final NullPointerException ne) {
+ // mCallbacks has been disposed by release().
+ } catch (final RemoteException re) {
+ re.printStackTrace();
+ }
+ }
+
+ @Override
+ public synchronized void stop() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop " + this);
+ }
+ try {
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.stop();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void flush() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "flush " + this);
+ }
+ try {
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.flush();
+ if (DEBUG) {
+ Log.d(LOGTAG, "flushed " + this);
+ }
+ mInputProcessor.start();
+ mOutputProcessor.start();
+ mCodec.resumeReceivingInputs();
+ mSession++;
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized Sample dequeueInput(final int size) throws RemoteException {
+ try {
+ return mInputProcessor.onAllocate(size);
+ } catch (final Exception e) {
+ // Translate allocation error to remote exception.
+ throw new RemoteException(e.getMessage());
+ }
+ }
+
+ @Override
+ public synchronized SampleBuffer getInputBuffer(final int id) {
+ if (mSamplePool == null) {
+ return null;
+ }
+ return mSamplePool.getInputBuffer(id);
+ }
+
+ @Override
+ public synchronized SampleBuffer getOutputBuffer(final int id) {
+ if (mSamplePool == null) {
+ return null;
+ }
+ return mSamplePool.getOutputBuffer(id);
+ }
+
+ @Override
+ public synchronized void queueInput(final Sample sample) throws RemoteException {
+ try {
+ mInputProcessor.onSample(sample);
+ } catch (final Exception e) {
+ throw new RemoteException(e.getMessage());
+ }
+ }
+
+ @Override
+ public synchronized void setBitrate(final int bps) {
+ try {
+ mCodec.setBitrate(bps);
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void releaseOutput(final Sample sample, final boolean render) {
+ try {
+ mOutputProcessor.onRelease(sample, render);
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void release() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "release " + this);
+ }
+ try {
+ // In case Codec.stop() is not called yet.
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.release();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ mCodec = null;
+ mSamplePool.reset();
+ mSamplePool = null;
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ if (mSurface != null) {
+ mSurface.release();
+ mSurface = null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java
new file mode 100644
index 0000000000..e31ea4b132
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java
@@ -0,0 +1,508 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.DeadObjectException;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.SparseArray;
+import androidx.annotation.RequiresApi;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.GeckoSurface;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+// Proxy class of ICodec binder.
+public final class CodecProxy {
+ private static final String LOGTAG = "GeckoRemoteCodecProxy";
+ private static final boolean DEBUG = false;
+ @WrapForJNI private static final long INVALID_SESSION = -1;
+
+ private ICodec mRemote;
+ private long mSession;
+ private boolean mIsEncoder;
+ private FormatParam mFormat;
+ private GeckoSurface mOutputSurface;
+ private CallbacksForwarder mCallbacks;
+ private String mRemoteDrmStubId;
+ private Queue<Sample> mSurfaceOutputs = new ConcurrentLinkedQueue<>();
+ private boolean mFlushed = true;
+
+ private SparseArray<SampleBuffer> mInputBuffers = new SparseArray<>();
+ private SparseArray<SampleBuffer> mOutputBuffers = new SparseArray<>();
+
+ public interface Callbacks {
+ void onInputStatus(long timestamp, boolean processed);
+
+ void onOutputFormatChanged(MediaFormat format);
+
+ void onOutput(Sample output, SampleBuffer buffer);
+
+ void onError(boolean fatal);
+ }
+
+ @WrapForJNI
+ public static class NativeCallbacks extends JNIObject implements Callbacks {
+ public native void onInputStatus(long timestamp, boolean processed);
+
+ public native void onOutputFormatChanged(MediaFormat format);
+
+ public native void onOutput(Sample output, SampleBuffer buffer);
+
+ public native void onError(boolean fatal);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private class CallbacksForwarder extends ICodecCallbacks.Stub {
+ private final Callbacks mCallbacks;
+ private boolean mCodecProxyReleased;
+
+ CallbacksForwarder(final Callbacks callbacks) {
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public synchronized void onInputQueued(final long timestamp) throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onInputStatus(timestamp, true /* processed */);
+ }
+ }
+
+ @Override
+ public synchronized void onInputPending(final long timestamp) throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onInputStatus(timestamp, false /* processed */);
+ }
+ }
+
+ @Override
+ public synchronized void onOutputFormatChanged(final FormatParam format)
+ throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onOutputFormatChanged(format.asFormat());
+ }
+ }
+
+ @Override
+ public synchronized void onOutput(final Sample sample) throws RemoteException {
+ if (mCodecProxyReleased) {
+ sample.dispose();
+ return;
+ }
+
+ final SampleBuffer buffer = CodecProxy.this.getOutputBuffer(sample.bufferId);
+ if (mOutputSurface != null) {
+ // Don't render to surface just yet. Callback will make that happen when it's time.
+ mSurfaceOutputs.offer(sample);
+ } else if (buffer == null) {
+ // Buffer with given ID has been flushed.
+ sample.dispose();
+ return;
+ }
+ mCallbacks.onOutput(sample, buffer);
+ }
+
+ @Override
+ public void onError(final boolean fatal) throws RemoteException {
+ reportError(fatal);
+ }
+
+ private synchronized void reportError(final boolean fatal) {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onError(fatal);
+ }
+ }
+
+ private synchronized void setCodecProxyReleased() {
+ mCodecProxyReleased = true;
+ }
+ }
+
+ @WrapForJNI
+ public int GetInputFormatStride() {
+ if (mFormat.asFormat().containsKey(MediaFormat.KEY_STRIDE)) {
+ return mFormat.asFormat().getInteger(MediaFormat.KEY_STRIDE);
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public int GetInputFormatYPlaneHeight() {
+ if (mFormat.asFormat().containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ return mFormat.asFormat().getInteger(MediaFormat.KEY_SLICE_HEIGHT);
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public static CodecProxy create(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ return RemoteManager.getInstance()
+ .createCodec(isEncoder, format, surface, callbacks, drmStubId);
+ }
+
+ public static CodecProxy createCodecProxy(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ return new CodecProxy(isEncoder, format, surface, callbacks, drmStubId);
+ }
+
+ private CodecProxy(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ mIsEncoder = isEncoder;
+ mFormat = new FormatParam(format);
+ mOutputSurface = surface;
+ mRemoteDrmStubId = drmStubId;
+ mCallbacks = new CallbacksForwarder(callbacks);
+ }
+
+ boolean init(final ICodec remote) {
+ try {
+ remote.setCallbacks(mCallbacks);
+ if (!remote.configure(
+ mFormat,
+ mOutputSurface,
+ mIsEncoder ? MediaCodec.CONFIGURE_FLAG_ENCODE : 0,
+ mRemoteDrmStubId)) {
+ return false;
+ }
+ remote.start();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ mRemote = remote;
+ return true;
+ }
+
+ boolean deinit() {
+ try {
+ mRemote.stop();
+ mRemote.release();
+ mRemote = null;
+ return true;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isAdaptivePlaybackSupported() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isAdaptivePlaybackSupported with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isAdaptivePlaybackSupported();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isHardwareAccelerated() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isHardwareAccelerated with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isHardwareAccelerated();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isTunneledPlaybackSupported() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isTunneledPlaybackSupported with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isTunneledPlaybackSupported();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized long input(
+ final ByteBuffer bytes, final BufferInfo info, final CryptoInfo cryptoInfo) {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot send input to an ended codec");
+ return INVALID_SESSION;
+ }
+
+ final boolean eos = info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM;
+
+ if (eos) {
+ return sendInput(Sample.EOS);
+ }
+
+ try {
+ final Sample s = mRemote.dequeueInput(info.size);
+ fillInputBuffer(s.bufferId, bytes, info.offset, info.size);
+ mSession = s.session;
+ return sendInput(s.set(info, cryptoInfo));
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "fail to dequeue input buffer", e);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "fail to copy input data.", e);
+ // Balance dequeue/queue.
+ sendInput(null);
+ }
+ return INVALID_SESSION;
+ }
+
+ private void fillInputBuffer(
+ final int bufferId, final ByteBuffer bytes, final int offset, final int size)
+ throws RemoteException, IOException {
+ if (bytes == null || size == 0) {
+ Log.w(LOGTAG, "empty input");
+ return;
+ }
+
+ SampleBuffer buffer = mInputBuffers.get(bufferId);
+ if (buffer == null) {
+ buffer = mRemote.getInputBuffer(bufferId);
+ if (buffer != null) {
+ mInputBuffers.put(bufferId, buffer);
+ }
+ }
+
+ if (buffer.capacity() < size) {
+ final IOException e =
+ new IOException("data larger than capacity: " + size + " > " + buffer.capacity());
+ Log.e(LOGTAG, "cannot fill input.", e);
+ throw e;
+ }
+
+ buffer.readFromByteBuffer(bytes, offset, size);
+ }
+
+ private long sendInput(final Sample sample) {
+ try {
+ mRemote.queueInput(sample);
+ if (sample != null) {
+ sample.dispose();
+ mFlushed = false;
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "fail to queue input:" + sample, e);
+ return INVALID_SESSION;
+ }
+ return mSession;
+ }
+
+ @WrapForJNI
+ public synchronized boolean flush() {
+ if (mFlushed) {
+ return true;
+ }
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot flush an ended codec");
+ return false;
+ }
+ try {
+ if (DEBUG) {
+ Log.d(LOGTAG, "flush " + this);
+ }
+ resetBuffers();
+ mRemote.flush();
+ mFlushed = true;
+ } catch (final DeadObjectException e) {
+ return false;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+
+ private void resetBuffers() {
+ for (int i = 0; i < mInputBuffers.size(); ++i) {
+ mInputBuffers.valueAt(i).dispose();
+ }
+ mInputBuffers.clear();
+ for (int i = 0; i < mOutputBuffers.size(); ++i) {
+ mOutputBuffers.valueAt(i).dispose();
+ }
+ mOutputBuffers.clear();
+ }
+
+ @WrapForJNI
+ public boolean release() {
+ mCallbacks.setCodecProxyReleased();
+ synchronized (this) {
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ return true;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "release " + this);
+ }
+
+ if (!mSurfaceOutputs.isEmpty()) {
+ // Flushing output buffers to surface may cause some frames to be skipped and
+ // should not happen unless caller release codec before processing all buffers.
+ Log.w(LOGTAG, "release codec when " + mSurfaceOutputs.size() + " output buffers unhandled");
+ try {
+ for (final Sample s : mSurfaceOutputs) {
+ mRemote.releaseOutput(s, true);
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ mSurfaceOutputs.clear();
+ }
+
+ resetBuffers();
+
+ try {
+ RemoteManager.getInstance().releaseCodec(this);
+ } catch (final DeadObjectException e) {
+ return false;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean setBitrate(final int bps) {
+ if (!mIsEncoder) {
+ Log.w(LOGTAG, "this api is encoder-only");
+ return false;
+ }
+
+ if (android.os.Build.VERSION.SDK_INT < 19) {
+ Log.w(LOGTAG, "this api was added in API level 19");
+ return false;
+ }
+
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ return true;
+ }
+
+ try {
+ mRemote.setBitrate(bps);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "remote fail to set rates:" + bps);
+ e.printStackTrace();
+ }
+ return true;
+ }
+
+ @WrapForJNI
+ public synchronized boolean releaseOutput(final Sample sample, final boolean render) {
+ if (mOutputSurface != null) {
+ if (!mSurfaceOutputs.remove(sample)) {
+ if (mRemote != null) Log.w(LOGTAG, "already released: " + sample);
+ return true;
+ }
+
+ if (DEBUG && !render) {
+ Log.d(LOGTAG, "drop output:" + sample.info.presentationTimeUs);
+ }
+ }
+
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ sample.dispose();
+ return true;
+ }
+
+ try {
+ mRemote.releaseOutput(sample, render);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "remote fail to release output:" + sample.info.presentationTimeUs);
+ e.printStackTrace();
+ }
+ sample.dispose();
+
+ return true;
+ }
+
+ /* package */ void reportError(final boolean fatal) {
+ mCallbacks.reportError(fatal);
+ }
+
+ private synchronized SampleBuffer getOutputBuffer(final int id) {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot get buffer#" + id + " from an ended codec");
+ return null;
+ }
+
+ if (mOutputSurface != null || id == Sample.NO_BUFFER) {
+ return null;
+ }
+
+ SampleBuffer buffer = mOutputBuffers.get(id);
+ if (buffer != null) {
+ return buffer;
+ }
+
+ try {
+ buffer = mRemote.getOutputBuffer(id);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "cannot get buffer#" + id, e);
+ return null;
+ }
+ if (buffer != null) {
+ mOutputBuffers.put(id, buffer);
+ }
+
+ return buffer;
+ }
+
+ @WrapForJNI
+ public static boolean supportsCBCS() {
+ // Android N/API-24 supports CBCS but there seems to be a bug.
+ // See https://github.com/google/ExoPlayer/issues/4022
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N_MR1)
+ @WrapForJNI
+ public static boolean setCryptoPatternIfNeeded(
+ final CryptoInfo info, final int blocksToEncrypt, final int blocksToSkip) {
+ if (supportsCBCS() && (blocksToEncrypt > 0 || blocksToSkip > 0)) {
+ info.setPattern(new CryptoInfo.Pattern(blocksToEncrypt, blocksToSkip));
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java
new file mode 100644
index 0000000000..03340530ee
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java
@@ -0,0 +1,178 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaFormat;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import java.nio.ByteBuffer;
+
+/**
+ * A wrapper to make {@link MediaFormat} parcelable. Supports following keys:
+ *
+ * <ul>
+ * <li>{@link MediaFormat#KEY_MIME}
+ * <li>{@link MediaFormat#KEY_WIDTH}
+ * <li>{@link MediaFormat#KEY_HEIGHT}
+ * <li>{@link MediaFormat#KEY_CHANNEL_COUNT}
+ * <li>{@link MediaFormat#KEY_SAMPLE_RATE}
+ * <li>{@link MediaFormat#KEY_BIT_RATE}
+ * <li>{@link MediaFormat#KEY_BITRATE_MODE}
+ * <li>{@link MediaFormat#KEY_COLOR_FORMAT}
+ * <li>{@link MediaFormat#KEY_FRAME_RATE}
+ * <li>{@link MediaFormat#KEY_I_FRAME_INTERVAL}
+ * <li>{@link MediaFormat#KEY_STRIDE}
+ * <li>{@link MediaFormat#KEY_SLICE_HEIGHT}
+ * <li>"csd-0"
+ * <li>"csd-1"
+ * </ul>
+ */
+public final class FormatParam implements Parcelable {
+ // Keys for codec specific config bits not exposed in {@link MediaFormat}.
+ private static final String KEY_CONFIG_0 = "csd-0";
+ private static final String KEY_CONFIG_1 = "csd-1";
+
+ private MediaFormat mFormat;
+
+ public MediaFormat asFormat() {
+ return mFormat;
+ }
+
+ public FormatParam(final MediaFormat format) {
+ mFormat = format;
+ }
+
+ protected FormatParam(final Parcel in) {
+ mFormat = new MediaFormat();
+ readFromParcel(in);
+ }
+
+ public static final Creator<FormatParam> CREATOR =
+ new Creator<FormatParam>() {
+ @Override
+ public FormatParam createFromParcel(final Parcel in) {
+ return new FormatParam(in);
+ }
+
+ @Override
+ public FormatParam[] newArray(final int size) {
+ return new FormatParam[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void readFromParcel(final Parcel in) {
+ final Bundle bundle = in.readBundle();
+ fromBundle(bundle);
+ }
+
+ private void fromBundle(final Bundle bundle) {
+ if (bundle.containsKey(MediaFormat.KEY_MIME)) {
+ mFormat.setString(MediaFormat.KEY_MIME, bundle.getString(MediaFormat.KEY_MIME));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_WIDTH)) {
+ mFormat.setInteger(MediaFormat.KEY_WIDTH, bundle.getInt(MediaFormat.KEY_WIDTH));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) {
+ mFormat.setInteger(MediaFormat.KEY_HEIGHT, bundle.getInt(MediaFormat.KEY_HEIGHT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ mFormat.setInteger(
+ MediaFormat.KEY_CHANNEL_COUNT, bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, bundle.getInt(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (bundle.containsKey(KEY_CONFIG_0)) {
+ mFormat.setByteBuffer(KEY_CONFIG_0, ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0)));
+ }
+ if (bundle.containsKey(KEY_CONFIG_1)) {
+ mFormat.setByteBuffer(KEY_CONFIG_1, ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1))));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_BIT_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_BIT_RATE, bundle.getInt(MediaFormat.KEY_BIT_RATE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_BITRATE_MODE)) {
+ mFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, bundle.getInt(MediaFormat.KEY_BITRATE_MODE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_COLOR_FORMAT)) {
+ mFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, bundle.getInt(MediaFormat.KEY_COLOR_FORMAT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_FRAME_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, bundle.getInt(MediaFormat.KEY_FRAME_RATE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) {
+ mFormat.setInteger(
+ MediaFormat.KEY_I_FRAME_INTERVAL, bundle.getInt(MediaFormat.KEY_I_FRAME_INTERVAL));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_STRIDE)) {
+ mFormat.setInteger(MediaFormat.KEY_STRIDE, bundle.getInt(MediaFormat.KEY_STRIDE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ mFormat.setInteger(MediaFormat.KEY_SLICE_HEIGHT, bundle.getInt(MediaFormat.KEY_SLICE_HEIGHT));
+ }
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeBundle(toBundle());
+ }
+
+ private Bundle toBundle() {
+ final Bundle bundle = new Bundle();
+ if (mFormat.containsKey(MediaFormat.KEY_MIME)) {
+ bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) {
+ bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) {
+ bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ bundle.putInt(
+ MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_0)) {
+ final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0);
+ bundle.putByteArray(KEY_CONFIG_0, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_1)) {
+ final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1);
+ bundle.putByteArray(KEY_CONFIG_1, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_BIT_RATE)) {
+ bundle.putInt(MediaFormat.KEY_BIT_RATE, mFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_BITRATE_MODE)) {
+ bundle.putInt(MediaFormat.KEY_BITRATE_MODE, mFormat.getInteger(MediaFormat.KEY_BITRATE_MODE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_COLOR_FORMAT)) {
+ bundle.putInt(MediaFormat.KEY_COLOR_FORMAT, mFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
+ bundle.putInt(MediaFormat.KEY_FRAME_RATE, mFormat.getInteger(MediaFormat.KEY_FRAME_RATE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) {
+ bundle.putInt(
+ MediaFormat.KEY_I_FRAME_INTERVAL, mFormat.getInteger(MediaFormat.KEY_I_FRAME_INTERVAL));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_STRIDE)) {
+ bundle.putInt(MediaFormat.KEY_STRIDE, mFormat.getInteger(MediaFormat.KEY_STRIDE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ bundle.putInt(MediaFormat.KEY_SLICE_HEIGHT, mFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT));
+ }
+ return bundle;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java
new file mode 100644
index 0000000000..6418375a57
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java
@@ -0,0 +1,36 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// A subset of the class AudioInfo in dom/media/MediaInfo.h
+@WrapForJNI
+public final class GeckoAudioInfo {
+ public final byte[] codecSpecificData;
+ public final int rate;
+ public final int channels;
+ public final int bitDepth;
+ public final int profile;
+ public final long duration;
+ public final String mimeType;
+
+ public GeckoAudioInfo(
+ final int rate,
+ final int channels,
+ final int bitDepth,
+ final int profile,
+ final long duration,
+ final String mimeType,
+ final byte[] codecSpecificData) {
+ this.rate = rate;
+ this.channels = channels;
+ this.bitDepth = bitDepth;
+ this.profile = profile;
+ this.duration = duration;
+ this.mimeType = mimeType;
+ this.codecSpecificData = codecSpecificData;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java
new file mode 100644
index 0000000000..cd732fe535
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java
@@ -0,0 +1,166 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+public final class GeckoHLSDemuxerWrapper {
+ private static final String LOGTAG = "GeckoHLSDemuxerWrapper";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+
+ // NOTE : These TRACK definitions should be synced with Gecko.
+ public enum TrackType {
+ UNDEFINED(0),
+ AUDIO(1),
+ VIDEO(2),
+ TEXT(3);
+ private int mType;
+
+ private TrackType(final int type) {
+ mType = type;
+ }
+
+ public int value() {
+ return mType;
+ }
+ }
+
+ private BaseHlsPlayer mPlayer = null;
+
+ public static class Callbacks extends JNIObject implements BaseHlsPlayer.DemuxerCallbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ Callbacks() {}
+
+ @Override
+ @WrapForJNI
+ public native void onInitialized(boolean hasAudio, boolean hasVideo);
+
+ @Override
+ @WrapForJNI
+ public native void onError(int errorCode);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // Callbacks
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ private BaseHlsPlayer.TrackType getPlayerTrackType(final int trackType) {
+ if (trackType == TrackType.AUDIO.value()) {
+ return BaseHlsPlayer.TrackType.AUDIO;
+ } else if (trackType == TrackType.VIDEO.value()) {
+ return BaseHlsPlayer.TrackType.VIDEO;
+ } else if (trackType == TrackType.TEXT.value()) {
+ return BaseHlsPlayer.TrackType.TEXT;
+ }
+ return BaseHlsPlayer.TrackType.UNDEFINED;
+ }
+
+ @WrapForJNI
+ public long getBuffered() {
+ assertTrue(mPlayer != null);
+ return mPlayer.getBufferedPosition();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static GeckoHLSDemuxerWrapper create(
+ final int id, final BaseHlsPlayer.DemuxerCallbacks callback) {
+ return new GeckoHLSDemuxerWrapper(id, callback);
+ }
+
+ @WrapForJNI
+ public int getNumberOfTracks(final int trackType) {
+ assertTrue(mPlayer != null);
+ final int tracks = mPlayer.getNumberOfTracks(getPlayerTrackType(trackType));
+ if (DEBUG) Log.d(LOGTAG, "[GetNumberOfTracks] type : " + trackType + ", num = " + tracks);
+ return tracks;
+ }
+
+ @WrapForJNI
+ public GeckoAudioInfo getAudioInfo(final int index) {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "[getAudioInfo] formatIndex : " + index);
+ final GeckoAudioInfo aInfo = mPlayer.getAudioInfo(index);
+ return aInfo;
+ }
+
+ @WrapForJNI
+ public GeckoVideoInfo getVideoInfo(final int index) {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index);
+ final GeckoVideoInfo vInfo = mPlayer.getVideoInfo(index);
+ return vInfo;
+ }
+
+ @WrapForJNI
+ public boolean seek(final long seekTime) {
+ // seekTime : microseconds.
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "seek : " + seekTime + " (Us)");
+ return mPlayer.seek(seekTime);
+ }
+
+ GeckoHLSDemuxerWrapper(final int id, final BaseHlsPlayer.DemuxerCallbacks callback) {
+ if (DEBUG) Log.d(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ...");
+ assertTrue(callback != null);
+ try {
+ mPlayer = GeckoPlayerFactory.getPlayer(id);
+ if (mPlayer != null) {
+ mPlayer.addDemuxerWrapperCallbackListener(callback);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ... error", e);
+ callback.onError(BaseHlsPlayer.DemuxerError.UNKNOWN.code());
+ }
+ }
+
+ @WrapForJNI
+ private GeckoHLSSample[] getSamples(final int mediaType, final int number) {
+ assertTrue(mPlayer != null);
+ ConcurrentLinkedQueue<GeckoHLSSample> samples = null;
+ // getA/VSamples will always return a non-null instance.
+ samples = mPlayer.getSamples(getPlayerTrackType(mediaType), number);
+ assertTrue(samples.size() <= number);
+ return samples.toArray(new GeckoHLSSample[samples.size()]);
+ }
+
+ @WrapForJNI
+ private long getNextKeyFrameTime() {
+ assertTrue(mPlayer != null);
+ return mPlayer.getNextKeyFrameTime();
+ }
+
+ @WrapForJNI
+ private boolean isLiveStream() {
+ assertTrue(mPlayer != null);
+ return mPlayer.isLiveStream();
+ }
+
+ @WrapForJNI // Called when native object is destroyed.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mPlayer != null) {
+ release();
+ }
+ }
+
+ private void release() {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "release BaseHlsPlayer...");
+ GeckoPlayerFactory.removePlayer(mPlayer);
+ mPlayer.release();
+ mPlayer = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java
new file mode 100644
index 0000000000..c21789fdd0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java
@@ -0,0 +1,119 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+public class GeckoHLSResourceWrapper {
+ private static final String LOGTAG = "GeckoHLSResourceWrapper";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ private BaseHlsPlayer mPlayer = null;
+ private boolean mDestroy = false;
+
+ public static class Callbacks extends JNIObject implements BaseHlsPlayer.ResourceCallbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ Callbacks() {}
+
+ @Override
+ @WrapForJNI
+ public native void onLoad(String mediaUrl);
+
+ @Override
+ @WrapForJNI
+ public native void onDataArrived();
+
+ @Override
+ @WrapForJNI
+ public native void onError(int errorCode);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // Callbacks
+
+ private GeckoHLSResourceWrapper(
+ final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper created with url = " + url);
+ assertTrue(callback != null);
+
+ mPlayer = GeckoPlayerFactory.getPlayer();
+ try {
+ mPlayer.init(url, callback);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to create GeckoHlsResourceWrapper !", e);
+ callback.onError(BaseHlsPlayer.ResourceError.UNKNOWN.code());
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static GeckoHLSResourceWrapper create(
+ final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
+ return new GeckoHLSResourceWrapper(url, callback);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public int getPlayerId() {
+ // GeckoHLSResourceWrapper should always be created before others
+ assertTrue(!mDestroy);
+ assertTrue(mPlayer != null);
+ return mPlayer.getId();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void suspend() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper suspend");
+ if (mPlayer != null) {
+ mPlayer.suspend();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void resume() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper resume");
+ if (mPlayer != null) {
+ mPlayer.resume();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void play() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement played");
+ if (mPlayer != null) {
+ mPlayer.play();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void pause() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement paused");
+ if (mPlayer != null) {
+ mPlayer.pause();
+ }
+ }
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ @WrapForJNI // Called when native object is mDestroy.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mDestroy) {
+ return;
+ }
+ mDestroy = true;
+ if (mPlayer != null) {
+ GeckoPlayerFactory.removePlayer(mPlayer);
+ mPlayer.release();
+ mPlayer = null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java
new file mode 100644
index 0000000000..d2ab76a13d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java
@@ -0,0 +1,93 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class GeckoHLSSample {
+ public static final GeckoHLSSample EOS;
+
+ static {
+ final BufferInfo eosInfo = new BufferInfo();
+ eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ EOS = new GeckoHLSSample(null, eosInfo, null, 0);
+ }
+
+ // Indicate the index of format which is used by this sample.
+ @WrapForJNI public final int formatIndex;
+
+ @WrapForJNI public long duration;
+
+ @WrapForJNI public final BufferInfo info;
+
+ @WrapForJNI public final CryptoInfo cryptoInfo;
+
+ private ByteBuffer mBuffer = null;
+
+ @WrapForJNI
+ public void writeToByteBuffer(final ByteBuffer dest) throws IOException {
+ if (mBuffer != null && dest != null && info.size > 0) {
+ dest.put(mBuffer);
+ }
+ }
+
+ @WrapForJNI
+ public boolean isEOS() {
+ return (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ }
+
+ @WrapForJNI
+ public boolean isKeyFrame() {
+ return (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
+ }
+
+ public static GeckoHLSSample create(
+ final ByteBuffer src,
+ final BufferInfo info,
+ final CryptoInfo cryptoInfo,
+ final int formatIndex) {
+ return new GeckoHLSSample(src, info, cryptoInfo, formatIndex);
+ }
+
+ private GeckoHLSSample(
+ final ByteBuffer buffer,
+ final BufferInfo info,
+ final CryptoInfo cryptoInfo,
+ final int formatIndex) {
+ this.formatIndex = formatIndex;
+ duration = Long.MAX_VALUE;
+ this.mBuffer = buffer;
+ this.info = info;
+ this.cryptoInfo = cryptoInfo;
+ }
+
+ @Override
+ public String toString() {
+ if (isEOS()) {
+ return "EOS GeckoHLSSample";
+ }
+
+ final StringBuilder str = new StringBuilder();
+ str.append("{ info=")
+ .append("{ offset=")
+ .append(info.offset)
+ .append(", size=")
+ .append(info.size)
+ .append(", pts=")
+ .append(info.presentationTimeUs)
+ .append(", duration=")
+ .append(duration)
+ .append(", flags=")
+ .append(Integer.toHexString(info.flags))
+ .append(" }")
+ .append(" }");
+ return str.toString();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java
new file mode 100644
index 0000000000..a666e0e860
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java
@@ -0,0 +1,170 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Build;
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.List;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+public class GeckoHlsAudioRenderer extends GeckoHlsRendererBase {
+ public GeckoHlsAudioRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(C.TRACK_TYPE_AUDIO, eventDispatcher);
+ assertTrue(Build.VERSION.SDK_INT >= 16);
+ LOGTAG = getClass().getSimpleName();
+ DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ }
+
+ @Override
+ public final int supportsFormat(final Format format) {
+ /*
+ * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering
+ * formats with the same mime type, but
+ * the properties of the format exceed
+ * the renderer's capability.
+ * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose
+ * renderer for formats of the same
+ * top-level type, but is not capable of
+ * rendering the format or any other format
+ * with the same mime type because the
+ * sub-type is not supported.
+ * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering
+ * the format, either because it does not support
+ * the format's top-level type, or because it's
+ * a specialized renderer for a different mime type.
+ * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats,
+ * but may suffer a brief discontinuity (~50-100ms)
+ * when adaptation occurs.
+ */
+ final String mimeType = format.sampleMimeType;
+ if (!MimeTypes.isAudio(mimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+ List<MediaCodecInfo> decoderInfos = null;
+ try {
+ final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (decoderInfos == null || decoderInfos.isEmpty()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ }
+ final MediaCodecInfo info = decoderInfos.get(0);
+ /*
+ * Note : If the code can make it to this place, ExoPlayer assumes
+ * support for unknown sampleRate and channelCount when
+ * SDK version is less than 21, otherwise, further check is needed
+ * if there's no sampleRate/channelCount in format.
+ */
+ final boolean decoderCapable =
+ (Build.VERSION.SDK_INT < 21)
+ || ((format.sampleRate == Format.NO_VALUE
+ || info.isAudioSampleRateSupportedV21(format.sampleRate))
+ && (format.channelCount == Format.NO_VALUE
+ || info.isAudioChannelCountSupportedV21(format.channelCount)));
+ return RendererCapabilities.create(
+ decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES,
+ ADAPTIVE_NOT_SEAMLESS,
+ TUNNELING_NOT_SUPPORTED);
+ }
+
+ @Override
+ protected final void createInputBuffer() {
+ // We're not able to estimate the size for audio from format. So we rely
+ // on the dynamic allocation mechanism provided in DecoderInputBuffer.
+ mInputBuffer = null;
+ }
+
+ @Override
+ protected void resetRenderer() {
+ mInputBuffer = null;
+ mInitialized = false;
+ }
+
+ @Override
+ protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) {
+ // Do nothing
+ }
+
+ @Override
+ protected void handleFormatRead(final DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException {
+ onInputFormatChanged(mFormatHolder.format);
+ }
+
+ @Override
+ protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) {
+ mInputStreamEnded = true;
+ mDemuxedInputSamples.offer(GeckoHLSSample.EOS);
+ }
+
+ @Override
+ protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) {
+ final int size = bufferForRead.data.limit();
+ final byte[] realData = new byte[size];
+ bufferForRead.data.get(realData, 0, size);
+ final ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ final CryptoInfo cryptoInfo =
+ bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ final BufferInfo bufferInfo = new BufferInfo();
+ // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags.
+ int flags = 0;
+ flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
+ flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0;
+ bufferInfo.set(0, size, bufferForRead.timeUs, flags);
+
+ assertTrue(mFormats.size() >= 0);
+ // We add a new format in the list once format changes, so the formatIndex
+ // should indicate to the last(latest) format.
+ final GeckoHLSSample sample =
+ GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1);
+
+ mDemuxedInputSamples.offer(sample);
+
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(
+ LOGTAG,
+ "Demuxed sample PTS : "
+ + sample.info.presentationTimeUs
+ + ", duration :"
+ + sample.duration
+ + ", formatIndex("
+ + sample.formatIndex
+ + "), queue size : "
+ + mDemuxedInputSamples.size());
+ }
+ }
+
+ @Override
+ protected boolean clearInputSamplesQueue() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "clearInputSamplesQueue");
+ }
+ mDemuxedInputSamples.clear();
+ return true;
+ }
+
+ @Override
+ protected void notifyPlayerInputFormatChanged(final Format newFormat) {
+ mPlayerEventDispatcher.onAudioInputFormatChanged(newFormat);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java
new file mode 100644
index 0000000000..2193eaa602
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java
@@ -0,0 +1,1110 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.content.Context;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.DefaultLoadControl;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsMediaSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+
+@ReflectionTarget
+public class GeckoHlsPlayer implements BaseHlsPlayer, ExoPlayer.EventListener {
+ private static final String LOGTAG = "GeckoHlsPlayer";
+ private static final DefaultBandwidthMeter BANDWIDTH_METER =
+ new DefaultBandwidthMeter.Builder(null).build();
+ private static final int MAX_TIMELINE_ITEM_LINES = 3;
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+
+ private static final AtomicInteger sPlayerId = new AtomicInteger(0);
+ /*
+ * Because we treat GeckoHlsPlayer as a source data provider.
+ * It will be created and initialized with a URL by HLSResource in
+ * Gecko media pipleine (in cpp). Once HLSDemuxer is created later, we
+ * need to bridge this HLSResource to the created demuxer. And they share
+ * the same GeckoHlsPlayer.
+ * mPlayerId is a token used for Gecko media pipeline to obtain corresponding player.
+ */
+ private final int mPlayerId;
+ // Accessed only in GeckoHlsPlayerThread.
+ private boolean mExoplayerSuspended = false;
+
+ private static final int DEFAULT_MIN_BUFFER_MS = 5 * 1000;
+ private static final int DEFAULT_MAX_BUFFER_MS = 10 * 1000;
+
+ private enum MediaDecoderPlayState {
+ PLAY_STATE_PREPARING,
+ PLAY_STATE_PAUSED,
+ PLAY_STATE_PLAYING
+ }
+ // Default value is PLAY_STATE_PREPARING and it will be set to PLAY_STATE_PLAYING
+ // once HTMLMediaElement calls PlayInternal().
+ // Accessed only in GeckoHlsPlayerThread.
+ private MediaDecoderPlayState mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PREPARING;
+
+ private Handler mMainHandler;
+ private HandlerThread mThread;
+ private ExoPlayer mPlayer;
+ private GeckoHlsRendererBase[] mRenderers;
+ private DefaultTrackSelector mTrackSelector;
+ private MediaSource mMediaSource;
+ private SourceEventListener mSourceEventListener;
+ private ComponentListener mComponentListener;
+ private ComponentEventDispatcher mComponentEventDispatcher;
+
+ private volatile boolean mIsTimelineStatic = false;
+ private long mDurationUs;
+
+ private GeckoHlsVideoRenderer mVRenderer = null;
+ private GeckoHlsAudioRenderer mARenderer = null;
+
+ // Able to control if we only want V/A/V+A tracks from bitstream.
+ private class RendererController {
+ private final boolean mEnableV;
+ private final boolean mEnableA;
+
+ RendererController(final boolean enableVideoRenderer, final boolean enableAudioRenderer) {
+ this.mEnableV = enableVideoRenderer;
+ this.mEnableA = enableAudioRenderer;
+ }
+
+ boolean isVideoRendererEnabled() {
+ return mEnableV;
+ }
+
+ boolean isAudioRendererEnabled() {
+ return mEnableA;
+ }
+ }
+
+ private RendererController mRendererController = new RendererController(true, true);
+
+ // Provide statistical information of tracks.
+ private class HlsMediaTracksInfo {
+ private int mNumVideoTracks = 0;
+ private int mNumAudioTracks = 0;
+ private boolean mVideoInfoUpdated = false;
+ private boolean mAudioInfoUpdated = false;
+ private boolean mVideoDataArrived = false;
+ private boolean mAudioDataArrived = false;
+
+ HlsMediaTracksInfo() {}
+
+ public void reset() {
+ mNumVideoTracks = 0;
+ mNumAudioTracks = 0;
+ mVideoInfoUpdated = false;
+ mAudioInfoUpdated = false;
+ mVideoDataArrived = false;
+ mAudioDataArrived = false;
+ }
+
+ public void updateNumOfVideoTracks(final int numOfTracks) {
+ mNumVideoTracks = numOfTracks;
+ }
+
+ public void updateNumOfAudioTracks(final int numOfTracks) {
+ mNumAudioTracks = numOfTracks;
+ }
+
+ public boolean hasVideo() {
+ return mNumVideoTracks > 0;
+ }
+
+ public boolean hasAudio() {
+ return mNumAudioTracks > 0;
+ }
+
+ public int getNumOfVideoTracks() {
+ return mNumVideoTracks;
+ }
+
+ public int getNumOfAudioTracks() {
+ return mNumAudioTracks;
+ }
+
+ public void onVideoInfoUpdated() {
+ mVideoInfoUpdated = true;
+ }
+
+ public void onAudioInfoUpdated() {
+ mAudioInfoUpdated = true;
+ }
+
+ public void onDataArrived(final int trackType) {
+ if (trackType == C.TRACK_TYPE_VIDEO) {
+ mVideoDataArrived = true;
+ } else if (trackType == C.TRACK_TYPE_AUDIO) {
+ mAudioDataArrived = true;
+ }
+ }
+
+ public boolean videoReady() {
+ return !hasVideo() || (mVideoInfoUpdated && mVideoDataArrived);
+ }
+
+ public boolean audioReady() {
+ return !hasAudio() || (mAudioInfoUpdated && mAudioDataArrived);
+ }
+ }
+
+ private HlsMediaTracksInfo mTracksInfo = new HlsMediaTracksInfo();
+
+ // Used only in GeckoHlsPlayerThread.
+ private boolean mIsPlayerInitDone = false;
+ private boolean mIsDemuxerInitDone = false;
+ private BaseHlsPlayer.DemuxerCallbacks mDemuxerCallbacks;
+ private BaseHlsPlayer.ResourceCallbacks mResourceCallbacks;
+
+ private boolean mReleasing = false; // Used only in Gecko Main thread.
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ protected void checkInitDone() {
+ if (mIsDemuxerInitDone) {
+ return;
+ }
+ assertTrue(mDemuxerCallbacks != null);
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "[checkInitDone] VReady:"
+ + mTracksInfo.videoReady()
+ + ",AReady:"
+ + mTracksInfo.audioReady()
+ + ",hasV:"
+ + mTracksInfo.hasVideo()
+ + ",hasA:"
+ + mTracksInfo.hasAudio());
+ }
+ if (mTracksInfo.videoReady() && mTracksInfo.audioReady()) {
+ if (mDemuxerCallbacks != null) {
+ mDemuxerCallbacks.onInitialized(mTracksInfo.hasAudio(), mTracksInfo.hasVideo());
+ }
+ mIsDemuxerInitDone = true;
+ }
+ }
+
+ private final class SourceEventListener implements MediaSourceEventListener {
+ public void onLoadStarted(
+ final int windowIndex,
+ final MediaSource.MediaPeriodId mediaPeriodId,
+ final LoadEventInfo loadEventInfo,
+ final MediaLoadData mediaLoadData) {
+ assertTrue(isPlayerThread());
+
+ synchronized (GeckoHlsPlayer.this) {
+ if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) {
+ // Don't report non-media URLs.
+ return;
+ }
+ if (mResourceCallbacks == null || loadEventInfo.uri == null || mReleasing) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "on-load: url=" + loadEventInfo.uri);
+ }
+ mResourceCallbacks.onLoad(loadEventInfo.uri.toString());
+ }
+ }
+ }
+
+ public final class ComponentEventDispatcher {
+ // Called from GeckoHls{Audio,Video}Renderer/ExoPlayer internal playback thread
+ // or GeckoHlsPlayerThread.
+ public void onDataArrived(final int trackType) {
+ assertTrue(mComponentListener != null);
+
+ if (mComponentListener != null) {
+ runOnPlayerThread(() -> mComponentListener.onDataArrived(trackType));
+ }
+ }
+
+ // Called from GeckoHls{Audio,Video}Renderer internal playback thread.
+ public void onVideoInputFormatChanged(final Format format) {
+ assertTrue(mComponentListener != null);
+
+ if (mComponentListener != null) {
+ runOnPlayerThread(() -> mComponentListener.onVideoInputFormatChanged(format));
+ }
+ }
+
+ // Called from GeckoHls{Audio,Video}Renderer internal playback thread.
+ public void onAudioInputFormatChanged(final Format format) {
+ assertTrue(mComponentListener != null);
+
+ if (mComponentListener != null) {
+ runOnPlayerThread(() -> mComponentListener.onAudioInputFormatChanged(format));
+ }
+ }
+ }
+
+ public final class ComponentListener {
+
+ // General purpose implementation
+ // Called on GeckoHlsPlayerThread
+ public void onDataArrived(final int trackType) {
+ assertTrue(isPlayerThread());
+
+ synchronized (GeckoHlsPlayer.this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[CB][onDataArrived] id " + mPlayerId);
+ }
+ if (!mIsPlayerInitDone) {
+ return;
+ }
+
+ mTracksInfo.onDataArrived(trackType);
+ if (!mReleasing) {
+ mResourceCallbacks.onDataArrived();
+ }
+ checkInitDone();
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread
+ public void onVideoInputFormatChanged(final Format format) {
+ assertTrue(isPlayerThread());
+
+ synchronized (GeckoHlsPlayer.this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[CB] onVideoInputFormatChanged [" + format + "]");
+ Log.d(
+ LOGTAG,
+ "[CB] SampleMIMEType ["
+ + format.sampleMimeType
+ + "], ContainerMIMEType ["
+ + format.containerMimeType
+ + "], id : "
+ + mPlayerId);
+ }
+ if (!mIsPlayerInitDone) {
+ return;
+ }
+ mTracksInfo.onVideoInfoUpdated();
+ checkInitDone();
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread
+ public void onAudioInputFormatChanged(final Format format) {
+ assertTrue(isPlayerThread());
+
+ synchronized (GeckoHlsPlayer.this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[CB] onAudioInputFormatChanged [" + format + "], mPlayerId :" + mPlayerId);
+ }
+ if (!mIsPlayerInitDone) {
+ return;
+ }
+ mTracksInfo.onAudioInfoUpdated();
+ checkInitDone();
+ }
+ }
+ }
+
+ private HlsMediaSource.Factory buildDataSourceFactory(
+ final Context ctx, final DefaultBandwidthMeter bandwidthMeter) {
+ return new HlsMediaSource.Factory(
+ new DefaultDataSourceFactory(
+ ctx, bandwidthMeter, buildHttpDataSourceFactory(bandwidthMeter)));
+ }
+
+ private HttpDataSource.Factory buildHttpDataSourceFactory(
+ final DefaultBandwidthMeter bandwidthMeter) {
+ return new DefaultHttpDataSourceFactory(
+ BuildConfig.USER_AGENT_GECKOVIEW_MOBILE,
+ bandwidthMeter /* listener */,
+ DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS,
+ DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS,
+ true /* allowCrossProtocolRedirects */);
+ }
+
+ private long getDuration() {
+ return awaitPlayerThread(
+ () -> {
+ long duration = 0L;
+ // Value returned by getDuration() is in milliseconds.
+ if (mPlayer != null && !isLiveStream()) {
+ duration = Math.max(0L, mPlayer.getDuration() * 1000L);
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "getDuration : " + duration + "(Us)");
+ }
+ return duration;
+ });
+ }
+
+ // To make sure that each player has a unique id, GeckoHlsPlayer should be
+ // created only from synchronized APIs in GeckoPlayerFactory.
+ public GeckoHlsPlayer() {
+ mPlayerId = sPlayerId.incrementAndGet();
+ if (DEBUG) {
+ Log.d(LOGTAG, " construct player with id(" + mPlayerId + ")");
+ }
+ }
+
+ // Should be only called by GeckoPlayerFactory and GeckoHLSResourceWrapper.
+ // The mPlayerId is used to make sure that the same GeckoHlsPlayer is used by
+ // corresponding HLSResource and HLSDemuxer for each media playback.
+ // Called on Gecko's main thread
+ @Override
+ public int getId() {
+ return mPlayerId;
+ }
+
+ // Called on Gecko's main thread
+ @Override
+ public synchronized void addDemuxerWrapperCallbackListener(
+ final BaseHlsPlayer.DemuxerCallbacks callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, " addDemuxerWrapperCallbackListener ...");
+ }
+ mDemuxerCallbacks = callback;
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public synchronized void onLoadingChanged(final boolean isLoading) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "loading [" + isLoading + "]");
+ }
+ if (!isLoading) {
+ if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ suspendExoplayer();
+ }
+ // To update buffered position.
+ mComponentEventDispatcher.onDataArrived(C.TRACK_TYPE_DEFAULT);
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public synchronized void onPlayerStateChanged(final boolean playWhenReady, final int state) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "state [" + playWhenReady + ", " + getStateString(state) + "]");
+ }
+ if (state == ExoPlayer.STATE_READY
+ && !mExoplayerSuspended
+ && mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ resumeExoplayer();
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public void onPositionDiscontinuity(final int reason) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "positionDiscontinuity: reason=" + reason);
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "playbackParameters "
+ + String.format(
+ "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch));
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public synchronized void onPlayerError(final ExoPlaybackException e) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.e(LOGTAG, "playerFailed", e);
+ }
+ mIsPlayerInitDone = false;
+ if (mReleasing) {
+ return;
+ }
+ if (mResourceCallbacks != null) {
+ mResourceCallbacks.onError(ResourceError.PLAYER.code());
+ }
+ if (mDemuxerCallbacks != null) {
+ mDemuxerCallbacks.onError(DemuxerError.PLAYER.code());
+ }
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public synchronized void onTracksChanged(
+ final TrackGroupArray ignored, final TrackSelectionArray trackSelections) {
+ assertTrue(isPlayerThread());
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "onTracksChanged : TGA[" + ignored + "], TSA[" + trackSelections + "]");
+
+ final MappedTrackInfo mappedTrackInfo = mTrackSelector.getCurrentMappedTrackInfo();
+ if (mappedTrackInfo == null) {
+ Log.d(LOGTAG, "Tracks []");
+ return;
+ }
+ Log.d(LOGTAG, "Tracks [");
+ // Log tracks associated to renderers.
+ for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) {
+ final TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex);
+ final TrackSelection trackSelection = trackSelections.get(rendererIndex);
+ if (rendererTrackGroups.length > 0) {
+ Log.d(LOGTAG, " Renderer:" + rendererIndex + " [");
+ for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) {
+ final TrackGroup trackGroup = rendererTrackGroups.get(groupIndex);
+ final String adaptiveSupport =
+ getAdaptiveSupportString(
+ trackGroup.length,
+ mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false));
+ Log.d(
+ LOGTAG,
+ " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " [");
+ for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
+ final String status = getTrackStatusString(trackSelection, trackGroup, trackIndex);
+ final String formatSupport =
+ getFormatSupportString(
+ mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex));
+ Log.d(
+ LOGTAG,
+ " "
+ + status
+ + " Track:"
+ + trackIndex
+ + ", "
+ + Format.toLogString(trackGroup.getFormat(trackIndex))
+ + ", supported="
+ + formatSupport);
+ }
+ Log.d(LOGTAG, " ]");
+ }
+ Log.d(LOGTAG, " ]");
+ }
+ }
+ // Log tracks not associated with a renderer.
+ final TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups();
+ if (unassociatedTrackGroups.length > 0) {
+ Log.d(LOGTAG, " Renderer:None [");
+ for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) {
+ Log.d(LOGTAG, " Group:" + groupIndex + " [");
+ final TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex);
+ for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
+ final String status = getTrackStatusString(false);
+ final String formatSupport =
+ getFormatSupportString(RendererCapabilities.FORMAT_UNSUPPORTED_TYPE);
+ Log.d(
+ LOGTAG,
+ " "
+ + status
+ + " Track:"
+ + trackIndex
+ + ", "
+ + Format.toLogString(trackGroup.getFormat(trackIndex))
+ + ", supported="
+ + formatSupport);
+ }
+ Log.d(LOGTAG, " ]");
+ }
+ Log.d(LOGTAG, " ]");
+ }
+ Log.d(LOGTAG, "]");
+ }
+ mTracksInfo.reset();
+ int numVideoTracks = 0;
+ int numAudioTracks = 0;
+ for (int j = 0; j < ignored.length; j++) {
+ final TrackGroup tg = ignored.get(j);
+ for (int i = 0; i < tg.length; i++) {
+ final Format fmt = tg.getFormat(i);
+ if (fmt.sampleMimeType != null) {
+ if (mRendererController.isVideoRendererEnabled()
+ && fmt.sampleMimeType.startsWith(new String("video"))) {
+ numVideoTracks++;
+ } else if (mRendererController.isAudioRendererEnabled()
+ && fmt.sampleMimeType.startsWith(new String("audio"))) {
+ numAudioTracks++;
+ }
+ }
+ }
+ }
+ mTracksInfo.updateNumOfVideoTracks(numVideoTracks);
+ mTracksInfo.updateNumOfAudioTracks(numAudioTracks);
+ }
+
+ // Called on GeckoHlsPlayerThread from ExoPlayer
+ @Override
+ public synchronized void onTimelineChanged(final Timeline timeline, final int reason) {
+ assertTrue(isPlayerThread());
+
+ // For now, we use the interface ExoPlayer.getDuration() for gecko,
+ // so here we create local variable 'window' & 'peroid' to obtain
+ // the dynamic duration.
+ // See.
+ // http://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Timeline.html
+ // for further information.
+ final Timeline.Window window = new Timeline.Window();
+ mIsTimelineStatic =
+ !timeline.isEmpty() && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic;
+
+ final int periodCount = timeline.getPeriodCount();
+ final int windowCount = timeline.getWindowCount();
+ if (DEBUG) {
+ Log.d(LOGTAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount);
+ }
+ final Timeline.Period period = new Timeline.Period();
+ for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) {
+ timeline.getPeriod(i, period);
+ if (mDurationUs < period.getDurationUs()) {
+ mDurationUs = period.getDurationUs();
+ }
+ }
+ for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) {
+ timeline.getWindow(i, window);
+ if (mDurationUs < window.getDurationUs()) {
+ mDurationUs = window.getDurationUs();
+ }
+ }
+ // TODO : Need to check if the duration from play.getDuration is different
+ // with the one calculated from multi-timelines/windows.
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "Media duration (from Timeline) = "
+ + mDurationUs
+ + "(us)"
+ + " player.getDuration() = "
+ + mPlayer.getDuration()
+ + "(ms)");
+ }
+ }
+
+ private static String getStateString(final int state) {
+ switch (state) {
+ case ExoPlayer.STATE_BUFFERING:
+ return "B";
+ case ExoPlayer.STATE_ENDED:
+ return "E";
+ case ExoPlayer.STATE_IDLE:
+ return "I";
+ case ExoPlayer.STATE_READY:
+ return "R";
+ default:
+ return "?";
+ }
+ }
+
+ private static String getFormatSupportString(final int formatSupport) {
+ switch (formatSupport) {
+ case RendererCapabilities.FORMAT_HANDLED:
+ return "YES";
+ case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES:
+ return "NO_EXCEEDS_CAPABILITIES";
+ case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE:
+ return "NO_UNSUPPORTED_TYPE";
+ case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE:
+ return "NO";
+ default:
+ return "?";
+ }
+ }
+
+ private static String getAdaptiveSupportString(final int trackCount, final int adaptiveSupport) {
+ if (trackCount < 2) {
+ return "N/A";
+ }
+ switch (adaptiveSupport) {
+ case RendererCapabilities.ADAPTIVE_SEAMLESS:
+ return "YES";
+ case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS:
+ return "YES_NOT_SEAMLESS";
+ case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED:
+ return "NO";
+ default:
+ return "?";
+ }
+ }
+
+ private static String getTrackStatusString(
+ final TrackSelection selection, final TrackGroup group, final int trackIndex) {
+ return getTrackStatusString(
+ selection != null
+ && selection.getTrackGroup() == group
+ && selection.indexOf(trackIndex) != C.INDEX_UNSET);
+ }
+
+ private static String getTrackStatusString(final boolean enabled) {
+ return enabled ? "[X]" : "[ ]";
+ }
+
+ // Called on GeckoHlsPlayerThread
+ private void createExoPlayer(final String url) {
+ assertTrue(isPlayerThread());
+
+ final Context ctx = GeckoAppShell.getApplicationContext();
+ mComponentListener = new ComponentListener();
+ mComponentEventDispatcher = new ComponentEventDispatcher();
+ mDurationUs = 0;
+
+ // Prepare trackSelector
+ final TrackSelection.Factory videoTrackSelectionFactory =
+ new AdaptiveTrackSelection.Factory(BANDWIDTH_METER);
+ mTrackSelector = new DefaultTrackSelector(videoTrackSelectionFactory);
+
+ // Prepare customized renderer
+ mRenderers = new GeckoHlsRendererBase[2];
+ mVRenderer = new GeckoHlsVideoRenderer(mComponentEventDispatcher);
+ mARenderer = new GeckoHlsAudioRenderer(mComponentEventDispatcher);
+ mRenderers[0] = mVRenderer;
+ mRenderers[1] = mARenderer;
+
+ final DefaultLoadControl dlc =
+ new DefaultLoadControl.Builder()
+ .setAllocator(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE))
+ .setBufferDurationsMs(
+ DEFAULT_MIN_BUFFER_MS,
+ DEFAULT_MAX_BUFFER_MS,
+ DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS,
+ DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
+ .createDefaultLoadControl();
+ // Create ExoPlayer instance with specific components.
+ mPlayer =
+ new ExoPlayer.Builder(ctx, mRenderers)
+ .setTrackSelector(mTrackSelector)
+ .setLoadControl(dlc)
+ .build();
+ mPlayer.addListener(this);
+
+ final Uri uri = Uri.parse(url);
+ mMediaSource = buildDataSourceFactory(ctx, BANDWIDTH_METER).createMediaSource(uri);
+ mSourceEventListener = new SourceEventListener();
+ mMediaSource.addEventListener(mMainHandler, mSourceEventListener);
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "Uri is " + uri + ", ContentType is " + Util.inferContentType(uri.getLastPathSegment()));
+ }
+ mPlayer.setPlayWhenReady(false);
+ mPlayer.prepare(mMediaSource);
+ mIsPlayerInitDone = true;
+ }
+ // =======================================================================
+ // API for GeckoHLSResourceWrapper
+ // =======================================================================
+ // Called on Gecko Main Thread
+ @Override
+ public synchronized void init(final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, " init");
+ }
+ assertTrue(callback != null);
+ assertTrue(!mIsPlayerInitDone);
+
+ mThread = new HandlerThread("GeckoHlsPlayerThread");
+ mThread.start();
+ mMainHandler = new Handler(mThread.getLooper());
+
+ mMainHandler.post(
+ () -> {
+ mResourceCallbacks = callback;
+ createExoPlayer(url);
+ });
+ }
+
+ // Called on MDSM's TaskQueue
+ @Override
+ public boolean isLiveStream() {
+ return !mIsTimelineStatic;
+ }
+ // =======================================================================
+ // API for GeckoHLSDemuxerWrapper
+ // =======================================================================
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getSamples(
+ final TrackType trackType, final int number) {
+ if (trackType == TrackType.VIDEO) {
+ return mVRenderer != null
+ ? mVRenderer.getQueuedSamples(number)
+ : new ConcurrentLinkedQueue<GeckoHLSSample>();
+ } else if (trackType == TrackType.AUDIO) {
+ return mARenderer != null
+ ? mARenderer.getQueuedSamples(number)
+ : new ConcurrentLinkedQueue<GeckoHLSSample>();
+ } else {
+ return new ConcurrentLinkedQueue<GeckoHLSSample>();
+ }
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public long getBufferedPosition() {
+ return awaitPlayerThread(
+ () -> {
+ // Value returned by getBufferedPosition() is in milliseconds.
+ final long bufferedPos =
+ mPlayer == null ? 0L : Math.max(0L, mPlayer.getBufferedPosition() * 1000L);
+ if (DEBUG) {
+ Log.d(LOGTAG, "getBufferedPosition : " + bufferedPos + "(Us)");
+ }
+ return bufferedPos;
+ });
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public synchronized int getNumberOfTracks(final TrackType trackType) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getNumberOfTracks : type " + trackType);
+ }
+ if (trackType == TrackType.VIDEO) {
+ return mTracksInfo.getNumOfVideoTracks();
+ } else if (trackType == TrackType.AUDIO) {
+ return mTracksInfo.getNumOfAudioTracks();
+ }
+ return 0;
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public GeckoVideoInfo getVideoInfo(final int index) {
+ final Format fmt;
+ synchronized (this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getVideoInfo");
+ }
+ if (mVRenderer == null) {
+ Log.e(LOGTAG, "no render to get video info from. Index : " + index);
+ return null;
+ }
+ if (!mTracksInfo.hasVideo()) {
+ return null;
+ }
+ fmt = mVRenderer.getFormat(index);
+ if (fmt == null) {
+ return null;
+ }
+ }
+ final GeckoVideoInfo vInfo =
+ new GeckoVideoInfo(
+ fmt.width,
+ fmt.height,
+ fmt.width,
+ fmt.height,
+ fmt.rotationDegrees,
+ fmt.stereoMode,
+ getDuration(),
+ fmt.sampleMimeType,
+ null,
+ null);
+ return vInfo;
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public GeckoAudioInfo getAudioInfo(final int index) {
+ final Format fmt;
+ synchronized (this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getAudioInfo");
+ }
+ if (mARenderer == null) {
+ Log.e(LOGTAG, "no render to get audio info from. Index : " + index);
+ return null;
+ }
+ if (!mTracksInfo.hasAudio()) {
+ return null;
+ }
+ fmt = mARenderer.getFormat(index);
+ if (fmt == null) {
+ return null;
+ }
+ }
+ /* According to https://github.com/google/ExoPlayer/blob
+ * /d979469659861f7fe1d39d153b90bdff1ab479cc/library/core/src/main
+ * /java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java#L221-L224,
+ * if the input audio format is not raw, exoplayer would assure that
+ * the sample's pcm encoding bitdepth is 16.
+ * For HLS content, it should always be 16.
+ */
+ assertTrue(!MimeTypes.AUDIO_RAW.equals(fmt.sampleMimeType));
+ // For HLS content, csd-0 is enough.
+ final byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0);
+ final GeckoAudioInfo aInfo =
+ new GeckoAudioInfo(
+ fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd);
+ return aInfo;
+ }
+
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public boolean seek(final long positionUs) {
+ synchronized (this) {
+ if (mPlayer == null) {
+ Log.d(LOGTAG, "Seek operation won't be performed as no player exists!");
+ return false;
+ }
+ }
+ return awaitPlayerThread(
+ () -> {
+ // Need to temporarily resume Exoplayer to download the chunks for getting the demuxed
+ // keyframe sample when HTMLMediaElement is paused. Suspend Exoplayer when collecting
+ // enough
+ // samples in onLoadingChanged.
+ if (mExoplayerSuspended) {
+ resumeExoplayer();
+ }
+ // positionUs : microseconds.
+ // NOTE : 1) It's not possible to seek media by tracktype via ExoPlayer Interface.
+ // 2) positionUs is samples PTS from MFR, we need to re-adjust it
+ // for ExoPlayer by subtracting sample start time.
+ // 3) Time unit for ExoPlayer.seek() is milliseconds.
+ try {
+ // TODO : Gather Timeline Period / Window information to develop
+ // complete timeline, and seekTime should be inside the duration.
+ Long startTime = Long.MAX_VALUE;
+ for (final GeckoHlsRendererBase r : mRenderers) {
+ if (r == mVRenderer
+ && mRendererController.isVideoRendererEnabled()
+ && mTracksInfo.hasVideo()
+ || r == mARenderer
+ && mRendererController.isAudioRendererEnabled()
+ && mTracksInfo.hasAudio()) {
+ // Find the min value of the start time
+ startTime = Math.min(startTime, r.getFirstSamplePTS());
+ }
+ }
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "seeking : "
+ + positionUs / 1000
+ + " (ms); startTime : "
+ + startTime / 1000
+ + " (ms)");
+ }
+ assertTrue(startTime != Long.MAX_VALUE && startTime != Long.MIN_VALUE);
+ mPlayer.seekTo(positionUs / 1000 - startTime / 1000);
+ } catch (final Exception e) {
+ if (mReleasing) {
+ return false;
+ }
+ if (mDemuxerCallbacks != null) {
+ mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code());
+ }
+ return false;
+ }
+ return true;
+ });
+ }
+
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public synchronized long getNextKeyFrameTime() {
+ final long nextKeyFrameTime =
+ mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE;
+ return nextKeyFrameTime;
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void suspend() {
+ runOnPlayerThread(
+ () -> {
+ if (mExoplayerSuspended) {
+ return;
+ }
+ if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "suspend player id : " + mPlayerId);
+ }
+ suspendExoplayer();
+ }
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void resume() {
+ runOnPlayerThread(
+ () -> {
+ if (!mExoplayerSuspended) {
+ return;
+ }
+ if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "resume player id : " + mPlayerId);
+ }
+ resumeExoplayer();
+ }
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void play() {
+ runOnPlayerThread(
+ () -> {
+ if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "MediaDecoder played.");
+ }
+ mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PLAYING;
+ resumeExoplayer();
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void pause() {
+ runOnPlayerThread(
+ () -> {
+ if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "MediaDecoder paused.");
+ }
+ mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PAUSED;
+ suspendExoplayer();
+ });
+ }
+
+ private void suspendExoplayer() {
+ assertTrue(isPlayerThread());
+
+ if (mPlayer == null) {
+ return;
+ }
+ mExoplayerSuspended = true;
+ if (DEBUG) {
+ Log.d(LOGTAG, "suspend Exoplayer");
+ }
+ mPlayer.setPlayWhenReady(false);
+ }
+
+ private void resumeExoplayer() {
+ assertTrue(isPlayerThread());
+
+ if (mPlayer == null) {
+ return;
+ }
+ mExoplayerSuspended = false;
+ if (DEBUG) {
+ Log.d(LOGTAG, "resume Exoplayer");
+ }
+ mPlayer.setPlayWhenReady(true);
+ }
+
+ // Called on Gecko's main thread, when HLSDemuxer or HLSResource destructs.
+ @Override
+ public void release() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "releasing ... id : " + mPlayerId);
+ }
+
+ synchronized (this) {
+ if (mReleasing) {
+ return;
+ } else {
+ mReleasing = true;
+ }
+ }
+
+ runOnPlayerThread(
+ () -> {
+ if (mPlayer != null) {
+ mPlayer.removeListener(this);
+ mPlayer.stop();
+ mPlayer.release();
+ mVRenderer = null;
+ mARenderer = null;
+ mPlayer = null;
+ }
+ if (mThread != null) {
+ mThread.quit();
+ mThread = null;
+ }
+ mDemuxerCallbacks = null;
+ mResourceCallbacks = null;
+ mIsPlayerInitDone = false;
+ mIsDemuxerInitDone = false;
+ });
+ }
+
+ private void runOnPlayerThread(final Runnable task) {
+ assertTrue(mMainHandler != null);
+ if (isPlayerThread()) {
+ task.run();
+ } else {
+ mMainHandler.post(task);
+ }
+ }
+
+ private boolean isPlayerThread() {
+ return Thread.currentThread() == mMainHandler.getLooper().getThread();
+ }
+
+ private <T> T awaitPlayerThread(final Callable<T> task) {
+ assertTrue(!isPlayerThread());
+
+ try {
+ final FutureTask<T> wait = new FutureTask<T>(task);
+ mMainHandler.post(wait);
+ return wait.get();
+ } catch (final Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java
new file mode 100644
index 0000000000..ecb7b93d61
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java
@@ -0,0 +1,340 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.BaseRenderer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+
+public abstract class GeckoHlsRendererBase extends BaseRenderer {
+ protected static final int QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD = 1000000; // 1sec
+ protected final FormatHolder mFormatHolder = new FormatHolder();
+ /*
+ * DEBUG/LOGTAG will be set in the 2 subclass GeckoHlsAudioRenderer and
+ * GeckoHlsVideoRenderer, and we still wants to log message in the base class
+ * GeckoHlsRendererBase, so neither 'static' nor 'final' are applied to them.
+ */
+ protected boolean DEBUG;
+ protected String LOGTAG;
+ // Notify GeckoHlsPlayer about renderer's status, i.e. data has arrived.
+ protected GeckoHlsPlayer.ComponentEventDispatcher mPlayerEventDispatcher;
+
+ protected ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedInputSamples =
+ new ConcurrentLinkedQueue<>();
+
+ protected ByteBuffer mInputBuffer = null;
+ protected ArrayList<Format> mFormats = new ArrayList<Format>();
+ protected boolean mInitialized = false;
+ protected boolean mWaitingForData = true;
+ protected boolean mInputStreamEnded = false;
+ protected long mFirstSampleStartTime = Long.MIN_VALUE;
+
+ protected abstract void createInputBuffer() throws ExoPlaybackException;
+
+ protected abstract void handleReconfiguration(DecoderInputBuffer bufferForRead);
+
+ protected abstract void handleFormatRead(DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException;
+
+ protected abstract void handleEndOfStream(DecoderInputBuffer bufferForRead);
+
+ protected abstract void handleSamplePreparation(DecoderInputBuffer bufferForRead);
+
+ protected abstract void resetRenderer();
+
+ protected abstract boolean clearInputSamplesQueue();
+
+ protected abstract void notifyPlayerInputFormatChanged(Format newFormat);
+
+ private DecoderInputBuffer mBufferForRead =
+ new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
+ private final DecoderInputBuffer mFlagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance();
+
+ protected void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ public GeckoHlsRendererBase(
+ final int trackType, final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(trackType);
+ mPlayerEventDispatcher = eventDispatcher;
+ }
+
+ private boolean isQueuedEnoughData() {
+ if (mDemuxedInputSamples.isEmpty()) {
+ return false;
+ }
+
+ final Iterator<GeckoHLSSample> iter = mDemuxedInputSamples.iterator();
+ long firstPTS = 0;
+ if (iter.hasNext()) {
+ final GeckoHLSSample sample = iter.next();
+ firstPTS = sample.info.presentationTimeUs;
+ }
+ long lastPTS = firstPTS;
+ while (iter.hasNext()) {
+ final GeckoHLSSample sample = iter.next();
+ lastPTS = sample.info.presentationTimeUs;
+ }
+ return Math.abs(lastPTS - firstPTS) > QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD;
+ }
+
+ public Format getFormat(final int index) {
+ assertTrue(index >= 0);
+ final Format fmt = index < mFormats.size() ? mFormats.get(index) : null;
+ if (DEBUG) {
+ Log.d(LOGTAG, "getFormat : index = " + index + ", format : " + fmt);
+ }
+ return fmt;
+ }
+
+ public synchronized long getFirstSamplePTS() {
+ return mFirstSampleStartTime;
+ }
+
+ public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getQueuedSamples(final int number) {
+ final ConcurrentLinkedQueue<GeckoHLSSample> samples =
+ new ConcurrentLinkedQueue<GeckoHLSSample>();
+
+ GeckoHLSSample sample = null;
+ final int queuedSize = mDemuxedInputSamples.size();
+ for (int i = 0; i < queuedSize; i++) {
+ if (i >= number) {
+ break;
+ }
+ sample = mDemuxedInputSamples.poll();
+ samples.offer(sample);
+ }
+
+ sample = samples.isEmpty() ? null : samples.peek();
+ if (sample == null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getQueuedSamples isEmpty, mWaitingForData = true !");
+ }
+ mWaitingForData = true;
+ } else if (mFirstSampleStartTime == Long.MIN_VALUE) {
+ mFirstSampleStartTime = sample.info.presentationTimeUs;
+ if (DEBUG) {
+ Log.d(LOGTAG, "mFirstSampleStartTime = " + mFirstSampleStartTime);
+ }
+ }
+ return samples;
+ }
+
+ protected void handleDrmInitChanged(final Format oldFormat, final Format newFormat) {
+ final Object oldDrmInit = oldFormat == null ? null : oldFormat.drmInitData;
+ final Object newDrnInit = newFormat.drmInitData;
+
+ // TODO: Notify MFR if the content is encrypted or not.
+ if (newDrnInit != oldDrmInit) {
+ if (newDrnInit != null) {
+ } else {
+ }
+ }
+ }
+
+ protected boolean canReconfigure(final Format oldFormat, final Format newFormat) {
+ // Referring to ExoPlayer's MediaCodecBaseRenderer, the default is set
+ // to false. Only override it in video renderer subclass.
+ return false;
+ }
+
+ protected void prepareReconfiguration() {
+ // Referring to ExoPlayer's MediaCodec related renderers, only video
+ // renderer handles this.
+ }
+
+ protected void updateCSDInfo(final Format format) {
+ // do nothing.
+ }
+
+ protected void onInputFormatChanged(final Format newFormat) throws ExoPlaybackException {
+ Format oldFormat;
+ try {
+ oldFormat = mFormats.get(mFormats.size() - 1);
+ } catch (final IndexOutOfBoundsException e) {
+ oldFormat = null;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onInputFormatChanged] old : " + oldFormat + " => new : " + newFormat);
+ }
+ mFormats.add(newFormat);
+ handleDrmInitChanged(oldFormat, newFormat);
+
+ if (mInitialized && canReconfigure(oldFormat, newFormat)) {
+ prepareReconfiguration();
+ } else {
+ resetRenderer();
+ maybeInitRenderer();
+ }
+
+ updateCSDInfo(newFormat);
+ notifyPlayerInputFormatChanged(newFormat);
+ }
+
+ protected void maybeInitRenderer() throws ExoPlaybackException {
+ if (mInitialized || mFormats.size() == 0) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "Initializing ... ");
+ }
+ try {
+ createInputBuffer();
+ mInitialized = true;
+ } catch (final OutOfMemoryError e) {
+ throw ExoPlaybackException.createForRenderer(
+ new RuntimeException(e),
+ getIndex(),
+ mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1),
+ RendererCapabilities.FORMAT_HANDLED);
+ }
+ }
+
+ /*
+ * The place we get demuxed data from HlsMediaSource(ExoPlayer).
+ * The data will then be converted to GeckoHLSSample and deliver to
+ * GeckoHlsDemuxerWrapper for further use.
+ * If the return value is ture, that means a GeckoHLSSample is queued
+ * successfully. We can try to feed more samples into queue.
+ * If the return value is false, that means we might encounter following
+ * situation 1) not initialized 2) input stream is ended 3) queue is full.
+ * 4) format changed. 5) exception happened.
+ */
+ protected synchronized boolean feedInputBuffersQueue() throws ExoPlaybackException {
+ if (!mInitialized || mInputStreamEnded || isQueuedEnoughData()) {
+ // Need to reinitialize the renderer or the input stream has ended
+ // or we just reached the maximum queue size.
+ return false;
+ }
+
+ mBufferForRead.data = mInputBuffer;
+ if (mBufferForRead.data != null) {
+ mBufferForRead.clear();
+ }
+
+ handleReconfiguration(mBufferForRead);
+
+ // Read data from HlsMediaSource
+ int result = C.RESULT_NOTHING_READ;
+ try {
+ result = readSource(mFormatHolder, mBufferForRead, false);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "[feedInput] Exception when readSource :", e);
+ return false;
+ }
+
+ if (result == C.RESULT_NOTHING_READ) {
+ return false;
+ }
+
+ if (result == C.RESULT_FORMAT_READ) {
+ handleFormatRead(mBufferForRead);
+ return true;
+ }
+
+ // We've read a buffer.
+ if (mBufferForRead.isEndOfStream()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Now we're at the End Of Stream.");
+ }
+ handleEndOfStream(mBufferForRead);
+ return false;
+ }
+
+ mBufferForRead.flip();
+
+ handleSamplePreparation(mBufferForRead);
+
+ maybeNotifyDataArrived();
+ return true;
+ }
+
+ private void maybeNotifyDataArrived() {
+ if (mWaitingForData && isQueuedEnoughData()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onDataArrived");
+ }
+ mPlayerEventDispatcher.onDataArrived(getTrackType());
+ mWaitingForData = false;
+ }
+ }
+
+ private void readFormat() throws ExoPlaybackException {
+ mFlagsOnlyBuffer.clear();
+ final int result = readSource(mFormatHolder, mFlagsOnlyBuffer, true);
+ if (result == C.RESULT_FORMAT_READ) {
+ onInputFormatChanged(mFormatHolder.format);
+ }
+ }
+
+ @Override
+ protected void onEnabled(final boolean joining) {
+ // Do nothing.
+ }
+
+ @Override
+ protected void onDisabled() {
+ mFormats.clear();
+ resetRenderer();
+ }
+
+ @Override
+ public boolean isReady() {
+ return mFormats.size() != 0;
+ }
+
+ @Override
+ public boolean isEnded() {
+ return mInputStreamEnded;
+ }
+
+ @Override
+ protected synchronized void onPositionReset(final long positionUs, final boolean joining) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onPositionReset : positionUs = " + positionUs);
+ }
+ mInputStreamEnded = false;
+ if (mInitialized) {
+ clearInputSamplesQueue();
+ }
+ }
+
+ /*
+ * This is called by ExoPlayerImplInternal.java.
+ * ExoPlayer checks the status of renderer, i.e. isReady() / isEnded(), and
+ * calls renderer.render by passing its wall clock time.
+ */
+ @Override
+ public void render(final long positionUs, final long elapsedRealtimeUs)
+ throws ExoPlaybackException {
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(LOGTAG, "positionUs = " + positionUs + ", mInputStreamEnded = " + mInputStreamEnded);
+ }
+ if (mInputStreamEnded) {
+ return;
+ }
+ if (mFormats.size() == 0) {
+ readFormat();
+ }
+
+ maybeInitRenderer();
+ while (feedInputBuffersQueue()) {
+ // Do nothing
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java
new file mode 100644
index 0000000000..f2917ccbcc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java
@@ -0,0 +1,518 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Build;
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+public class GeckoHlsVideoRenderer extends GeckoHlsRendererBase {
+ /*
+ * By configuring these states, initialization data is provided for
+ * ExoPlayer's HlsMediaSource to parse HLS bitstream and then provide samples
+ * starting with an Access Unit Delimiter including SPS/PPS for TS,
+ * and provide samples starting with an AUD without SPS/PPS for FMP4.
+ */
+ private enum RECONFIGURATION_STATE {
+ NONE,
+ WRITE_PENDING,
+ QUEUE_PENDING
+ }
+
+ private boolean mRendererReconfigured;
+ private RECONFIGURATION_STATE mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+
+ // A list of the formats which may be included in the bitstream.
+ private Format[] mStreamFormats;
+ // The max width/height/inputBufferSize for specific codec format.
+ private CodecMaxValues mCodecMaxValues;
+ // A temporary queue for samples whose duration is not calculated yet.
+ private ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedNoDurationSamples =
+ new ConcurrentLinkedQueue<>();
+
+ // Contain CSD-0(SPS)/CSD-1(PPS) information (in AnnexB format) for
+ // prepending each keyframe. When video format changes, this information
+ // changes accordingly.
+ private byte[] mCSDInfo = null;
+
+ public GeckoHlsVideoRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(C.TRACK_TYPE_VIDEO, eventDispatcher);
+ assertTrue(Build.VERSION.SDK_INT >= 16);
+ LOGTAG = getClass().getSimpleName();
+ DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ }
+
+ @Override
+ public final int supportsMixedMimeTypeAdaptation() {
+ return ADAPTIVE_NOT_SEAMLESS;
+ }
+
+ @Override
+ public final int supportsFormat(final Format format) {
+ /*
+ * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering
+ * formats with the same mime type, but
+ * the properties of the format exceed
+ * the renderer's capability.
+ * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose
+ * renderer for formats of the same
+ * top-level type, but is not capable of
+ * rendering the format or any other format
+ * with the same mime type because the
+ * sub-type is not supported.
+ * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering
+ * the format, either because it does not support
+ * the format's top-level type, or because it's
+ * a specialized renderer for a different mime type.
+ * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats,
+ * but may suffer a brief discontinuity (~50-100ms)
+ * when adaptation occurs.
+ * ADAPTIVE_SEAMLESS : The Renderer can seamlessly adapt between formats.
+ */
+ final String mimeType = format.sampleMimeType;
+ if (!MimeTypes.isVideo(mimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+
+ List<MediaCodecInfo> decoderInfos = null;
+ try {
+ final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (decoderInfos == null || decoderInfos.isEmpty()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ }
+
+ boolean decoderCapable = false;
+ MediaCodecInfo info = null;
+ for (final MediaCodecInfo i : decoderInfos) {
+ if (i.isCodecSupported(format)) {
+ decoderCapable = true;
+ info = i;
+ }
+ }
+ if (decoderCapable && format.width > 0 && format.height > 0) {
+ if (Build.VERSION.SDK_INT < 21) {
+ try {
+ decoderCapable =
+ format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize();
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (!decoderCapable) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Check [legacyFrameSize, " + format.width + "x" + format.height + "]");
+ }
+ }
+ } else {
+ decoderCapable =
+ info.isVideoSizeAndRateSupportedV21(format.width, format.height, format.frameRate);
+ }
+ }
+
+ return RendererCapabilities.create(
+ decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES,
+ info != null && info.adaptive ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS,
+ TUNNELING_NOT_SUPPORTED);
+ }
+
+ @Override
+ protected final void createInputBuffer() throws ExoPlaybackException {
+ assertTrue(mFormats.size() > 0);
+ // Calculate maximum size which might be used for target format.
+ final Format currentFormat = mFormats.get(mFormats.size() - 1);
+ mCodecMaxValues = getCodecMaxValues(currentFormat, mStreamFormats);
+ // Create a buffer with maximal size for reading source.
+ // Note : Though we are able to dynamically enlarge buffer size by
+ // creating DecoderInputBuffer with specific BufferReplacementMode, we
+ // still allocate a calculated max size buffer for it at first to reduce
+ // runtime overhead.
+ try {
+ mInputBuffer = ByteBuffer.wrap(new byte[mCodecMaxValues.inputSize]);
+ } catch (final OutOfMemoryError e) {
+ Log.e(LOGTAG, "cannot allocate input buffer of size " + mCodecMaxValues.inputSize, e);
+ throw ExoPlaybackException.createForRenderer(
+ new Exception(e),
+ getIndex(),
+ mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1),
+ RendererCapabilities.FORMAT_HANDLED);
+ }
+ }
+
+ @Override
+ protected void resetRenderer() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[resetRenderer] mInitialized = " + mInitialized);
+ }
+ if (mInitialized) {
+ mRendererReconfigured = false;
+ mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+ mInputBuffer = null;
+ mCSDInfo = null;
+ mInitialized = false;
+ }
+ }
+
+ @Override
+ protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) {
+ // For adaptive reconfiguration OMX decoders expect all reconfiguration
+ // data to be supplied at the start of the buffer that also contains
+ // the first frame in the new format.
+ assertTrue(mFormats.size() > 0);
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.WRITE_PENDING) {
+ if (bufferForRead.data == null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][WRITE_PENDING] bufferForRead.data is not initialized.");
+ }
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][WRITE_PENDING] put initialization data");
+ }
+ final Format currentFormat = mFormats.get(mFormats.size() - 1);
+ for (int i = 0; i < currentFormat.initializationData.size(); i++) {
+ final byte[] data = currentFormat.initializationData.get(i);
+ bufferForRead.data.put(data);
+ }
+ mRendererReconfigurationState = RECONFIGURATION_STATE.QUEUE_PENDING;
+ }
+ }
+
+ @Override
+ protected void handleFormatRead(final DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException {
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] 2 formats in a row.");
+ }
+ // We received two formats in a row. Clear the current buffer of any reconfiguration data
+ // associated with the first format.
+ bufferForRead.clear();
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ onInputFormatChanged(mFormatHolder.format);
+ }
+
+ @Override
+ protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) {
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] isEndOfStream.");
+ }
+ // We received a new format immediately before the end of the stream. We need to clear
+ // the corresponding reconfiguration data from the current buffer, but re-write it into
+ // a subsequent buffer if there are any (e.g. if the user seeks backwards).
+ bufferForRead.clear();
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ mInputStreamEnded = true;
+ final GeckoHLSSample sample = GeckoHLSSample.EOS;
+ calculatDuration(sample);
+ }
+
+ @Override
+ protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) {
+ final int csdInfoSize = mCSDInfo != null ? mCSDInfo.length : 0;
+ final int dataSize = bufferForRead.data.limit();
+ final int size = bufferForRead.isKeyFrame() ? csdInfoSize + dataSize : dataSize;
+ final byte[] realData = new byte[size];
+ if (bufferForRead.isKeyFrame()) {
+ // Prepend the CSD information to the sample if it's a key frame.
+ System.arraycopy(mCSDInfo, 0, realData, 0, csdInfoSize);
+ bufferForRead.data.get(realData, csdInfoSize, dataSize);
+ } else {
+ bufferForRead.data.get(realData, 0, dataSize);
+ }
+ final ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ final CryptoInfo cryptoInfo =
+ bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ final BufferInfo bufferInfo = new BufferInfo();
+ // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags.
+ int flags = 0;
+ flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
+ flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0;
+ bufferInfo.set(0, size, bufferForRead.timeUs, flags);
+
+ assertTrue(mFormats.size() > 0);
+ // We add a new format in the list once format changes, so the formatIndex
+ // should indicate to the last(latest) format.
+ final GeckoHLSSample sample =
+ GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1);
+
+ // There's no duration information from the ExoPlayer's sample, we need
+ // to calculate it.
+ calculatDuration(sample);
+ mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+ }
+
+ @Override
+ protected void onPositionReset(final long positionUs, final boolean joining) {
+ super.onPositionReset(positionUs, joining);
+ if (mInitialized && mRendererReconfigured && mFormats.size() != 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onPositionReset] WRITE_PENDING");
+ }
+ // Any reconfiguration data that we put shortly before the reset
+ // may be invalid. We avoid this issue by sending reconfiguration
+ // data following every position reset.
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ }
+
+ @Override
+ protected boolean clearInputSamplesQueue() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "clearInputSamplesQueue");
+ }
+ mDemuxedInputSamples.clear();
+ mDemuxedNoDurationSamples.clear();
+ return true;
+ }
+
+ @Override
+ protected boolean canReconfigure(final Format oldFormat, final Format newFormat) {
+ final boolean canReconfig =
+ areAdaptationCompatible(oldFormat, newFormat)
+ && newFormat.width <= mCodecMaxValues.width
+ && newFormat.height <= mCodecMaxValues.height
+ && newFormat.maxInputSize <= mCodecMaxValues.inputSize;
+ if (DEBUG) {
+ Log.d(LOGTAG, "[canReconfigure] : " + canReconfig);
+ }
+ return canReconfig;
+ }
+
+ @Override
+ protected void prepareReconfiguration() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onInputFormatChanged] starting reconfiguration !");
+ }
+ mRendererReconfigured = true;
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+
+ @Override
+ protected void updateCSDInfo(final Format format) {
+ int size = 0;
+ for (int i = 0; i < format.initializationData.size(); i++) {
+ size += format.initializationData.get(i).length;
+ }
+ int startPos = 0;
+ mCSDInfo = new byte[size];
+ for (int i = 0; i < format.initializationData.size(); i++) {
+ final byte[] data = format.initializationData.get(i);
+ System.arraycopy(data, 0, mCSDInfo, startPos, data.length);
+ startPos += data.length;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "mCSDInfo [" + Utils.bytesToHex(mCSDInfo) + "]");
+ }
+ }
+
+ @Override
+ protected void notifyPlayerInputFormatChanged(final Format newFormat) {
+ mPlayerEventDispatcher.onVideoInputFormatChanged(newFormat);
+ }
+
+ private void calculateSamplesWithin(final GeckoHLSSample[] samples, final int range) {
+ // Calculate the first 'range' elements.
+ for (int i = 0; i < range; i++) {
+ // Comparing among samples in the window.
+ for (int j = -2; j < 14; j++) {
+ if (i + j >= 0
+ && i + j < range
+ && samples[i + j].info.presentationTimeUs > samples[i].info.presentationTimeUs) {
+ samples[i].duration =
+ Math.min(
+ samples[i].duration,
+ samples[i + j].info.presentationTimeUs - samples[i].info.presentationTimeUs);
+ }
+ }
+ }
+ }
+
+ private void calculatDuration(final GeckoHLSSample inputSample) {
+ /*
+ * NOTE :
+ * Since we customized renderer as a demuxer. Here we're not able to
+ * obtain duration from the DecoderInputBuffer as there's no duration inside.
+ * So we calcualte it by referring to nearby samples' timestamp.
+ * A temporary queue |mDemuxedNoDurationSamples| is used to queue demuxed
+ * samples from HlsMediaSource which have no duration information at first.
+ * We're choosing 16 as the comparing window size, because it's commonly
+ * used as a GOP size.
+ * Considering there're 16 demuxed samples in the _no duration_ queue already,
+ * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|
+ * Once a new demuxed(No duration) sample X (17th) is put into the
+ * temporary queue,
+ * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|X|
+ * we are able to calculate the correct duration for sample 0 by finding
+ * the closest but greater pts than sample 0 among these 16 samples,
+ * here, let's say sample -2 to 13.
+ */
+ if (inputSample != null) {
+ mDemuxedNoDurationSamples.offer(inputSample);
+ }
+ final int sizeOfNoDura = mDemuxedNoDurationSamples.size();
+ // A calculation window we've ever found suitable for both HLS TS & FMP4.
+ final int range = sizeOfNoDura >= 17 ? 17 : sizeOfNoDura;
+ final GeckoHLSSample[] inputArray =
+ mDemuxedNoDurationSamples.toArray(new GeckoHLSSample[sizeOfNoDura]);
+ if (range >= 17 && !mInputStreamEnded) {
+ calculateSamplesWithin(inputArray, range);
+
+ final GeckoHLSSample toQueue = mDemuxedNoDurationSamples.poll();
+ mDemuxedInputSamples.offer(toQueue);
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(
+ LOGTAG,
+ "Demuxed sample PTS : "
+ + toQueue.info.presentationTimeUs
+ + ", duration :"
+ + toQueue.duration
+ + ", isKeyFrame("
+ + toQueue.isKeyFrame()
+ + ", formatIndex("
+ + toQueue.formatIndex
+ + "), queue size : "
+ + mDemuxedInputSamples.size()
+ + ", NoDuQueue size : "
+ + mDemuxedNoDurationSamples.size());
+ }
+ } else if (mInputStreamEnded) {
+ calculateSamplesWithin(inputArray, sizeOfNoDura);
+
+ // NOTE : We're not able to calculate the duration for the last sample.
+ // A workaround here is to assign a close duration to it.
+ long prevDuration = 33333;
+ GeckoHLSSample sample = null;
+ for (sample = mDemuxedNoDurationSamples.poll();
+ sample != null;
+ sample = mDemuxedNoDurationSamples.poll()) {
+ if (sample.duration == Long.MAX_VALUE) {
+ sample.duration = prevDuration;
+ if (DEBUG) {
+ Log.d(LOGTAG, "Adjust the PTS of the last sample to " + sample.duration + " (us)");
+ }
+ }
+ prevDuration = sample.duration;
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "last loop to offer samples - PTS : "
+ + sample.info.presentationTimeUs
+ + ", Duration : "
+ + sample.duration
+ + ", isEOS : "
+ + sample.isEOS());
+ }
+ mDemuxedInputSamples.offer(sample);
+ }
+ }
+ }
+
+ // Return the time of first keyframe sample in the queue.
+ // If there's no key frame in the queue, return the MAX_VALUE so
+ // MFR won't mistake for that which the decode is getting slow.
+ public long getNextKeyFrameTime() {
+ long nextKeyFrameTime = Long.MAX_VALUE;
+ for (final GeckoHLSSample sample : mDemuxedInputSamples) {
+ if (sample != null && (sample.info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ nextKeyFrameTime = sample.info.presentationTimeUs;
+ break;
+ }
+ }
+ return nextKeyFrameTime;
+ }
+
+ @Override
+ protected void onStreamChanged(final Format[] formats, final long offsetUs) {
+ mStreamFormats = formats;
+ }
+
+ private static CodecMaxValues getCodecMaxValues(
+ final Format format, final Format[] streamFormats) {
+ int maxWidth = format.width;
+ int maxHeight = format.height;
+ int maxInputSize = getMaxInputSize(format);
+ for (final Format streamFormat : streamFormats) {
+ if (areAdaptationCompatible(format, streamFormat)) {
+ maxWidth = Math.max(maxWidth, streamFormat.width);
+ maxHeight = Math.max(maxHeight, streamFormat.height);
+ maxInputSize = Math.max(maxInputSize, getMaxInputSize(streamFormat));
+ }
+ }
+ return new CodecMaxValues(maxWidth, maxHeight, maxInputSize);
+ }
+
+ private static int getMaxInputSize(final Format format) {
+ if (format.maxInputSize != Format.NO_VALUE) {
+ // The format defines an explicit maximum input size.
+ return format.maxInputSize;
+ }
+
+ if (format.width == Format.NO_VALUE || format.height == Format.NO_VALUE) {
+ // We can't infer a maximum input size without video dimensions.
+ return Format.NO_VALUE;
+ }
+
+ // Attempt to infer a maximum input size from the format.
+ final int maxPixels;
+ final int minCompressionRatio;
+ switch (format.sampleMimeType) {
+ case MimeTypes.VIDEO_H264:
+ // Round up width/height to an integer number of macroblocks.
+ maxPixels = ((format.width + 15) / 16) * ((format.height + 15) / 16) * 16 * 16;
+ minCompressionRatio = 2;
+ break;
+ default:
+ // Leave the default max input size.
+ return Format.NO_VALUE;
+ }
+ // Estimate the maximum input size assuming three channel 4:2:0 subsampled input frames.
+ return (maxPixels * 3) / (2 * minCompressionRatio);
+ }
+
+ private static boolean areAdaptationCompatible(final Format first, final Format second) {
+ return first.sampleMimeType.equals(second.sampleMimeType)
+ && getRotationDegrees(first) == getRotationDegrees(second);
+ }
+
+ private static int getRotationDegrees(final Format format) {
+ return format.rotationDegrees == Format.NO_VALUE ? 0 : format.rotationDegrees;
+ }
+
+ private static final class CodecMaxValues {
+ public final int width;
+ public final int height;
+ public final int inputSize;
+
+ public CodecMaxValues(final int width, final int height, final int inputSize) {
+ this.width = width;
+ this.height = height;
+ this.inputSize = inputSize;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java
new file mode 100644
index 0000000000..543f85da68
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java
@@ -0,0 +1,39 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+
+public interface GeckoMediaDrm {
+ public interface Callbacks {
+ void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request);
+
+ void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ void onSessionClosed(int promiseId, byte[] sessionId);
+
+ void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request);
+
+ void onSessionError(byte[] sessionId, String message);
+
+ void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos);
+ // All failure cases should go through this function.
+ void onRejectPromise(int promiseId, String message);
+ }
+
+ void setCallbacks(Callbacks callbacks);
+
+ void createSession(int createSessionToken, int promiseId, String initDataType, byte[] initData);
+
+ void updateSession(int promiseId, String sessionId, byte[] response);
+
+ void closeSession(int promiseId, String sessionId);
+
+ void release();
+
+ MediaCrypto getMediaCrypto();
+
+ void setServerCertificate(final byte[] cert);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java
new file mode 100644
index 0000000000..e5380bbb5c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java
@@ -0,0 +1,771 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.media.DeniedByServerException;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.media.NotProvisionedException;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.util.Log;
+import androidx.annotation.RequiresApi;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.UUID;
+import org.mozilla.gecko.util.ProxySelector;
+
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+public class GeckoMediaDrmBridgeV21 implements GeckoMediaDrm {
+ protected final String LOGTAG;
+ private static final String INVALID_SESSION_ID = "Invalid";
+ private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha";
+ private static final boolean DEBUG = false;
+ private static final UUID WIDEVINE_SCHEME_UUID =
+ new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL);
+ private static final int MAX_PROMISE_ID = Integer.MAX_VALUE;
+ // MediaDrm.KeyStatus information listener is supported on M+, adding a
+ // dummy key id to report key status.
+ private static final byte[] DUMMY_KEY_ID = new byte[] {0};
+
+ public static final Charset UTF_8 = Charset.forName("UTF-8");
+
+ private UUID mSchemeUUID;
+ private Handler mHandler;
+ PostRequestTask mProvisionTask;
+ private HandlerThread mHandlerThread;
+ private ByteBuffer mCryptoSessionId;
+
+ // mProvisioningPromiseId is great than 0 only during provisioning.
+ private int mProvisioningPromiseId;
+ private HashSet<ByteBuffer> mSessionIds;
+ private HashMap<ByteBuffer, String> mSessionMIMETypes;
+ private ArrayDeque<PendingCreateSessionData> mPendingCreateSessionDataQueue;
+ private PendingKeyRequest mPendingKeyRequest;
+ private GeckoMediaDrm.Callbacks mCallbacks;
+
+ private MediaCrypto mCrypto;
+ protected MediaDrm mDrm;
+
+ public static final int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/
+ public static final int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/
+ public static final int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/
+
+ // Store session data while provisioning
+ private static class PendingCreateSessionData {
+ public final int mToken;
+ public final int mPromiseId;
+ public final byte[] mInitData;
+ public final String mMimeType;
+
+ private PendingCreateSessionData(
+ final int token, final int promiseId, final byte[] initData, final String mimeType) {
+ mToken = token;
+ mPromiseId = promiseId;
+ mInitData = initData;
+ mMimeType = mimeType;
+ }
+ }
+
+ private static class PendingKeyRequest {
+ public final ByteBuffer mSession;
+ public final byte[] mData;
+ public final String mMimeType;
+
+ private PendingKeyRequest(final ByteBuffer session, final byte[] data, final String mimeType) {
+ mSession = session;
+ mData = data;
+ mMimeType = mimeType;
+ }
+ }
+
+ public boolean isSecureDecoderComonentRequired(final String mimeType) {
+ if (mCrypto != null) {
+ return mCrypto.requiresSecureDecoderComponent(mimeType);
+ }
+ return false;
+ }
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ @SuppressLint("WrongConstant")
+ private void configureVendorSpecificProperty() {
+ assertTrue(mDrm != null);
+ if (mDrm == null) {
+ return;
+ }
+ // Support L3 for now
+ mDrm.setPropertyString("securityLevel", "L3");
+ // Refer to chromium, set multi-session mode for Widevine.
+ if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) {
+ mDrm.setPropertyString("privacyMode", "enable");
+ mDrm.setPropertyString("sessionSharing", "enable");
+ }
+ }
+
+ GeckoMediaDrmBridgeV21(final String keySystem) throws Exception {
+ LOGTAG = getClass().getSimpleName();
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21 ctor");
+
+ mProvisioningPromiseId = 0;
+ mSessionIds = new HashSet<ByteBuffer>();
+ mSessionMIMETypes = new HashMap<ByteBuffer, String>();
+ mPendingCreateSessionDataQueue = new ArrayDeque<PendingCreateSessionData>();
+
+ mSchemeUUID = convertKeySystemToSchemeUUID(keySystem);
+ mCryptoSessionId = null;
+
+ if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString());
+
+ // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions
+ // threw by the following steps.
+ mDrm = new MediaDrm(mSchemeUUID);
+ configureVendorSpecificProperty();
+ mDrm.setOnEventListener(new MediaDrmListener());
+ try {
+ // ensureMediaCryptoCreated may cause NotProvisionedException for the first time use.
+ // Need to start provisioning with a dummy promise id.
+ ensureMediaCryptoCreated();
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage());
+ startProvisioning(MAX_PROMISE_ID);
+ }
+ }
+
+ @Override
+ public void setCallbacks(final GeckoMediaDrm.Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ if (mProvisioningPromiseId > 0 && mCrypto == null) {
+ if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !");
+ savePendingCreateSessionData(
+ createSessionToken, promiseId,
+ initData, initDataType);
+ return;
+ }
+
+ ByteBuffer sessionId = null;
+ try {
+ final boolean hasMediaCrypto = ensureMediaCryptoCreated();
+ if (!hasMediaCrypto) {
+ onRejectPromise(promiseId, "MediaCrypto intance is not created !");
+ return;
+ }
+
+ sessionId = openSession();
+ if (sessionId == null) {
+ onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !");
+ return;
+ }
+
+ final MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType);
+ if (request == null) {
+ mDrm.closeSession(sessionId.array());
+ onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !");
+ return;
+ }
+ onSessionCreated(createSessionToken, promiseId, sessionId.array(), request.getData());
+ onSessionMessage(sessionId.array(), LICENSE_REQUEST_INITIAL, request.getData());
+ mSessionMIMETypes.put(sessionId, initDataType);
+ mSessionIds.add(sessionId);
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ " StringID : " + new String(sessionId.array(), UTF_8) + " is put into mSessionIds ");
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage());
+ if (sessionId != null) {
+ // The promise of this createSession will be either resolved
+ // or rejected after provisioning.
+ mDrm.closeSession(sessionId.array());
+ }
+ savePendingCreateSessionData(
+ createSessionToken, promiseId,
+ initData, initDataType);
+ startProvisioning(promiseId);
+ }
+ }
+
+ @Override
+ public void updateSession(final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId);
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8));
+ if (!sessionExists(session)) {
+ onRejectPromise(promiseId, "Invalid session during updateSession.");
+ return;
+ }
+
+ try {
+ final byte[] keySetId = mDrm.provideKeyResponse(session.array(), response);
+ if (DEBUG) {
+ final HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array());
+ for (final String strKey : infoMap.keySet()) {
+ final String strValue = infoMap.get(strKey);
+ Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")");
+ }
+ }
+ HandleKeyStatusChangeByDummyKey(sessionId);
+ onSessionUpdated(promiseId, session.array());
+ return;
+ } catch (final NotProvisionedException | DeniedByServerException | IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:", e);
+ onSessionError(session.array(), "Got exception during updateSession.");
+ onRejectPromise(promiseId, "Got exception during updateSession.");
+ }
+ release();
+ return;
+ }
+
+ @Override
+ public void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8));
+ mSessionIds.remove(session);
+ mDrm.closeSession(session.array());
+ onSessionClosed(promiseId, session.array());
+ }
+
+ @Override
+ public void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ if (mProvisionTask != null) {
+ mProvisionTask.cancel(true);
+ mProvisionTask = null;
+ }
+ if (mProvisioningPromiseId > 0) {
+ onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session.");
+ mProvisioningPromiseId = 0;
+ }
+ if (mPendingKeyRequest != null) {
+ mPendingKeyRequest = null;
+ }
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (pendingData != null) {
+ onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions.");
+ }
+ }
+ mPendingCreateSessionDataQueue = null;
+
+ if (mDrm != null) {
+ for (final ByteBuffer session : mSessionIds) {
+ mDrm.closeSession(session.array());
+ }
+ mDrm.release();
+ mDrm = null;
+ }
+ mSessionIds.clear();
+ mSessionIds = null;
+ mSessionMIMETypes.clear();
+ mSessionMIMETypes = null;
+
+ mCryptoSessionId = null;
+ if (mCrypto != null) {
+ mCrypto.release();
+ mCrypto = null;
+ }
+ if (mHandlerThread != null) {
+ mHandlerThread.quitSafely();
+ mHandlerThread = null;
+ }
+ mHandler = null;
+ }
+
+ @Override
+ public MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+ return mCrypto;
+ }
+
+ @SuppressLint("WrongConstant")
+ @Override
+ public void setServerCertificate(final byte[] cert) {
+ if (DEBUG) Log.d(LOGTAG, "setServerCertificate()");
+ if (mDrm == null) {
+ throw new IllegalStateException("MediaDrm instance doesn't exist !!");
+ }
+ mDrm.setPropertyByteArray("serviceCertificate", cert);
+ return;
+ }
+
+ protected void HandleKeyStatusChangeByDummyKey(final String sessionId) {
+ final SessionKeyInfo[] keyInfos = new SessionKeyInfo[1];
+ keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID, MediaDrm.KeyStatus.STATUS_USABLE);
+ onSessionBatchedKeyChanged(sessionId.getBytes(), keyInfos);
+ if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId);
+ }
+
+ protected void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+ }
+
+ protected void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+ }
+
+ protected void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+ }
+
+ protected void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+ }
+
+ protected void onSessionError(final byte[] sessionId, final String message) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionError(sessionId, message);
+ }
+ }
+
+ protected void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+
+ protected void onRejectPromise(final int promiseId, final String message) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onRejectPromise(promiseId, message);
+ }
+ }
+
+ private MediaDrm.KeyRequest getKeyRequest(
+ final ByteBuffer aSession, final byte[] data, final String mimeType)
+ throws android.media.NotProvisionedException {
+ if (mProvisioningPromiseId > 0) {
+ if (DEBUG) Log.d(LOGTAG, "Now provisioning");
+ return null;
+ }
+
+ try {
+ final HashMap<String, String> optionalParameters = new HashMap<String, String>();
+ return mDrm.getKeyRequest(
+ aSession.array(), data, mimeType, MediaDrm.KEY_TYPE_STREAMING, optionalParameters);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e);
+ }
+ return null;
+ }
+
+ private class MediaDrmListener implements MediaDrm.OnEventListener {
+ @Override
+ public void onEvent(
+ final MediaDrm mediaDrm,
+ final byte[] sessionArray,
+ final int event,
+ final int extra,
+ final byte[] data) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()");
+ if (sessionArray == null) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session.");
+ return;
+ }
+ final ByteBuffer session = ByteBuffer.wrap(sessionArray);
+ if (!sessionExists(session)) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session.");
+ return;
+ }
+ // On L, these events are treated as exceptions and handled correspondingly.
+ // Leaving this code block for logging message.
+ switch (event) {
+ case MediaDrm.EVENT_PROVISION_REQUIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED");
+ break;
+ case MediaDrm.EVENT_KEY_REQUIRED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_KEY_REQUIRED, sessionId=" + new String(session.array(), UTF_8));
+ final String mimeType = mSessionMIMETypes.get(session);
+ MediaDrm.KeyRequest request = null;
+ try {
+ request = getKeyRequest(session, data, mimeType);
+ } catch (final android.media.NotProvisionedException e) {
+ Log.w(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED, Device not provisioned.", e);
+ startProvisioning(MAX_PROMISE_ID);
+ mPendingKeyRequest = new PendingKeyRequest(session, data, mimeType);
+ return;
+ }
+ requestLicense(sessionArray, request);
+ break;
+ case MediaDrm.EVENT_KEY_EXPIRED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + new String(session.array(), UTF_8));
+ break;
+ case MediaDrm.EVENT_VENDOR_DEFINED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + new String(session.array(), UTF_8));
+ break;
+ case MediaDrm.EVENT_SESSION_RECLAIMED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_SESSION_RECLAIMED, sessionId="
+ + new String(session.array(), UTF_8));
+ break;
+ default:
+ if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event);
+ return;
+ }
+ }
+ }
+
+ private ByteBuffer openSession() throws android.media.NotProvisionedException {
+ try {
+ final byte[] sessionId = mDrm.openSession();
+ // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in
+ // case the underlying byte[] is modified.
+ return ByteBuffer.wrap(sessionId.clone());
+ } catch (final android.media.NotProvisionedException e) {
+ // Throw NotProvisionedException so that we can startProvisioning().
+ throw e;
+ } catch (final java.lang.RuntimeException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage());
+ release();
+ return null;
+ } catch (final android.media.MediaDrmException e) {
+ // Other MediaDrmExceptions (e.g. ResourceBusyException) are not
+ // recoverable.
+ release();
+ return null;
+ }
+ }
+
+ protected boolean sessionExists(final ByteBuffer session) {
+ if (mCryptoSessionId == null) {
+ if (DEBUG)
+ Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created.");
+ return false;
+ }
+ if (session == null) {
+ if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !");
+ return false;
+ }
+ return !session.equals(mCryptoSessionId) && mSessionIds.contains(session);
+ }
+
+ private class PostRequestTask extends AsyncTask<Void, Void, Void> {
+ private static final String LOGTAG = "PostRequestTask";
+
+ private int mPromiseId;
+ private String mURL;
+ private byte[] mDrmRequest;
+ private byte[] mResponseBody;
+
+ PostRequestTask(final int promiseId, final String url, final byte[] drmRequest) {
+ this.mPromiseId = promiseId;
+ this.mURL = url;
+ this.mDrmRequest = drmRequest;
+ }
+
+ @Override
+ protected Void doInBackground(final Void... params) {
+ HttpURLConnection urlConnection = null;
+ BufferedReader in = null;
+ try {
+ final URI finalURI =
+ new URI(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8"));
+ urlConnection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(finalURI);
+ urlConnection.setRequestMethod("POST");
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURI.toString());
+
+ // Add data
+ urlConnection.setRequestProperty("Accept", "*/*");
+ urlConnection.setRequestProperty("User-Agent", getCDMUserAgent());
+ urlConnection.setRequestProperty("Content-Type", "application/json");
+
+ // Execute HTTP Post Request
+ urlConnection.connect();
+
+ final int responseCode = urlConnection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), UTF_8));
+ String inputLine;
+ final StringBuffer response = new StringBuffer();
+
+ while ((inputLine = in.readLine()) != null) {
+ response.append(inputLine);
+ }
+ in.close();
+ mResponseBody = String.valueOf(response).getBytes(UTF_8);
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, response received.");
+ if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length);
+ } else {
+ Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode);
+ }
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Got exception during posting provisioning request ...", e);
+ } catch (final URISyntaxException e) {
+ Log.e(LOGTAG, "Got exception during creating uri ...", e);
+ } finally {
+ if (urlConnection != null) {
+ urlConnection.disconnect();
+ }
+ try {
+ if (in != null) {
+ in.close();
+ }
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Exception during closing in ...", e);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void v) {
+ onProvisionResponse(mPromiseId, mResponseBody);
+ }
+ }
+
+ private boolean provideProvisionResponse(final byte[] response) {
+ if (response == null || response.length == 0) {
+ if (DEBUG) Log.d(LOGTAG, "Invalid provision response.");
+ return false;
+ }
+
+ try {
+ mDrm.provideProvisionResponse(response);
+ return true;
+ } catch (final android.media.DeniedByServerException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ } catch (final java.lang.IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ }
+ return false;
+ }
+
+ private void savePendingCreateSessionData(
+ final int token, final int promiseId, final byte[] initData, final String mime) {
+ if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId);
+ mPendingCreateSessionDataQueue.offer(
+ new PendingCreateSessionData(token, promiseId, initData, mime));
+ }
+
+ private void processPendingCreateSessionData() {
+ if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... ");
+
+ assertTrue(mProvisioningPromiseId == 0);
+ try {
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (pendingData == null) {
+ return;
+ }
+ if (DEBUG)
+ Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId);
+
+ createSession(
+ pendingData.mToken,
+ pendingData.mPromiseId,
+ pendingData.mMimeType,
+ pendingData.mInitData);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e);
+ }
+ }
+
+ private void resumePendingOperations() {
+ if (mHandlerThread == null) {
+ mHandlerThread = new HandlerThread("PendingSessionOpsThread");
+ mHandlerThread.start();
+ }
+ if (mHandler == null) {
+ mHandler = new Handler(mHandlerThread.getLooper());
+ }
+ mHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mPendingKeyRequest != null) {
+ MediaDrm.KeyRequest request = null;
+ try {
+ request =
+ getKeyRequest(
+ mPendingKeyRequest.mSession,
+ mPendingKeyRequest.mData,
+ mPendingKeyRequest.mMimeType);
+ } catch (final NotProvisionedException e) {
+ Log.e(LOGTAG, "Cannot get key request after provisioning!");
+ return;
+ } finally {
+ mPendingKeyRequest = null;
+ }
+ requestLicense(mPendingKeyRequest.mSession.array(), request);
+ } else {
+ processPendingCreateSessionData();
+ }
+ }
+ });
+ }
+
+ private void requestLicense(final byte[] session, final MediaDrm.KeyRequest request) {
+ if (request == null) {
+ Log.e(LOGTAG, "null key request when requesting license");
+ return;
+ }
+ // The EME spec says the messageType is only for optimization and optional.
+ // Send 'License_request' as default when it's not available.
+ int requestType = LICENSE_REQUEST_INITIAL;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ requestType = request.getRequestType();
+ }
+ onSessionMessage(session, requestType, request.getData());
+ }
+
+ // Only triggered when failed on {openSession, getKeyRequest}
+ private void startProvisioning(final int promiseId) {
+ if (DEBUG) Log.d(LOGTAG, "startProvisioning()");
+ if (mProvisioningPromiseId > 0) {
+ // Already in provisioning.
+ return;
+ }
+ try {
+ mProvisioningPromiseId = promiseId;
+ final MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest();
+ mProvisionTask = new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData());
+ mProvisionTask.execute();
+ } catch (final Exception e) {
+ onRejectPromise(promiseId, "Exception happened in startProvisioning !");
+ mProvisioningPromiseId = 0;
+ }
+ }
+
+ private void onProvisionResponse(final int promiseId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()");
+ mProvisionTask = null;
+ mProvisioningPromiseId = 0;
+ final boolean success = provideProvisionResponse(response);
+ if (success) {
+ // Promise will either be resovled / rejected in createSession during
+ // resuming operations.
+ resumePendingOperations();
+ } else {
+ onRejectPromise(promiseId, "Failed to provide provision response.");
+ }
+ }
+
+ private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException {
+ if (mCrypto != null) {
+ return true;
+ }
+ try {
+ mCryptoSessionId = openSession();
+ if (mCryptoSessionId == null) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto");
+ return false;
+ }
+
+ if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) {
+ final byte[] cryptoSessionId = mCryptoSessionId.array();
+ mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId);
+ mSessionIds.add(mCryptoSessionId);
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaCrypto successfully created! - SId "
+ + INVALID_SESSION_ID
+ + ", "
+ + new String(cryptoSessionId, UTF_8));
+ return true;
+ } else {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme.");
+ return false;
+ }
+ } catch (final android.media.MediaCryptoException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage());
+ release();
+ return false;
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG)
+ Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage());
+ throw e;
+ }
+ }
+
+ private UUID convertKeySystemToSchemeUUID(final String keySystem) {
+ if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) {
+ return WIDEVINE_SCHEME_UUID;
+ }
+ if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem);
+ return new UUID(0L, 0L);
+ }
+
+ private String getCDMUserAgent() {
+ // This user agent is found and hard-coded in Android(L) source code and
+ // Chromium project. Not sure if it's gonna change in the future.
+ final String ua = "Widevine CDM v1.0";
+ return ua;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
new file mode 100644
index 0000000000..bee2635a81
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
@@ -0,0 +1,50 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import static android.os.Build.VERSION_CODES.M;
+
+import android.annotation.TargetApi;
+import android.media.MediaDrm;
+import android.util.Log;
+import java.util.List;
+
+@TargetApi(M)
+public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 {
+ private static final boolean DEBUG = false;
+
+ GeckoMediaDrmBridgeV23(final String keySystem) throws Exception {
+ super(keySystem);
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor");
+ mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null);
+ }
+
+ private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener {
+ @Override
+ public void onKeyStatusChange(
+ final MediaDrm mediaDrm,
+ final byte[] sessionId,
+ final List<MediaDrm.KeyStatus> keyInformation,
+ final boolean hasNewUsableKey) {
+ if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey);
+ if (keyInformation.size() == 0) {
+ return;
+ }
+ final SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()];
+ for (int i = 0; i < keyInformation.size(); i++) {
+ final MediaDrm.KeyStatus keyStatus = keyInformation.get(i);
+ keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(), keyStatus.getStatusCode());
+ }
+ onSessionBatchedKeyChanged(sessionId, keyInfos);
+ if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + new String(sessionId));
+ }
+ }
+
+ @Override
+ protected void HandleKeyStatusChangeByDummyKey(final String sessionId) {
+ // MediaDrm.KeyStatus information listener is supported on M+, there is no need to use
+ // dummy key id to report key status anymore.
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java
new file mode 100644
index 0000000000..47278115d3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java
@@ -0,0 +1,43 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import java.util.ArrayList;
+
+public final class GeckoPlayerFactory {
+ public static final ArrayList<BaseHlsPlayer> sPlayerList = new ArrayList<BaseHlsPlayer>();
+
+ static synchronized BaseHlsPlayer getPlayer() {
+ try {
+ final Class<?> cls = Class.forName("org.mozilla.gecko.media.GeckoHlsPlayer");
+ final BaseHlsPlayer player = (BaseHlsPlayer) cls.newInstance();
+ sPlayerList.add(player);
+ return player;
+ } catch (final Exception e) {
+ Log.e("GeckoPlayerFactory", "Class GeckoHlsPlayer not found or failed to create", e);
+ }
+ return null;
+ }
+
+ static synchronized BaseHlsPlayer getPlayer(final int id) {
+ for (final BaseHlsPlayer player : sPlayerList) {
+ if (player.getId() == id) {
+ return player;
+ }
+ }
+ Log.w("GeckoPlayerFactory", "No player found with id : " + id);
+ return null;
+ }
+
+ static synchronized void removePlayer(final @NonNull BaseHlsPlayer player) {
+ final int index = sPlayerList.indexOf(player);
+ if (index >= 0) {
+ sPlayerList.remove(player);
+ Log.d("GeckoPlayerFactory", "HlsPlayer with id(" + player.getId() + ") is removed.");
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java
new file mode 100644
index 0000000000..c641c58354
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java
@@ -0,0 +1,45 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// A subset of the class VideoInfo in dom/media/MediaInfo.h
+@WrapForJNI
+public final class GeckoVideoInfo {
+ public final byte[] codecSpecificData;
+ public final byte[] extraData;
+ public final int displayWidth;
+ public final int displayHeight;
+ public final int pictureWidth;
+ public final int pictureHeight;
+ public final int rotation;
+ public final int stereoMode;
+ public final long duration;
+ public final String mimeType;
+
+ public GeckoVideoInfo(
+ final int displayWidth,
+ final int displayHeight,
+ final int pictureWidth,
+ final int pictureHeight,
+ final int rotation,
+ final int stereoMode,
+ final long duration,
+ final String mimeType,
+ final byte[] extraData,
+ final byte[] codecSpecificData) {
+ this.displayWidth = displayWidth;
+ this.displayHeight = displayHeight;
+ this.pictureWidth = pictureWidth;
+ this.pictureHeight = pictureHeight;
+ this.rotation = rotation;
+ this.stereoMode = stereoMode;
+ this.duration = duration;
+ this.mimeType = mimeType;
+ this.extraData = extraData;
+ this.codecSpecificData = codecSpecificData;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
new file mode 100644
index 0000000000..7c5102c63d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
@@ -0,0 +1,490 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+import android.view.Surface;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+
+// Implement async API using MediaCodec sync mode (API v16).
+// This class uses internal worker thread/handler (mBufferPoller) to poll
+// input and output buffer and notifies the client through callbacks.
+final class JellyBeanAsyncCodec implements AsyncCodec {
+ private static final String LOGTAG = "GeckoAsyncCodecAPIv16";
+ private static final boolean DEBUG = false;
+
+ private static final int ERROR_CODEC = -10000;
+
+ private abstract class CancelableHandler extends Handler {
+ private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL'
+
+ protected CancelableHandler(final Looper looper) {
+ super(looper);
+ }
+
+ protected void cancel() {
+ removeCallbacksAndMessages(null);
+ sendEmptyMessage(MSG_CANCELLATION);
+ // Wait until handleMessageLocked() is done.
+ synchronized (this) {
+ }
+ }
+
+ protected boolean isCanceled() {
+ return hasMessages(MSG_CANCELLATION);
+ }
+
+ // Subclass should implement this and return true if it handles msg.
+ // Warning: Never, ever call super.handleMessage() in this method!
+ protected abstract boolean handleMessageLocked(Message msg);
+
+ public final void handleMessage(final Message msg) {
+ // Block cancel() during handleMessageLocked().
+ synchronized (this) {
+ if (isCanceled() || handleMessageLocked(msg)) {
+ return;
+ }
+ }
+
+ switch (msg.what) {
+ case MSG_CANCELLATION:
+ // Just a marker. Nothing to do here.
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this);
+ }
+ break;
+ default:
+ super.handleMessage(msg);
+ break;
+ }
+ }
+ }
+
+ // A handler to invoke AsyncCodec.Callbacks methods.
+ private final class CallbackSender extends CancelableHandler {
+ private static final int MSG_INPUT_BUFFER_AVAILABLE = 1;
+ private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2;
+ private static final int MSG_OUTPUT_FORMAT_CHANGE = 3;
+ private static final int MSG_ERROR = 4;
+ private Callbacks mCallbacks;
+
+ private CallbackSender(final Looper looper, final Callbacks callbacks) {
+ super(looper);
+ mCallbacks = callbacks;
+ }
+
+ public void notifyInputBuffer(final int index) {
+ if (isCanceled()) {
+ return;
+ }
+
+ final Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ private void processMessage(final Message msg) {
+ if (Looper.myLooper() == getLooper()) {
+ handleMessage(msg);
+ } else {
+ sendMessage(msg);
+ }
+ }
+
+ public void notifyOutputBuffer(final int index, final MediaCodec.BufferInfo info) {
+ if (isCanceled()) {
+ return;
+ }
+
+ final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ public void notifyOutputFormat(final MediaFormat format) {
+ if (isCanceled()) {
+ return;
+ }
+ processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format));
+ }
+
+ public void notifyError(final int result) {
+ Log.e(LOGTAG, "codec error:" + result);
+ processMessage(obtainMessage(MSG_ERROR, result, 0));
+ }
+
+ protected boolean handleMessageLocked(final Message msg) {
+ switch (msg.what) {
+ case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index.
+ mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this, msg.arg1);
+ break;
+ case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info.
+ mCallbacks.onOutputBufferAvailable(
+ JellyBeanAsyncCodec.this, msg.arg1, (MediaCodec.BufferInfo) msg.obj);
+ break;
+ case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format.
+ mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this, (MediaFormat) msg.obj);
+ break;
+ case MSG_ERROR: // arg1: error code.
+ mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1);
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(),
+ // with 10ms time-out. Once triggered and successfully gets a buffer, it
+ // will schedule next polling until EOS or failure. To prevent it from
+ // automatically polling more buffer, use cancel() it inherits from
+ // CancelableHandler.
+ private final class BufferPoller extends CancelableHandler {
+ private static final int MSG_POLL_INPUT_BUFFERS = 1;
+ private static final int MSG_POLL_OUTPUT_BUFFERS = 2;
+
+ private static final long DEQUEUE_TIMEOUT_US = 10000;
+
+ public BufferPoller(final Looper looper) {
+ super(looper);
+ }
+
+ private void schedulePollingIfNotCanceled(final int what) {
+ if (isCanceled()) {
+ return;
+ }
+
+ schedulePolling(what);
+ }
+
+ private void schedulePolling(final int what) {
+ if (needsBuffer(what)) {
+ sendEmptyMessage(what);
+ }
+ }
+
+ private boolean needsBuffer(final int what) {
+ if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) {
+ return false;
+ }
+
+ if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected boolean handleMessageLocked(final Message msg) {
+ try {
+ switch (msg.what) {
+ case MSG_POLL_INPUT_BUFFERS:
+ pollInputBuffer();
+ break;
+ case MSG_POLL_OUTPUT_BUFFERS:
+ pollOutputBuffer();
+ break;
+ default:
+ return false;
+ }
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ }
+
+ return true;
+ }
+
+ private void pollInputBuffer() {
+ final int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ mCallbackSender.notifyInputBuffer(result);
+ } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ mBufferPoller.schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ } else {
+ mCallbackSender.notifyError(result);
+ }
+ }
+
+ private void pollOutputBuffer() {
+ boolean dequeueMoreBuffer = true;
+ final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ final int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ mOutputEnded = true;
+ }
+ mCallbackSender.notifyOutputBuffer(result, info);
+ } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ mOutputBuffers = mCodec.getOutputBuffers();
+ } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ mOutputBuffers = mCodec.getOutputBuffers();
+ mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat());
+ } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ // When input ended, keep polling remaining output buffer until EOS.
+ dequeueMoreBuffer = mInputEnded;
+ } else {
+ mCallbackSender.notifyError(result);
+ dequeueMoreBuffer = false;
+ }
+
+ if (dequeueMoreBuffer) {
+ schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS);
+ }
+ }
+ }
+
+ private MediaCodec mCodec;
+ private ByteBuffer[] mInputBuffers;
+ private ByteBuffer[] mOutputBuffers;
+ private AsyncCodec.Callbacks mCallbacks;
+ private CallbackSender mCallbackSender;
+
+ private BufferPoller mBufferPoller;
+ private volatile boolean mInputEnded;
+ private volatile boolean mOutputEnded;
+
+ // Must be called on a thread with looper.
+ /* package */ JellyBeanAsyncCodec(final String name) throws IOException {
+ mCodec = MediaCodec.createByCodecName(name);
+ initBufferPoller(name + " buffer poller");
+ }
+
+ private void initBufferPoller(final String name) {
+ if (mBufferPoller != null) {
+ Log.e(LOGTAG, "poller already initialized");
+ return;
+ }
+ final HandlerThread thread = new HandlerThread(name);
+ thread.start();
+ mBufferPoller = new BufferPoller(thread.getLooper());
+ if (DEBUG) {
+ Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId());
+ }
+ }
+
+ @Override
+ public void setCallbacks(final AsyncCodec.Callbacks callbacks, final Handler handler) {
+ if (callbacks == null) {
+ return;
+ }
+
+ Looper looper = (handler == null) ? null : handler.getLooper();
+ if (looper == null) {
+ // Use this thread if no handler supplied.
+ looper = Looper.myLooper();
+ }
+ if (looper == null) {
+ // This thread has no looper. Use poller thread.
+ looper = mBufferPoller.getLooper();
+ }
+ mCallbackSender = new CallbackSender(looper, callbacks);
+ if (DEBUG) {
+ Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender);
+ }
+ }
+
+ @Override
+ public void configure(
+ final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) {
+ assertCallbacks();
+
+ mCodec.configure(format, surface, crypto, flags);
+ }
+
+ @Override
+ public boolean isAdaptivePlaybackSupported(final String mimeType) {
+ return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType);
+ }
+
+ @Override
+ public boolean isTunneledPlaybackSupported(final String mimeType) {
+ try {
+ return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP
+ && mCodec
+ .getCodecInfo()
+ .getCapabilitiesForType(mimeType)
+ .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
+ private void assertCallbacks() {
+ if (mCallbackSender == null) {
+ throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks().");
+ }
+ }
+
+ @Override
+ public void start() {
+ assertCallbacks();
+
+ mCodec.start();
+ mInputEnded = false;
+ mOutputEnded = false;
+ mInputBuffers = mCodec.getInputBuffers();
+ resumeReceivingInputs();
+ mOutputBuffers = mCodec.getOutputBuffers();
+ }
+
+ @Override
+ public void resumeReceivingInputs() {
+ for (int i = 0; i < mInputBuffers.length; i++) {
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+ }
+
+ @Override
+ public final void setBitrate(final int bps) {
+ if (android.os.Build.VERSION.SDK_INT >= 19) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps);
+ mCodec.setParameters(params);
+ }
+ }
+
+ @Override
+ public final void queueInputBuffer(
+ final int index,
+ final int offset,
+ final int size,
+ final long presentationTimeUs,
+ final int flags) {
+ assertCallbacks();
+
+ mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+
+ if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
+ && ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0)) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ mCodec.setParameters(params);
+ }
+
+ try {
+ mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ return;
+ }
+
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS);
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+
+ @Override
+ public final void queueSecureInputBuffer(
+ final int index,
+ final int offset,
+ final MediaCodec.CryptoInfo cryptoInfo,
+ final long presentationTimeUs,
+ final int flags) {
+ assertCallbacks();
+
+ mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+
+ try {
+ mCodec.queueSecureInputBuffer(index, offset, cryptoInfo, presentationTimeUs, flags);
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ return;
+ }
+
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS);
+ }
+
+ @Override
+ public final void releaseOutputBuffer(final int index, final boolean render) {
+ assertCallbacks();
+
+ mCodec.releaseOutputBuffer(index, render);
+ }
+
+ @Override
+ public final ByteBuffer getInputBuffer(final int index) {
+ assertCallbacks();
+
+ return mInputBuffers[index];
+ }
+
+ @Override
+ public final ByteBuffer getOutputBuffer(final int index) {
+ assertCallbacks();
+
+ return mOutputBuffers[index];
+ }
+
+ @Override
+ public MediaFormat getInputFormat() {
+ return null;
+ }
+
+ @Override
+ public void flush() {
+ assertCallbacks();
+
+ mInputEnded = false;
+ mOutputEnded = false;
+ cancelPendingTasks();
+ mCodec.flush();
+ }
+
+ private void cancelPendingTasks() {
+ mBufferPoller.cancel();
+ mCallbackSender.cancel();
+ }
+
+ @Override
+ public void stop() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCodec.stop();
+ }
+
+ @Override
+ public void release() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCallbackSender = null;
+ mCodec.release();
+ stopBufferPoller();
+ }
+
+ private void stopBufferPoller() {
+ if (mBufferPoller == null) {
+ Log.e(LOGTAG, "no initialized poller.");
+ return;
+ }
+
+ mBufferPoller.getLooper().quit();
+ mBufferPoller = null;
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop poller " + this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java
new file mode 100644
index 0000000000..8afc96109d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java
@@ -0,0 +1,250 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.TargetApi;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+/* package */ final class LollipopAsyncCodec implements AsyncCodec {
+ private final MediaCodec mCodec;
+
+ private class CodecCallback extends MediaCodec.Callback {
+ private final Forwarder mForwarder;
+
+ private class Forwarder extends Handler {
+ private static final int MSG_INPUT_BUFFER_AVAILABLE = 1;
+ private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2;
+ private static final int MSG_OUTPUT_FORMAT_CHANGE = 3;
+ private static final int MSG_ERROR = 4;
+
+ private final Callbacks mTarget;
+
+ private Forwarder(final Looper looper, final Callbacks target) {
+ super(looper);
+ mTarget = target;
+ }
+
+ @Override
+ public void handleMessage(final Message msg) {
+ switch (msg.what) {
+ case MSG_INPUT_BUFFER_AVAILABLE:
+ mTarget.onInputBufferAvailable(LollipopAsyncCodec.this, msg.arg1); // index
+ break;
+ case MSG_OUTPUT_BUFFER_AVAILABLE:
+ mTarget.onOutputBufferAvailable(
+ LollipopAsyncCodec.this,
+ msg.arg1, // index
+ (MediaCodec.BufferInfo) msg.obj); // buffer info
+ break;
+ case MSG_OUTPUT_FORMAT_CHANGE:
+ mTarget.onOutputFormatChanged(
+ LollipopAsyncCodec.this, (MediaFormat) msg.obj); // output format
+ break;
+ case MSG_ERROR:
+ mTarget.onError(LollipopAsyncCodec.this, msg.arg1); // error code
+ break;
+ default:
+ super.handleMessage(msg);
+ }
+ }
+
+ private void onInput(final int index) {
+ notify(obtainMessage(MSG_INPUT_BUFFER_AVAILABLE, index, 0));
+ }
+
+ private void notify(final Message msg) {
+ if (Looper.myLooper() == getLooper()) {
+ handleMessage(msg);
+ } else {
+ sendMessage(msg);
+ }
+ }
+
+ private void onOutput(final int index, final MediaCodec.BufferInfo info) {
+ final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, index, 0, info);
+ notify(msg);
+ }
+
+ private void onOutputFormatChanged(final MediaFormat format) {
+ notify(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format));
+ }
+
+ private void onError(final MediaCodec.CodecException e) {
+ e.printStackTrace();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ notify(obtainMessage(MSG_ERROR, e.getErrorCode()));
+ } else {
+ notify(obtainMessage(MSG_ERROR, e.getLocalizedMessage()));
+ }
+ }
+ }
+
+ private CodecCallback(final Callbacks callbacks, final Handler handler) {
+ Looper looper = (handler == null) ? null : handler.getLooper();
+ if (looper == null) {
+ // Use this thread if no handler supplied.
+ looper = Looper.myLooper();
+ }
+ if (looper == null) {
+ // This thread has no looper. Use main thread.
+ looper = Looper.getMainLooper();
+ }
+
+ mForwarder = new Forwarder(looper, callbacks);
+ }
+
+ @Override
+ public void onInputBufferAvailable(@NonNull final MediaCodec codec, final int index) {
+ mForwarder.onInput(index);
+ }
+
+ @Override
+ public void onOutputBufferAvailable(
+ @NonNull final MediaCodec codec,
+ final int index,
+ @NonNull final MediaCodec.BufferInfo info) {
+ mForwarder.onOutput(index, info);
+ }
+
+ @Override
+ public void onOutputFormatChanged(
+ @NonNull final MediaCodec codec, @NonNull final MediaFormat format) {
+ mForwarder.onOutputFormatChanged(format);
+ }
+
+ @Override
+ public void onError(
+ @NonNull final MediaCodec codec, @NonNull final MediaCodec.CodecException e) {
+ mForwarder.onError(e);
+ }
+ }
+
+ /* package */ LollipopAsyncCodec(final String name) throws IOException {
+ mCodec = MediaCodec.createByCodecName(name);
+ }
+
+ @Override
+ public void setCallbacks(final Callbacks callbacks, final Handler handler) {
+ if (callbacks == null) {
+ return;
+ }
+
+ mCodec.setCallback(new CodecCallback(callbacks, handler));
+ }
+
+ @Override
+ public void configure(
+ final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) {
+ mCodec.configure(format, surface, crypto, flags);
+ }
+
+ @Override
+ public boolean isAdaptivePlaybackSupported(final String mimeType) {
+ return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType);
+ }
+
+ @Override
+ public boolean isTunneledPlaybackSupported(final String mimeType) {
+ try {
+ return mCodec
+ .getCodecInfo()
+ .getCapabilitiesForType(mimeType)
+ .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void start() {
+ mCodec.start();
+ }
+
+ @Override
+ public void stop() {
+ mCodec.stop();
+ }
+
+ @Override
+ public void flush() {
+ mCodec.flush();
+ }
+
+ @Override
+ public void resumeReceivingInputs() {
+ mCodec.start();
+ }
+
+ @Override
+ public void setBitrate(final int bps) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps);
+ mCodec.setParameters(params);
+ }
+
+ @Override
+ public void release() {
+ mCodec.release();
+ }
+
+ @Override
+ public ByteBuffer getInputBuffer(final int index) {
+ return mCodec.getInputBuffer(index);
+ }
+
+ @Override
+ public ByteBuffer getOutputBuffer(final int index) {
+ return mCodec.getOutputBuffer(index);
+ }
+
+ @Override
+ public MediaFormat getInputFormat() {
+ return mCodec.getInputFormat();
+ }
+
+ @Override
+ public void queueInputBuffer(
+ final int index,
+ final int offset,
+ final int size,
+ final long presentationTimeUs,
+ final int flags) {
+ if ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ mCodec.setParameters(params);
+ }
+ mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ }
+
+ @Override
+ public void queueSecureInputBuffer(
+ final int index,
+ final int offset,
+ final MediaCodec.CryptoInfo info,
+ final long presentationTimeUs,
+ final int flags) {
+ mCodec.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags);
+ }
+
+ @Override
+ public void releaseOutputBuffer(final int index, final boolean render) {
+ mCodec.releaseOutputBuffer(index, render);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java
new file mode 100644
index 0000000000..7be8be6236
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java
@@ -0,0 +1,298 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.SuppressLint;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.os.Build;
+import android.util.Log;
+import java.util.ArrayList;
+import java.util.UUID;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+public final class MediaDrmProxy {
+ private static final String LOGTAG = "GeckoMediaDrmProxy";
+ private static final boolean DEBUG = false;
+ private static final UUID WIDEVINE_SCHEME_UUID =
+ new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL);
+
+ private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha";
+ @WrapForJNI private static final String AAC = "audio/mp4a-latm";
+ @WrapForJNI private static final String AVC = "video/avc";
+ @WrapForJNI private static final String VORBIS = "audio/vorbis";
+ @WrapForJNI private static final String VP8 = "video/x-vnd.on2.vp8";
+ @WrapForJNI private static final String VP9 = "video/x-vnd.on2.vp9";
+ @WrapForJNI private static final String OPUS = "audio/opus";
+ @WrapForJNI private static final String FLAC = "audio/flac";
+
+ public static final ArrayList<MediaDrmProxy> sProxyList = new ArrayList<MediaDrmProxy>();
+
+ // A flag to avoid using the native object that has been destroyed.
+ private boolean mDestroyed;
+ private GeckoMediaDrm mImpl;
+ private String mDrmStubId;
+
+ private static boolean isSystemSupported() {
+ // Support versions >= Marshmallow
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ if (DEBUG)
+ Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT);
+ return false;
+ }
+ return true;
+ }
+
+ @SuppressLint("NewApi")
+ @WrapForJNI
+ public static boolean isSchemeSupported(final String keySystem) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID)
+ && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID);
+ }
+ if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem);
+ return false;
+ }
+
+ @SuppressLint("NewApi")
+ @WrapForJNI
+ public static boolean IsCryptoSchemeSupported(final String keySystem, final String container) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container);
+ }
+ if (DEBUG)
+ Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container);
+ return false;
+ }
+
+ // Interface for callback to native.
+ public interface Callbacks {
+ void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request);
+
+ void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ void onSessionClosed(int promiseId, byte[] sessionId);
+
+ void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request);
+
+ void onSessionError(byte[] sessionId, String message);
+
+ // MediaDrm.KeyStatus is available in API level 23(M)
+ // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html
+ // For compatibility between L and M above, we'll unwrap the KeyStatus structure
+ // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy).
+ void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos);
+
+ void onRejectPromise(int promiseId, String message);
+ } // Callbacks
+
+ public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ NativeMediaDrmProxyCallbacks() {}
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionCreated(
+ int createSessionToken, int promiseId, byte[] sessionId, byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionClosed(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionError(byte[] sessionId, String message);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onRejectPromise(int promiseId, String message);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // NativeMediaDrmProxyCallbacks
+
+ // A proxy to callback from LocalMediaDrmBridge to native instance.
+ public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks {
+ private final Callbacks mNativeCallbacks;
+ private final MediaDrmProxy mProxy;
+
+ public MediaDrmProxyCallbacks(final MediaDrmProxy proxy, final Callbacks callbacks) {
+ mNativeCallbacks = callbacks;
+ mProxy = proxy;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionError(sessionId, message);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onRejectPromise(promiseId, message);
+ }
+ }
+ } // MediaDrmProxyCallbacks
+
+ public boolean isDestroyed() {
+ return mDestroyed;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static MediaDrmProxy create(final String keySystem, final Callbacks nativeCallbacks) {
+ final MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks);
+ return proxy;
+ }
+
+ MediaDrmProxy(final String keySystem, final Callbacks nativeCallbacks) {
+ if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy");
+ try {
+ mDrmStubId = UUID.randomUUID().toString();
+ final IMediaDrmBridge remoteBridge =
+ RemoteManager.getInstance().createRemoteMediaDrmBridge(keySystem, mDrmStubId);
+ mImpl = new RemoteMediaDrmBridge(remoteBridge);
+ mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks));
+ sProxyList.add(this);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Constructing MediaDrmProxy ... error", e);
+ }
+ }
+
+ @WrapForJNI
+ private void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId);
+ mImpl.createSession(createSessionToken, promiseId, initDataType, initData);
+ }
+
+ @WrapForJNI
+ private void updateSession(final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG)
+ Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.updateSession(promiseId, sessionId, response);
+ }
+
+ @WrapForJNI
+ private void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG)
+ Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.closeSession(promiseId, sessionId);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private String getStubId() {
+ return mDrmStubId;
+ }
+
+ @WrapForJNI
+ public boolean setServerCertificate(final byte[] cert) {
+ try {
+ mImpl.setServerCertificate(cert);
+ return true;
+ } catch (final RuntimeException e) {
+ return false;
+ }
+ }
+
+ // Get corresponding MediaCrypto object by a generated UUID for MediaCodec.
+ // Will be called on MediaFormatReader's TaskQueue.
+ @WrapForJNI
+ public static MediaCrypto getMediaCrypto(final String stubId) {
+ for (final MediaDrmProxy proxy : sProxyList) {
+ if (proxy.getStubId().equals(stubId)) {
+ return proxy.getMediaCryptoFromBridge();
+ }
+ }
+ if (DEBUG) Log.d(LOGTAG, " NULL crytpo ");
+ return null;
+ }
+
+ @WrapForJNI // Called when natvie object is destroyed.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mDestroyed) {
+ return;
+ }
+ mDestroyed = true;
+ release();
+ }
+
+ private void release() {
+ if (DEBUG) Log.d(LOGTAG, "release");
+ sProxyList.remove(this);
+ mImpl.release();
+ }
+
+ private MediaCrypto getMediaCryptoFromBridge() {
+ return mImpl != null ? mImpl.getMediaCrypto() : null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java
new file mode 100644
index 0000000000..ef4fdc6932
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java
@@ -0,0 +1,79 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.Log;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.geckoview.BuildConfig;
+
+public final class MediaManager extends Service {
+ private static final String LOGTAG = "GeckoMediaManager";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ private static boolean sNativeLibLoaded;
+ private int mNumActiveRequests = 0;
+
+ private Binder mBinder =
+ new IMediaManager.Stub() {
+ @Override
+ public ICodec createCodec() throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "request codec. Current active requests:" + mNumActiveRequests);
+ mNumActiveRequests++;
+ return new Codec();
+ }
+
+ @Override
+ public IMediaDrmBridge createRemoteMediaDrmBridge(
+ final String keySystem, final String stubId) throws RemoteException {
+ if (DEBUG)
+ Log.d(LOGTAG, "request DRM bridge. Current active requests:" + mNumActiveRequests);
+ mNumActiveRequests++;
+ return new RemoteMediaDrmBridgeStub(keySystem, stubId);
+ }
+
+ @Override
+ public void endRequest() {
+ if (DEBUG) Log.d(LOGTAG, "end request. Current active requests:" + mNumActiveRequests);
+ if (mNumActiveRequests > 0) {
+ mNumActiveRequests--;
+ } else {
+ final RuntimeException e =
+ new RuntimeException("unmatched codec/DRM bridge creation and ending calls!");
+ Log.e(LOGTAG, "Error:", e);
+ }
+ }
+ };
+
+ @Override
+ public synchronized void onCreate() {
+ if (!sNativeLibLoaded) {
+ GeckoLoader.doLoadLibrary(this, "mozglue");
+ GeckoLoader.suppressCrashDialog();
+ sNativeLibLoaded = true;
+ }
+ }
+
+ @Override
+ public IBinder onBind(final Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public boolean onUnbind(final Intent intent) {
+ Log.i(LOGTAG, "Media service has been unbound. Stopping.");
+ stopSelf();
+ if (mNumActiveRequests != 0) {
+ // Not unbound by RemoteManager -- caller process is dead.
+ Log.w(LOGTAG, "unbound while client still active.");
+ Process.killProcess(Process.myPid());
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java
new file mode 100644
index 0000000000..62026f534f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java
@@ -0,0 +1,254 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.MediaFormat;
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.NoSuchElementException;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.TelemetryUtils;
+import org.mozilla.gecko.gfx.GeckoSurface;
+
+public final class RemoteManager implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteManager";
+ private static final boolean DEBUG = false;
+ private static RemoteManager sRemoteManager = null;
+
+ public static synchronized RemoteManager getInstance() {
+ if (sRemoteManager == null) {
+ sRemoteManager = new RemoteManager();
+ }
+
+ sRemoteManager.init();
+ return sRemoteManager;
+ }
+
+ private List<CodecProxy> mCodecs = new LinkedList<CodecProxy>();
+ private List<IMediaDrmBridge> mDrmBridges = new LinkedList<IMediaDrmBridge>();
+
+ private volatile IMediaManager mRemote;
+
+ private final class RemoteConnection implements ServiceConnection {
+ @Override
+ public void onServiceConnected(final ComponentName name, final IBinder service) {
+ if (DEBUG) Log.d(LOGTAG, "service connected");
+ try {
+ service.linkToDeath(RemoteManager.this, 0);
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ synchronized (this) {
+ mRemote = IMediaManager.Stub.asInterface(service);
+ notify();
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(final ComponentName name) {
+ if (DEBUG) Log.d(LOGTAG, "service disconnected");
+ unlink();
+ }
+
+ private boolean connect() {
+ final Context appCtxt = GeckoAppShell.getApplicationContext();
+ appCtxt.bindService(
+ new Intent(appCtxt, MediaManager.class),
+ mConnection,
+ Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT);
+ waitConnect();
+ return mRemote != null;
+ }
+
+ // Wait up to 5s.
+ private synchronized void waitConnect() {
+ int waitCount = 0;
+ while (mRemote == null && waitCount < 5) {
+ try {
+ wait(1000);
+ waitCount++;
+ } catch (final InterruptedException e) {
+ if (DEBUG) {
+ e.printStackTrace();
+ }
+ }
+ }
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "wait ~" + waitCount + "s for connection: " + (mRemote == null ? "fail" : "ok"));
+ }
+ }
+
+ private synchronized void waitDisconnect() {
+ while (mRemote != null) {
+ try {
+ wait(1000);
+ } catch (final InterruptedException e) {
+ if (DEBUG) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ private synchronized void unlink() {
+ if (mRemote == null) {
+ return;
+ }
+ try {
+ mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0);
+ } catch (final NoSuchElementException e) {
+ Log.w(LOGTAG, "death recipient already released");
+ }
+ mRemote = null;
+ notify();
+ }
+ }
+
+ RemoteConnection mConnection = new RemoteConnection();
+
+ private synchronized boolean init() {
+ if (mRemote != null) {
+ return true;
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "init remote manager " + this);
+ return mConnection.connect();
+ }
+
+ public synchronized CodecProxy createCodec(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final CodecProxy.Callbacks callbacks,
+ final String drmStubId) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize");
+ return null;
+ }
+ try {
+ final ICodec remote = mRemote.createCodec();
+ final CodecProxy proxy =
+ CodecProxy.createCodecProxy(isEncoder, format, surface, callbacks, drmStubId);
+ if (proxy.init(remote)) {
+ mCodecs.add(proxy);
+ return proxy;
+ } else {
+ return null;
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ public synchronized IMediaDrmBridge createRemoteMediaDrmBridge(
+ final String keySystem, final String stubId) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize");
+ return null;
+ }
+ try {
+ final IMediaDrmBridge remoteBridge = mRemote.createRemoteMediaDrmBridge(keySystem, stubId);
+ mDrmBridges.add(remoteBridge);
+ return remoteBridge;
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void binderDied() {
+ Log.e(LOGTAG, "remote codec is dead");
+ TelemetryUtils.addToHistogram("MEDIA_DECODING_PROCESS_CRASH", 1);
+ handleRemoteDeath();
+ }
+
+ private synchronized void handleRemoteDeath() {
+ mConnection.waitDisconnect();
+
+ if (init() && recoverRemoteCodec()) {
+ notifyError(false);
+ } else {
+ notifyError(true);
+ }
+ }
+
+ private synchronized void notifyError(final boolean fatal) {
+ for (final CodecProxy proxy : mCodecs) {
+ proxy.reportError(fatal);
+ }
+ }
+
+ private synchronized boolean recoverRemoteCodec() {
+ if (DEBUG) Log.d(LOGTAG, "recover codec");
+ boolean ok = true;
+ try {
+ for (final CodecProxy proxy : mCodecs) {
+ ok &= proxy.init(mRemote.createCodec());
+ }
+ return ok;
+ } catch (final RemoteException e) {
+ return false;
+ }
+ }
+
+ public void releaseCodec(final CodecProxy proxy) throws DeadObjectException, RemoteException {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet");
+ return;
+ }
+ proxy.deinit();
+ synchronized (this) {
+ if (mCodecs.remove(proxy)) {
+ try {
+ mRemote.endRequest();
+ releaseIfNeeded();
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "fail to report remote codec disconnection");
+ }
+ }
+ }
+ }
+
+ private void releaseIfNeeded() {
+ if (!mCodecs.isEmpty() || !mDrmBridges.isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "release remote manager " + this);
+ mConnection.unlink();
+ final Context appCtxt = GeckoAppShell.getApplicationContext();
+ appCtxt.unbindService(mConnection);
+ }
+
+ public void onRemoteMediaDrmBridgeReleased(final IMediaDrmBridge remote) {
+ if (!mDrmBridges.contains(remote)) {
+ Log.e(LOGTAG, "Try to release unknown remote MediaDrm bridge: " + remote);
+ return;
+ }
+
+ synchronized (this) {
+ if (mDrmBridges.remove(remote)) {
+ try {
+ mRemote.endRequest();
+ releaseIfNeeded();
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "Fail to report remote DRM bridge disconnection");
+ }
+ }
+ }
+ }
+} // RemoteManager
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
new file mode 100644
index 0000000000..b90f720300
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
@@ -0,0 +1,163 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+import android.util.Log;
+
+final class RemoteMediaDrmBridge implements GeckoMediaDrm {
+ private static final String LOGTAG = "RemoteMediaDrmBridge";
+ private static final boolean DEBUG = false;
+ private CallbacksForwarder mCallbacksFwd;
+ private IMediaDrmBridge mRemote;
+
+ // Forward callbacks from remote bridge stub to MediaDrmProxy.
+ private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub {
+ private final GeckoMediaDrm.Callbacks mProxyCallbacks;
+
+ CallbacksForwarder(final Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mProxyCallbacks = callbacks;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ mProxyCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ mProxyCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ mProxyCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ mProxyCallbacks.onSessionError(sessionId, message);
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ mProxyCallbacks.onRejectPromise(promiseId, message);
+ }
+ } // CallbacksForwarder
+
+ /* package-private */ static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ public RemoteMediaDrmBridge(final IMediaDrmBridge remoteBridge) {
+ assertTrue(remoteBridge != null);
+ mRemote = remoteBridge;
+ }
+
+ @Override
+ public synchronized void setCallbacks(final Callbacks callbacks) {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(callbacks != null);
+ assertTrue(mRemote != null);
+
+ mCallbacksFwd = new CallbacksForwarder(callbacks);
+ try {
+ mRemote.setCallbacks(mCallbacksFwd);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception during setCallbacks", e);
+ }
+ }
+
+ @Override
+ public synchronized void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+
+ try {
+ mRemote.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while creating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(
+ final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+
+ try {
+ mRemote.updateSession(promiseId, sessionId, response);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while updating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+
+ try {
+ mRemote.closeSession(promiseId, sessionId);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while closing remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session.");
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+
+ try {
+ mRemote.release();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e);
+ }
+ RemoteManager.getInstance().onRemoteMediaDrmBridgeReleased(mRemote);
+ mRemote = null;
+ mCallbacksFwd = null;
+ }
+
+ @Override
+ public synchronized MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!");
+ assertTrue(false);
+ return null;
+ }
+
+ @Override
+ public synchronized void setServerCertificate(final byte[] cert) {
+ try {
+ mRemote.setServerCertificate(cert);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while setting server certificate.", e);
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
new file mode 100644
index 0000000000..f466529388
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
@@ -0,0 +1,252 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import java.util.ArrayList;
+
+final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub
+ implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "RemoteDrmBridgeStub";
+ private static final boolean DEBUG = false;
+ private volatile IMediaDrmBridgeCallbacks mCallbacks = null;
+
+ // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21.
+ private GeckoMediaDrm mBridge = null;
+
+ // mStubId is initialized during stub construction. It should be a unique
+ // string which is generated in MediaDrmProxy in Fennec App process and is
+ // used for Codec to obtain corresponding MediaCrypto as input to achieve
+ // decryption.
+ // The generated stubId will be delivered to Codec via a code path starting
+ // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec.
+ private String mStubId = "";
+
+ public static final ArrayList<RemoteMediaDrmBridgeStub> mBridgeStubs =
+ new ArrayList<RemoteMediaDrmBridgeStub>();
+
+ private String getId() {
+ return mStubId;
+ }
+
+ private MediaCrypto getMediaCryptoFromBridge() {
+ return mBridge != null ? mBridge.getMediaCrypto() : null;
+ }
+
+ public static synchronized MediaCrypto getMediaCrypto(final String stubId) {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+
+ for (int i = 0; i < mBridgeStubs.size(); i++) {
+ if (mBridgeStubs.get(i) != null && mBridgeStubs.get(i).getId().equals(stubId)) {
+ return mBridgeStubs.get(i).getMediaCryptoFromBridge();
+ }
+ }
+ return null;
+ }
+
+ // Callback to RemoteMediaDrmBridge.
+ private final class Callbacks implements GeckoMediaDrm.Callbacks {
+ private IMediaDrmBridgeCallbacks mRemoteCallbacks;
+
+ public Callbacks(final IMediaDrmBridgeCallbacks remote) {
+ mRemoteCallbacks = remote;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionCreated()");
+ try {
+ mRemoteCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()");
+ try {
+ mRemoteCallbacks.onSessionUpdated(promiseId, sessionId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionClosed()");
+ try {
+ mRemoteCallbacks.onSessionClosed(promiseId, sessionId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionMessage()");
+ try {
+ mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionError()");
+ try {
+ mRemoteCallbacks.onSessionError(sessionId, message);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()");
+ try {
+ mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ if (DEBUG) Log.d(LOGTAG, "onRejectPromise()");
+ try {
+ mRemoteCallbacks.onRejectPromise(promiseId, message);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+ }
+
+ /* package-private */ void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ RemoteMediaDrmBridgeStub(final String keySystem, final String stubId) throws RemoteException {
+ if (Build.VERSION.SDK_INT < 21) {
+ Log.e(LOGTAG, "Pre-Lollipop should never enter here!!");
+ throw new RemoteException("Error, unsupported version!");
+ }
+ try {
+ if (Build.VERSION.SDK_INT < 23) {
+ mBridge = new GeckoMediaDrmBridgeV21(keySystem);
+ } else {
+ mBridge = new GeckoMediaDrmBridgeV23(keySystem);
+ }
+ mStubId = stubId;
+ mBridgeStubs.add(this);
+ } catch (final Exception e) {
+ throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation.");
+ }
+ }
+
+ @Override
+ public synchronized void setCallbacks(final IMediaDrmBridgeCallbacks callbacks)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(mBridge != null);
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ mBridge.setCallbacks(new Callbacks(mCallbacks));
+ }
+
+ @Override
+ public synchronized void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to createSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to createSession.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(
+ final int promiseId, final String sessionId, final byte[] response) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.updateSession(promiseId, sessionId, response);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to updateSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to updateSession.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(final int promiseId, final String sessionId)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.closeSession(promiseId, sessionId);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to closeSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to closeSession.");
+ }
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Binder died !!");
+ try {
+ release();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ mBridgeStubs.remove(this);
+ if (mBridge != null) {
+ mBridge.release();
+ mBridge = null;
+ }
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ mStubId = "";
+ }
+
+ @Override
+ public synchronized void setServerCertificate(final byte[] cert) {
+ try {
+ mBridge.setServerCertificate(cert);
+ } catch (final IllegalStateException e) {
+ Log.e(LOGTAG, "Failed to setServerCertificate.", e);
+ throw e;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java
new file mode 100644
index 0000000000..de4cbf923a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java
@@ -0,0 +1,232 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Parcel;
+import android.os.Parcelable;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// Parcelable carrying input/output sample data and info cross process.
+public final class Sample implements Parcelable {
+ public static final Sample EOS;
+
+ static {
+ final BufferInfo eosInfo = new BufferInfo();
+ EOS = new Sample();
+ EOS.info.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ }
+
+ @WrapForJNI public long session;
+
+ public static final int NO_BUFFER = -1;
+
+ public int bufferId = NO_BUFFER;
+ @WrapForJNI public BufferInfo info = new BufferInfo();
+ public CryptoInfo cryptoInfo;
+
+ // Simple Linked list for recycling objects.
+ // Used to nodify Sample objects. Do not marshal/unmarshal.
+ private Sample mNext;
+ private static Sample sPool = new Sample();
+ private static int sPoolSize = 1;
+
+ private Sample() {}
+
+ private void readInfo(final Parcel in) {
+ final int offset = in.readInt();
+ final int size = in.readInt();
+ final long pts = in.readLong();
+ final int flags = in.readInt();
+
+ info.set(offset, size, pts, flags);
+ }
+
+ private void readCrypto(final Parcel in) {
+ final int hasCryptoInfo = in.readInt();
+ if (hasCryptoInfo == 0) {
+ cryptoInfo = null;
+ return;
+ }
+
+ final byte[] iv = in.createByteArray();
+ final byte[] key = in.createByteArray();
+ final int mode = in.readInt();
+ final int[] numBytesOfClearData = in.createIntArray();
+ final int[] numBytesOfEncryptedData = in.createIntArray();
+ final int numSubSamples = in.readInt();
+
+ if (cryptoInfo == null) {
+ cryptoInfo = new CryptoInfo();
+ }
+ cryptoInfo.set(numSubSamples, numBytesOfClearData, numBytesOfEncryptedData, key, iv, mode);
+ }
+
+ public Sample set(final BufferInfo info, final CryptoInfo cryptoInfo) {
+ setBufferInfo(info);
+ setCryptoInfo(cryptoInfo);
+ return this;
+ }
+
+ public void setBufferInfo(final BufferInfo info) {
+ this.info.set(0, info.size, info.presentationTimeUs, info.flags);
+ }
+
+ public void setCryptoInfo(final CryptoInfo crypto) {
+ if (crypto == null) {
+ cryptoInfo = null;
+ return;
+ }
+
+ if (cryptoInfo == null) {
+ cryptoInfo = new CryptoInfo();
+ }
+ cryptoInfo.set(
+ crypto.numSubSamples,
+ crypto.numBytesOfClearData,
+ crypto.numBytesOfEncryptedData,
+ crypto.key,
+ crypto.iv,
+ crypto.mode);
+ }
+
+ @WrapForJNI
+ public void dispose() {
+ if (isEOS()) {
+ return;
+ }
+
+ bufferId = NO_BUFFER;
+ info.set(0, 0, 0, 0);
+ if (cryptoInfo != null) {
+ cryptoInfo.set(0, null, null, null, null, 0);
+ }
+
+ // Recycle it.
+ synchronized (CREATOR) {
+ this.mNext = sPool;
+ sPool = this;
+ sPoolSize++;
+ }
+ }
+
+ public boolean isEOS() {
+ return (this == EOS) || ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0);
+ }
+
+ public static Sample obtain() {
+ synchronized (CREATOR) {
+ Sample s = null;
+ if (sPoolSize > 0) {
+ s = sPool;
+ sPool = s.mNext;
+ s.mNext = null;
+ sPoolSize--;
+ } else {
+ s = new Sample();
+ }
+ return s;
+ }
+ }
+
+ public static final Creator<Sample> CREATOR =
+ new Creator<Sample>() {
+ @Override
+ public Sample createFromParcel(final Parcel in) {
+ return obtainSample(in);
+ }
+
+ @Override
+ public Sample[] newArray(final int size) {
+ return new Sample[size];
+ }
+
+ private Sample obtainSample(final Parcel in) {
+ final Sample s = obtain();
+ s.session = in.readLong();
+ s.bufferId = in.readInt();
+ s.readInfo(in);
+ s.readCrypto(in);
+ return s;
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int parcelableFlags) {
+ dest.writeLong(session);
+ dest.writeInt(bufferId);
+ writeInfo(dest);
+ writeCrypto(dest);
+ }
+
+ private void writeInfo(final Parcel dest) {
+ dest.writeInt(info.offset);
+ dest.writeInt(info.size);
+ dest.writeLong(info.presentationTimeUs);
+ dest.writeInt(info.flags);
+ }
+
+ private void writeCrypto(final Parcel dest) {
+ if (cryptoInfo != null) {
+ dest.writeInt(1);
+ dest.writeByteArray(cryptoInfo.iv);
+ dest.writeByteArray(cryptoInfo.key);
+ dest.writeInt(cryptoInfo.mode);
+ dest.writeIntArray(cryptoInfo.numBytesOfClearData);
+ dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData);
+ dest.writeInt(cryptoInfo.numSubSamples);
+ } else {
+ dest.writeInt(0);
+ }
+ }
+
+ public static byte[] byteArrayFromBuffer(
+ final ByteBuffer buffer, final int offset, final int size) {
+ if (buffer == null || buffer.capacity() == 0 || size == 0) {
+ return null;
+ }
+ if (buffer.hasArray() && offset == 0 && buffer.array().length == size) {
+ return buffer.array();
+ }
+ final int length = Math.min(offset + size, buffer.capacity()) - offset;
+ final byte[] bytes = new byte[length];
+ buffer.position(offset);
+ buffer.get(bytes);
+ return bytes;
+ }
+
+ @Override
+ public String toString() {
+ if (isEOS()) {
+ return "EOS sample";
+ }
+
+ final StringBuilder str = new StringBuilder();
+ str.append("{ session#:")
+ .append(session)
+ .append(", buffer#")
+ .append(bufferId)
+ .append(", info=")
+ .append("{ offset=")
+ .append(info.offset)
+ .append(", size=")
+ .append(info.size)
+ .append(", pts=")
+ .append(info.presentationTimeUs)
+ .append(", flags=")
+ .append(Integer.toHexString(info.flags))
+ .append(" }")
+ .append(" }");
+ return str.toString();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java
new file mode 100644
index 0000000000..e6b242708d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java
@@ -0,0 +1,101 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+public final class SampleBuffer implements Parcelable {
+ private SharedMemory mSharedMem;
+
+ /* package */
+ public SampleBuffer(final SharedMemory sharedMem) {
+ mSharedMem = sharedMem;
+ }
+
+ protected SampleBuffer(final Parcel in) {
+ mSharedMem = in.readParcelable(SampleBuffer.class.getClassLoader());
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeParcelable(mSharedMem, flags);
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public static final Creator<SampleBuffer> CREATOR =
+ new Creator<SampleBuffer>() {
+ @Override
+ public SampleBuffer createFromParcel(final Parcel in) {
+ return new SampleBuffer(in);
+ }
+
+ @Override
+ public SampleBuffer[] newArray(final int size) {
+ return new SampleBuffer[size];
+ }
+ };
+
+ public int capacity() {
+ return mSharedMem != null ? mSharedMem.getSize() : 0;
+ }
+
+ public void readFromByteBuffer(final ByteBuffer src, final int offset, final int size)
+ throws IOException {
+ if (!src.isDirect()) {
+ throw new IOException("SharedMemBuffer only support reading from direct byte buffer.");
+ }
+ try {
+ nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size);
+ mSharedMem.flush();
+ } catch (final NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static native void nativeReadFromDirectBuffer(
+ ByteBuffer src, long dest, int offset, int size);
+
+ @WrapForJNI
+ public void writeToByteBuffer(final ByteBuffer dest, final int offset, final int size)
+ throws IOException {
+ if (!dest.isDirect()) {
+ throw new IOException("SharedMemBuffer only support writing to direct byte buffer.");
+ }
+ try {
+ nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size);
+ } catch (final NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static native void nativeWriteToDirectBuffer(
+ long src, ByteBuffer dest, int offset, int size);
+
+ public void dispose() {
+ if (mSharedMem != null) {
+ mSharedMem.dispose();
+ mSharedMem = null;
+ }
+ }
+
+ @WrapForJNI
+ public boolean isValid() {
+ return mSharedMem != null;
+ }
+
+ @Override
+ public String toString() {
+ return "Buffer: " + mSharedMem;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
new file mode 100644
index 0000000000..a2101b3aeb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
@@ -0,0 +1,154 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.util.SparseArray;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+final class SamplePool {
+ private static final class Impl {
+ private final String mName;
+ private int mDefaultBufferSize = 4096;
+ private final List<Sample> mRecycledSamples = new ArrayList<>();
+ private final boolean mBufferless;
+
+ private int mNextBufferId = Sample.NO_BUFFER + 1;
+ private SparseArray<SampleBuffer> mBuffers = new SparseArray<>();
+
+ private Impl(final String name, final boolean bufferless) {
+ mName = name;
+ mBufferless = bufferless;
+ }
+
+ private void setDefaultBufferSize(final int size) {
+ if (mBufferless) {
+ throw new IllegalStateException("Setting buffer size of a bufferless pool is not allowed");
+ }
+ mDefaultBufferSize = size;
+ }
+
+ private synchronized Sample obtain(final int size) {
+ if (!mRecycledSamples.isEmpty()) {
+ return mRecycledSamples.remove(0);
+ }
+
+ if (mBufferless) {
+ return Sample.obtain();
+ } else {
+ return allocateSampleAndBuffer(size);
+ }
+ }
+
+ private Sample allocateSampleAndBuffer(final int size) {
+ final int id = mNextBufferId++;
+ try {
+ final SharedMemory shm = new SharedMemory(id, Math.max(size, mDefaultBufferSize));
+ mBuffers.put((Integer) id, new SampleBuffer(shm));
+ final Sample s = Sample.obtain();
+ s.bufferId = id;
+ return s;
+ } catch (final NoSuchMethodException | IOException e) {
+ mBuffers.delete(id);
+ throw new UnsupportedOperationException(e);
+ }
+ }
+
+ private synchronized SampleBuffer getBuffer(final int id) {
+ return mBuffers.get(id);
+ }
+
+ private synchronized void recycle(final Sample recycled) {
+ if (mBufferless || isUsefulSample(recycled)) {
+ mRecycledSamples.add(recycled);
+ } else {
+ disposeSample(recycled);
+ }
+ }
+
+ private boolean isUsefulSample(final Sample sample) {
+ return mBuffers.get(sample.bufferId).capacity() >= mDefaultBufferSize;
+ }
+
+ private synchronized void clear() {
+ for (final Sample s : mRecycledSamples) {
+ disposeSample(s);
+ }
+ mRecycledSamples.clear();
+
+ for (int i = 0; i < mBuffers.size(); ++i) {
+ mBuffers.valueAt(i).dispose();
+ }
+ mBuffers.clear();
+ }
+
+ private void disposeSample(final Sample sample) {
+ if (sample.bufferId != Sample.NO_BUFFER) {
+ mBuffers.get(sample.bufferId).dispose();
+ mBuffers.delete(sample.bufferId);
+ }
+ sample.dispose();
+ }
+
+ @Override
+ protected void finalize() {
+ clear();
+ }
+ }
+
+ private final Impl mInputs;
+ private final Impl mOutputs;
+
+ /* package */ SamplePool(final String name, final boolean renderToSurface) {
+ mInputs = new Impl(name + " input sample pool", false);
+ // Buffers are useless when rendering to surface.
+ mOutputs = new Impl(name + " output sample pool", renderToSurface);
+ }
+
+ /* package */ void setInputBufferSize(final int size) {
+ mInputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ void setOutputBufferSize(final int size) {
+ mOutputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ Sample obtainInput(final int size) {
+ final Sample input = mInputs.obtain(size);
+ input.info.set(0, 0, 0, 0);
+ return input;
+ }
+
+ /* package */ Sample obtainOutput(final MediaCodec.BufferInfo info) {
+ final Sample output = mOutputs.obtain(info.size);
+ output.info.set(0, info.size, info.presentationTimeUs, info.flags);
+ return output;
+ }
+
+ /* package */ void recycleInput(final Sample sample) {
+ sample.cryptoInfo = null;
+ mInputs.recycle(sample);
+ }
+
+ /* package */ void recycleOutput(final Sample sample) {
+ mOutputs.recycle(sample);
+ }
+
+ /* package */ void reset() {
+ mInputs.clear();
+ mOutputs.clear();
+ }
+
+ /* package */ SampleBuffer getInputBuffer(final int id) {
+ return mInputs.getBuffer(id);
+ }
+
+ /* package */ SampleBuffer getOutputBuffer(final int id) {
+ return mOutputs.getBuffer(id);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java
new file mode 100644
index 0000000000..5e70a6f2a7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java
@@ -0,0 +1,50 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class SessionKeyInfo implements Parcelable {
+ @WrapForJNI public byte[] keyId;
+
+ @WrapForJNI public int status;
+
+ @WrapForJNI
+ public SessionKeyInfo(final byte[] keyId, final int status) {
+ this.keyId = keyId;
+ this.status = status;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int parcelableFlags) {
+ dest.writeByteArray(keyId);
+ dest.writeInt(status);
+ }
+
+ public static final Creator<SessionKeyInfo> CREATOR =
+ new Creator<SessionKeyInfo>() {
+ @Override
+ public SessionKeyInfo createFromParcel(final Parcel in) {
+ return new SessionKeyInfo(in);
+ }
+
+ @Override
+ public SessionKeyInfo[] newArray(final int size) {
+ return new SessionKeyInfo[size];
+ }
+ };
+
+ private SessionKeyInfo(final Parcel src) {
+ keyId = src.createByteArray();
+ status = src.readInt();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java
new file mode 100644
index 0000000000..5cc32e127c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java
@@ -0,0 +1,39 @@
+/* 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/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+
+public class Utils {
+ public static long getThreadId() {
+ final Thread t = Thread.currentThread();
+ return t.getId();
+ }
+
+ public static String getThreadSignature() {
+ final Thread t = Thread.currentThread();
+ final long l = t.getId();
+ final String name = t.getName();
+ final long p = t.getPriority();
+ final String gname = t.getThreadGroup().getName();
+ return (name + ":(id)" + l + ":(priority)" + p + ":(group)" + gname);
+ }
+
+ public static void logThreadSignature() {
+ Log.d("ThreadUtils", getThreadSignature());
+ }
+
+ private static final char[] hexArray = "0123456789ABCDEF".toCharArray();
+
+ public static String bytesToHex(final byte[] bytes) {
+ final char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ final int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java
new file mode 100644
index 0000000000..701780171e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java
@@ -0,0 +1,440 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.mozglue;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+import android.util.Log;
+import dalvik.system.BaseDexClassLoader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+public final class GeckoLoader {
+ private static final String LOGTAG = "GeckoLoader";
+
+ private static File sGREDir;
+
+ /* Synchronized on GeckoLoader.class. */
+ private static boolean sSQLiteLibsLoaded;
+ private static boolean sNSSLibsLoaded;
+ private static boolean sMozGlueLoaded;
+
+ private GeckoLoader() {
+ // prevent instantiation
+ }
+
+ public static File getGREDir(final Context context) {
+ if (sGREDir == null) {
+ sGREDir = new File(context.getApplicationInfo().dataDir);
+ }
+ return sGREDir;
+ }
+
+ private static void setupDownloadEnvironment(final Context context) {
+ try {
+ File downloadDir =
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ File updatesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
+ if (downloadDir == null) {
+ downloadDir = new File(Environment.getExternalStorageDirectory().getPath(), "download");
+ }
+ if (updatesDir == null) {
+ updatesDir = downloadDir;
+ }
+ putenv("DOWNLOADS_DIRECTORY=" + downloadDir.getPath());
+ putenv("UPDATES_DIRECTORY=" + updatesDir.getPath());
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "No download directory found.", e);
+ }
+ }
+
+ private static void delTree(final File file) {
+ if (file.isDirectory()) {
+ final File[] children = file.listFiles();
+ for (final File child : children) {
+ delTree(child);
+ }
+ }
+ file.delete();
+ }
+
+ private static File getTmpDir(final Context context) {
+ // It's important that this folder is in the cache directory so users can actually
+ // clear it when it gets too big.
+ return new File(context.getCacheDir(), "gecko_temp");
+ }
+
+ private static String escapeDoubleQuotes(final String str) {
+ return str.replaceAll("\"", "\\\"");
+ }
+
+ private static void setupInitialPrefs(final Map<String, Object> prefs) {
+ if (prefs != null) {
+ final StringBuilder prefsEnv = new StringBuilder("MOZ_DEFAULT_PREFS=");
+ for (final String key : prefs.keySet()) {
+ final Object value = prefs.get(key);
+ if (value == null) {
+ continue;
+ }
+ prefsEnv.append(String.format("pref(\"%s\",", escapeDoubleQuotes(key)));
+ if (value instanceof String) {
+ prefsEnv.append(String.format("\"%s\"", escapeDoubleQuotes(value.toString())));
+ } else if (value instanceof Boolean) {
+ prefsEnv.append((Boolean) value ? "true" : "false");
+ } else {
+ prefsEnv.append(value.toString());
+ }
+
+ prefsEnv.append(");\n");
+ }
+
+ putenv(prefsEnv.toString());
+ }
+ }
+
+ @SuppressWarnings("deprecation") // for Build.CPU_ABI
+ public static synchronized void setupGeckoEnvironment(
+ final Context context,
+ final boolean isChildProcess,
+ final String profilePath,
+ final Collection<String> env,
+ final Map<String, Object> prefs,
+ final boolean xpcshell) {
+ for (final String e : env) {
+ putenv(e);
+ }
+
+ putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName());
+
+ if (!isChildProcess) {
+ setupDownloadEnvironment(context);
+
+ // profile home path
+ putenv("HOME=" + profilePath);
+
+ // setup the downloads path
+ File f = Environment.getDownloadCacheDirectory();
+ putenv("EXTERNAL_STORAGE=" + f.getPath());
+
+ // setup the app-specific cache path
+ f = context.getCacheDir();
+ putenv("CACHE_DIRECTORY=" + f.getPath());
+
+ f = context.getExternalFilesDir(null);
+ if (f != null) {
+ putenv("PUBLIC_STORAGE=" + f.getPath());
+ }
+
+ if (Build.VERSION.SDK_INT >= 17) {
+ final android.os.UserManager um =
+ (android.os.UserManager) context.getSystemService(Context.USER_SERVICE);
+ if (um != null) {
+ putenv(
+ "MOZ_ANDROID_USER_SERIAL_NUMBER="
+ + um.getSerialNumberForUser(android.os.Process.myUserHandle()));
+ } else {
+ Log.d(
+ LOGTAG,
+ "Unable to obtain user manager service on a device with SDK version "
+ + Build.VERSION.SDK_INT);
+ }
+ }
+
+ setupInitialPrefs(prefs);
+ }
+
+ // Xpcshell tests set up their own temp directory
+ if (!xpcshell) {
+ // setup the tmp path
+ final File f = getTmpDir(context);
+ if (!f.exists()) {
+ f.mkdirs();
+ }
+ putenv("TMPDIR=" + f.getPath());
+ }
+
+ putenv("LANG=" + Locale.getDefault().toString());
+
+ final Class<?> crashHandler = GeckoAppShell.getCrashHandlerService();
+ if (crashHandler != null) {
+ putenv(
+ "MOZ_ANDROID_CRASH_HANDLER=" + context.getPackageName() + "/" + crashHandler.getName());
+ }
+
+ putenv("MOZ_ANDROID_DEVICE_SDK_VERSION=" + Build.VERSION.SDK_INT);
+ putenv("MOZ_ANDROID_CPU_ABI=" + Build.CPU_ABI);
+
+ // env from extras could have reset out linker flags; set them again.
+ loadLibsSetupLocked(context);
+ }
+
+ // Adapted from
+ // https://source.chromium.org/chromium/chromium/src/+/main:base/android/java/src/org/chromium/base/BundleUtils.java;l=196;drc=c0fedddd4a1444653235912cfae3d44b544ded01
+ private static String getLibraryPath(final String libraryName) {
+ // Due to b/171269960 isolated split class loaders have an empty library path, so check
+ // the base module class loader first which loaded GeckoAppShell. If the library is not
+ // found there, attempt to construct the correct library path from the split.
+ String path =
+ ((BaseDexClassLoader) GeckoAppShell.class.getClassLoader()).findLibrary(libraryName);
+ if (path != null) {
+ return path;
+ }
+
+ // SplitCompat is installed on the application context, so check there for library paths
+ // which were added to that ClassLoader.
+ final ClassLoader classLoader = GeckoAppShell.getApplicationContext().getClassLoader();
+ if (classLoader instanceof BaseDexClassLoader) {
+ path = ((BaseDexClassLoader) classLoader).findLibrary(libraryName);
+ if (path != null) {
+ return path;
+ }
+ }
+
+ throw new RuntimeException("Could not find mozglue path.");
+ }
+
+ private static String getLibraryBase() {
+ final String mozglue = getLibraryPath("mozglue");
+ final int lastSlash = mozglue.lastIndexOf('/');
+ if (lastSlash < 0) {
+ throw new IllegalStateException("Invalid library path for libmozglue.so: " + mozglue);
+ }
+ final String base = mozglue.substring(0, lastSlash);
+ Log.i(LOGTAG, "Library base=" + base);
+ return base;
+ }
+
+ private static void loadLibsSetupLocked(final Context context) {
+ putenv("GRE_HOME=" + getGREDir(context).getPath());
+ putenv("MOZ_ANDROID_LIBDIR=" + getLibraryBase());
+ }
+
+ @RobocopTarget
+ public static synchronized void loadSQLiteLibs(final Context context) {
+ if (sSQLiteLibsLoaded) {
+ return;
+ }
+
+ loadMozGlue(context);
+ loadLibsSetupLocked(context);
+ loadSQLiteLibsNative();
+ sSQLiteLibsLoaded = true;
+ }
+
+ public static synchronized void loadNSSLibs(final Context context) {
+ if (sNSSLibsLoaded) {
+ return;
+ }
+
+ loadMozGlue(context);
+ loadLibsSetupLocked(context);
+ loadNSSLibsNative();
+ sNSSLibsLoaded = true;
+ }
+
+ @SuppressWarnings("deprecation")
+ private static String getCPUABI() {
+ return android.os.Build.CPU_ABI;
+ }
+
+ /**
+ * Copy a library out of our APK.
+ *
+ * @param context a Context.
+ * @param lib the name of the library; e.g., "mozglue".
+ * @param outDir the output directory for the .so. No trailing slash.
+ * @return true on success, false on failure.
+ */
+ private static boolean extractLibrary(
+ final Context context, final String lib, final String outDir) {
+ final String apkPath = context.getApplicationInfo().sourceDir;
+
+ // Sanity check.
+ if (!apkPath.endsWith(".apk")) {
+ Log.w(LOGTAG, "sourceDir is not an APK.");
+ return false;
+ }
+
+ // Try to extract the named library from the APK.
+ final File outDirFile = new File(outDir);
+ if (!outDirFile.isDirectory()) {
+ if (!outDirFile.mkdirs()) {
+ Log.e(LOGTAG, "Couldn't create " + outDir);
+ return false;
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ final String[] abis = Build.SUPPORTED_ABIS;
+ for (final String abi : abis) {
+ if (tryLoadWithABI(lib, outDir, apkPath, abi)) {
+ return true;
+ }
+ }
+ return false;
+ } else {
+ final String abi = getCPUABI();
+ return tryLoadWithABI(lib, outDir, apkPath, abi);
+ }
+ }
+
+ private static boolean tryLoadWithABI(
+ final String lib, final String outDir, final String apkPath, final String abi) {
+ try {
+ final ZipFile zipFile = new ZipFile(new File(apkPath));
+ try {
+ final String libPath = "lib/" + abi + "/lib" + lib + ".so";
+ final ZipEntry entry = zipFile.getEntry(libPath);
+ if (entry == null) {
+ Log.w(LOGTAG, libPath + " not found in APK " + apkPath);
+ return false;
+ }
+
+ final InputStream in = zipFile.getInputStream(entry);
+ try {
+ final String outPath = outDir + "/lib" + lib + ".so";
+ final FileOutputStream out = new FileOutputStream(outPath);
+ final byte[] bytes = new byte[1024];
+ int read;
+
+ Log.d(LOGTAG, "Copying " + libPath + " to " + outPath);
+ boolean failed = false;
+ try {
+ while ((read = in.read(bytes, 0, 1024)) != -1) {
+ out.write(bytes, 0, read);
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Failing library copy.", e);
+ failed = true;
+ } finally {
+ out.close();
+ }
+
+ if (failed) {
+ // Delete the partial copy so we don't fail to load it.
+ // Don't bother to check the return value -- there's nothing
+ // we can do about a failure.
+ new File(outPath).delete();
+ } else {
+ // Mark the file as executable. This doesn't seem to be
+ // necessary for the loader, but it's the normal state of
+ // affairs.
+ Log.d(LOGTAG, "Marking " + outPath + " as executable.");
+ new File(outPath).setExecutable(true);
+ }
+
+ return !failed;
+ } finally {
+ in.close();
+ }
+ } finally {
+ zipFile.close();
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to extract lib from APK.", e);
+ return false;
+ }
+ }
+
+ private static boolean attemptLoad(final String path) {
+ try {
+ System.load(path);
+ return true;
+ } catch (final Throwable e) {
+ Log.wtf(LOGTAG, "Couldn't load " + path + ": " + e);
+ }
+
+ return false;
+ }
+
+ /**
+ * The first two attempts at loading a library: directly, and then using the app library path.
+ *
+ * <p>Returns null or the cause exception.
+ */
+ public static Throwable doLoadLibrary(final Context context, final String lib) {
+ try {
+ // Attempt 1: the way that should work.
+ System.loadLibrary(lib);
+ return null;
+ } catch (final Throwable e) {
+ final String libPath = getLibraryPath(lib);
+ // Does it even exist?
+ if (new File(libPath).exists()) {
+ if (attemptLoad(libPath)) {
+ // Success!
+ return null;
+ }
+ throw new RuntimeException(
+ "Library exists but couldn't load." + "Path: " + libPath + " lib: " + lib, e);
+ }
+ throw new RuntimeException(
+ "Library doesn't exist when it should." + "Path: " + libPath + " lib: " + lib, e);
+ }
+ }
+
+ public static synchronized void loadMozGlue(final Context context) {
+ if (sMozGlueLoaded) {
+ return;
+ }
+
+ doLoadLibrary(context, "mozglue");
+ sMozGlueLoaded = true;
+ }
+
+ public static synchronized void loadGeckoLibs(final Context context) {
+ loadLibsSetupLocked(context);
+ loadGeckoLibsNative();
+ }
+
+ @SuppressWarnings("serial")
+ public static class AbortException extends Exception {
+ public AbortException(final String msg) {
+ super(msg);
+ }
+ }
+
+ @JNITarget
+ public static void abort(final String msg) {
+ final Thread thread = Thread.currentThread();
+ final Thread.UncaughtExceptionHandler uncaughtHandler = thread.getUncaughtExceptionHandler();
+ if (uncaughtHandler != null) {
+ uncaughtHandler.uncaughtException(thread, new AbortException(msg));
+ }
+ }
+
+ // These methods are implemented in mozglue/android/nsGeckoUtils.cpp
+ private static native void putenv(String map);
+
+ // These methods are implemented in mozglue/android/APKOpen.cpp
+ public static native void nativeRun(
+ String[] args,
+ int prefsFd,
+ int prefMapFd,
+ int ipcFd,
+ int crashFd,
+ int crashAnnotationFd,
+ boolean xpcshell,
+ String outFilePath);
+
+ private static native void loadGeckoLibsNative();
+
+ private static native void loadSQLiteLibsNative();
+
+ private static native void loadNSSLibsNative();
+
+ public static native void suppressCrashDialog();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java
new file mode 100644
index 0000000000..3b0f8cc96b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java
@@ -0,0 +1,20 @@
+/* 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/. */
+
+package org.mozilla.gecko.mozglue;
+
+// Class that all classes with native methods extend from.
+public abstract class JNIObject {
+ // Pointer that references the native object. This is volatile because it may be accessed
+ // by multiple threads simultaneously.
+ private volatile long mHandle;
+
+ // Dispose of any reference to a native object.
+ //
+ // If the native instance is destroyed from the native side, this should never be
+ // called, so you should throw an UnsupportedOperationException. If instead you
+ // want to destroy the native side from the Java end, make override this with
+ // a native call, and the right thing will be done in the native code.
+ protected abstract void disposeNative();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java
new file mode 100644
index 0000000000..028cfd6590
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java
@@ -0,0 +1,12 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.mozglue;
+
+public interface NativeReference {
+ public void release();
+
+ public boolean isReleased();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java
new file mode 100644
index 0000000000..76dd99ce10
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java
@@ -0,0 +1,190 @@
+/* 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/. */
+
+package org.mozilla.gecko.mozglue;
+
+import android.os.MemoryFile;
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+import android.util.Log;
+import java.io.FileDescriptor;
+import java.io.IOException;
+import java.lang.reflect.Method;
+
+public class SharedMemory implements Parcelable {
+ private static final String LOGTAG = "GeckoShmem";
+ private static final Method sGetFDMethod;
+ private ParcelFileDescriptor mDescriptor;
+ private int mSize;
+ private int mId;
+ private long mHandle; // The native pointer.
+ private boolean mIsMapped;
+ private MemoryFile mBackedFile;
+
+ // MemoryFile.getFileDescriptor() is hidden. :(
+ static {
+ Method method = null;
+ try {
+ method = MemoryFile.class.getDeclaredMethod("getFileDescriptor");
+ } catch (final NoSuchMethodException e) {
+ e.printStackTrace();
+ }
+ sGetFDMethod = method;
+ }
+
+ private SharedMemory(final Parcel in) {
+ mDescriptor = in.readFileDescriptor();
+ mSize = in.readInt();
+ mId = in.readInt();
+ }
+
+ public static final Creator<SharedMemory> CREATOR =
+ new Creator<SharedMemory>() {
+ @Override
+ public SharedMemory createFromParcel(final Parcel in) {
+ return new SharedMemory(in);
+ }
+
+ @Override
+ public SharedMemory[] newArray(final int size) {
+ return new SharedMemory[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return CONTENTS_FILE_DESCRIPTOR;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ // We don't want ParcelFileDescriptor.writeToParcel() to close the fd.
+ dest.writeFileDescriptor(mDescriptor.getFileDescriptor());
+ dest.writeInt(mSize);
+ dest.writeInt(mId);
+ }
+
+ public SharedMemory(final int id, final int size) throws NoSuchMethodException, IOException {
+ if (sGetFDMethod == null) {
+ throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist.");
+ }
+ mBackedFile = new MemoryFile(null, size);
+ try {
+ final FileDescriptor fd = (FileDescriptor) sGetFDMethod.invoke(mBackedFile);
+ mDescriptor = ParcelFileDescriptor.dup(fd);
+ mSize = size;
+ mId = id;
+ mBackedFile.allowPurging(false);
+ } catch (final Exception e) {
+ e.printStackTrace();
+ close();
+ throw new IOException(e.getMessage());
+ }
+ }
+
+ public void flush() {
+ if (!mIsMapped) {
+ return;
+ }
+
+ unmap(mHandle, mSize);
+ mHandle = 0;
+ mIsMapped = false;
+ }
+
+ public void close() {
+ flush();
+
+ if (mDescriptor != null) {
+ try {
+ mDescriptor.close();
+ } catch (final IOException e) {
+ e.printStackTrace();
+ }
+ mDescriptor = null;
+ }
+ }
+
+ // Should only be called by process that allocates shared memory.
+ public void dispose() {
+ if (!isValid()) {
+ return;
+ }
+
+ close();
+
+ if (mBackedFile != null) {
+ mBackedFile.close();
+ mBackedFile = null;
+ }
+ }
+
+ private native void unmap(long address, int size);
+
+ public boolean isValid() {
+ return mDescriptor != null;
+ }
+
+ public int getSize() {
+ return mSize;
+ }
+
+ private int getFD() {
+ return isValid() ? mDescriptor.getFd() : -1;
+ }
+
+ public long getPointer() {
+ if (!isValid()) {
+ return 0;
+ }
+
+ if (!mIsMapped) {
+ try {
+ mHandle = map(getFD(), mSize);
+ } catch (final NullPointerException e) {
+ Log.e(LOGTAG, "SharedMemory#" + mId + " error.", e);
+ throw e;
+ }
+ if (mHandle != 0) {
+ mIsMapped = true;
+ }
+ }
+ return mHandle;
+ }
+
+ private native long map(int fd, int size);
+
+ @Override
+ protected void finalize() throws Throwable {
+ if (mBackedFile != null) {
+ Log.w(LOGTAG, "dispose() not called before finalizing");
+ }
+ dispose();
+
+ super.finalize();
+ }
+
+ @Override
+ public String toString() {
+ return "SHM("
+ + getSize()
+ + " bytes): id="
+ + mId
+ + ", backing="
+ + mBackedFile
+ + ",fd="
+ + mDescriptor;
+ }
+
+ @Override
+ public boolean equals(final Object that) {
+ return (this == that) || ((that instanceof SharedMemory) && (hashCode() == that.hashCode()));
+ }
+
+ @Override
+ public int hashCode() {
+ return mId;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja
new file mode 100644
index 0000000000..fa2f336566
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.process;
+
+public class GeckoChildProcessServices {
+ /* package */ static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = {{MOZ_ANDROID_CONTENT_SERVICE_COUNT}};
+ public static final class gmplugin extends GeckoServiceChildProcess {}
+ public static final class socket extends GeckoServiceChildProcess {}
+ public static final class gpu extends GeckoServiceGpuProcess {}
+ public static final class utility extends GeckoServiceChildProcess {}
+ public static final class ipdlunittest extends GeckoServiceChildProcess {}
+
+{% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %}
+ public static final class tab{{ id }} extends GeckoServiceChildProcess {}
+{% endfor %}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java
new file mode 100644
index 0000000000..039396f9e8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java
@@ -0,0 +1,927 @@
+/* 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+import androidx.collection.SimpleArrayMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.GeckoThread.FileDescriptors;
+import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.TelemetryUtils;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.CompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.gfx.RemoteSurfaceAllocator;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.process.ServiceAllocator.PriorityLevel;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+import org.mozilla.geckoview.GeckoResult;
+
+public final class GeckoProcessManager extends IProcessManager.Stub {
+ private static final String LOGTAG = "GeckoProcessManager";
+ private static final GeckoProcessManager INSTANCE = new GeckoProcessManager();
+ private static final int INVALID_PID = 0;
+
+ // This id univocally identifies the current process manager instance
+ private final String mInstanceId;
+
+ public static GeckoProcessManager getInstance() {
+ return INSTANCE;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void setEditableChildParent(
+ final IGeckoEditableChild child, final IGeckoEditableParent parent) {
+ try {
+ child.transferParent(parent);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Cannot set parent", e);
+ }
+ }
+
+ @WrapForJNI(stubName = "GetEditableParent", dispatchTo = "gecko")
+ private static native void nativeGetEditableParent(
+ IGeckoEditableChild child, long contentId, long tabId);
+
+ @Override // IProcessManager
+ public void getEditableParent(
+ final IGeckoEditableChild child, final long contentId, final long tabId) {
+ nativeGetEditableParent(child, contentId, tabId);
+ }
+
+ /**
+ * Returns the surface allocator interface to be used by child processes to allocate Surfaces. The
+ * service bound to the returned interface may live in either the GPU process or parent process.
+ */
+ @Override // IProcessManager
+ public ISurfaceAllocator getSurfaceAllocator() {
+ final GeckoResult<Boolean> gpuEnabled = GeckoAppShell.isGpuProcessEnabled();
+
+ try {
+ final GeckoResult<ISurfaceAllocator> allocator = new GeckoResult<>();
+ if (gpuEnabled.poll(1000)) {
+ // The GPU process is enabled, so look it up and ask it for its surface allocator.
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ final Selector selector = new Selector(GeckoProcessType.GPU);
+ final GpuProcessConnection conn =
+ (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn != null) {
+ allocator.complete(conn.getSurfaceAllocator());
+ } else {
+ // If we cannot find a GPU process, it has probably been killed and not yet
+ // restarted. Return null here, and allow the caller to try again later.
+ // We definitely do *not* want to return the parent process allocator instead, as
+ // that will result in surfaces being allocated in the parent process, which
+ // therefore won't be usable when the GPU process is eventually launched.
+ allocator.complete(null);
+ }
+ });
+ } else {
+ // The GPU process is disabled, so return the parent process allocator instance.
+ allocator.complete(RemoteSurfaceAllocator.getInstance(0));
+ }
+ return allocator.poll(100);
+ } catch (final Throwable e) {
+ Log.e(LOGTAG, "Error in getSurfaceAllocator", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI
+ public static CompositorSurfaceManager getCompositorSurfaceManager() {
+ final Selector selector = new Selector(GeckoProcessType.GPU);
+ final GpuProcessConnection conn =
+ (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return null;
+ }
+ return conn.getCompositorSurfaceManager();
+ }
+
+ /** Gecko uses this class to uniquely identify a process managed by GeckoProcessManager. */
+ public static final class Selector {
+ private final GeckoProcessType mType;
+ private final int mPid;
+
+ @WrapForJNI
+ private Selector(@NonNull final GeckoProcessType type, final int pid) {
+ if (pid == INVALID_PID) {
+ throw new RuntimeException("Invalid PID");
+ }
+
+ mType = type;
+ mPid = pid;
+ }
+
+ @WrapForJNI
+ private Selector(@NonNull final GeckoProcessType type) {
+ mType = type;
+ mPid = INVALID_PID;
+ }
+
+ public GeckoProcessType getType() {
+ return mType;
+ }
+
+ public int getPid() {
+ return mPid;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (obj == null) {
+ return false;
+ }
+
+ if (obj == ((Object) this)) {
+ return true;
+ }
+
+ final Selector other = (Selector) obj;
+ return mType == other.mType && mPid == other.mPid;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {mType, mPid});
+ }
+ }
+
+ private static final class IncompleteChildConnectionException extends RuntimeException {
+ public IncompleteChildConnectionException(@NonNull final String msg) {
+ super(msg);
+ }
+ }
+
+ /**
+ * Maintains state pertaining to an individual child process. Inheriting from
+ * ServiceAllocator.InstanceInfo enables this class to work with ServiceAllocator.
+ */
+ private static class ChildConnection extends ServiceAllocator.InstanceInfo {
+ private IChildProcess mChild;
+ private GeckoResult<IChildProcess> mPendingBind;
+ private int mPid;
+
+ protected ChildConnection(
+ @NonNull final ServiceAllocator allocator,
+ @NonNull final GeckoProcessType type,
+ @NonNull final PriorityLevel initialPriority) {
+ super(allocator, type, initialPriority);
+ mPid = INVALID_PID;
+ }
+
+ public int getPid() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (mChild == null) {
+ throw new IncompleteChildConnectionException(
+ "Calling ChildConnection.getPid() on an incomplete connection");
+ }
+
+ return mPid;
+ }
+
+ private GeckoResult<IChildProcess> completeFailedBind(
+ @NonNull final ServiceAllocator.BindException e) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ Log.e(LOGTAG, "Failed bind", e);
+
+ if (mPendingBind == null) {
+ throw new IllegalStateException("Bind failed with null mPendingBind");
+ }
+
+ final GeckoResult<IChildProcess> bindResult = mPendingBind;
+ mPendingBind = null;
+ unbind().accept(v -> bindResult.completeExceptionally(e));
+ return bindResult;
+ }
+
+ public GeckoResult<IChildProcess> bind() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mChild != null) {
+ // Already bound
+ return GeckoResult.fromValue(mChild);
+ }
+
+ if (mPendingBind != null) {
+ // Bind in progress
+ return mPendingBind;
+ }
+
+ mPendingBind = new GeckoResult<>();
+ try {
+ if (!bindService()) {
+ throw new ServiceAllocator.BindException("Cannot connect to process");
+ }
+ } catch (final ServiceAllocator.BindException e) {
+ return completeFailedBind(e);
+ }
+
+ return mPendingBind;
+ }
+
+ public GeckoResult<Void> unbind() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mPendingBind != null) {
+ // We called unbind() while bind() was still pending completion
+ return mPendingBind.then(child -> unbind());
+ }
+
+ if (mChild == null) {
+ // Not bound in the first place
+ return GeckoResult.fromValue(null);
+ }
+
+ unbindService();
+
+ return GeckoResult.fromValue(null);
+ }
+
+ @Override
+ protected void onBinderConnected(final IBinder service) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final IChildProcess child = IChildProcess.Stub.asInterface(service);
+ try {
+ mPid = child.getPid();
+ onBinderConnected(child);
+ } catch (final DeadObjectException e) {
+ unbindService();
+
+ // mPendingBind might be null if a bind was initiated by the system (eg Service Restart)
+ if (mPendingBind != null) {
+ mPendingBind.completeExceptionally(e);
+ mPendingBind = null;
+ }
+
+ return;
+ } catch (final RemoteException e) {
+ throw new RuntimeException(e);
+ }
+
+ mChild = child;
+ GeckoProcessManager.INSTANCE.mConnections.onBindComplete(this);
+
+ // mPendingBind might be null if a bind was initiated by the system (eg Service Restart)
+ if (mPendingBind != null) {
+ mPendingBind.complete(mChild);
+ mPendingBind = null;
+ }
+ }
+
+ // Subclasses of ChildConnection can override this method to make any IChildProcess calls
+ // specific to their process type immediately after connection.
+ protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {}
+
+ @Override
+ protected void onReleaseResources() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ // NB: This must happen *before* resetting mPid!
+ GeckoProcessManager.INSTANCE.mConnections.removeConnection(this);
+
+ mChild = null;
+ mPid = INVALID_PID;
+ }
+ }
+
+ private static class NonContentConnection extends ChildConnection {
+ public NonContentConnection(
+ @NonNull final ServiceAllocator allocator, @NonNull final GeckoProcessType type) {
+ super(allocator, type, PriorityLevel.FOREGROUND);
+ if (type == GeckoProcessType.CONTENT) {
+ throw new AssertionError("Attempt to create a NonContentConnection as CONTENT");
+ }
+ }
+
+ protected void onAppForeground() {
+ setPriorityLevel(PriorityLevel.FOREGROUND);
+ }
+
+ protected void onAppBackground() {
+ setPriorityLevel(PriorityLevel.IDLE);
+ }
+ }
+
+ private static final class GpuProcessConnection extends NonContentConnection {
+ private CompositorSurfaceManager mCompositorSurfaceManager;
+ private ISurfaceAllocator mSurfaceAllocator;
+
+ // Unique ID used to identify each GPU process instance. Will always be non-zero,
+ // and unlike the process' pid cannot be the same value for successive instances.
+ private int mUniqueGpuProcessId;
+ // Static counter used to initialize each instance's mUniqueGpuProcessId
+ private static int sUniqueGpuProcessIdCounter = 0;
+
+ public GpuProcessConnection(@NonNull final ServiceAllocator allocator) {
+ super(allocator, GeckoProcessType.GPU);
+
+ // Initialize the unique ID ensuring we skip 0 (as that is reserved for parent process
+ // allocators).
+ if (sUniqueGpuProcessIdCounter == 0) {
+ sUniqueGpuProcessIdCounter++;
+ }
+ mUniqueGpuProcessId = sUniqueGpuProcessIdCounter++;
+ }
+
+ @Override
+ protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {
+ mCompositorSurfaceManager = new CompositorSurfaceManager(child.getCompositorSurfaceManager());
+ mSurfaceAllocator = child.getSurfaceAllocator(mUniqueGpuProcessId);
+ }
+
+ public CompositorSurfaceManager getCompositorSurfaceManager() {
+ return mCompositorSurfaceManager;
+ }
+
+ public ISurfaceAllocator getSurfaceAllocator() {
+ return mSurfaceAllocator;
+ }
+ }
+
+ private static final class SocketProcessConnection extends NonContentConnection {
+ private boolean mIsForeground = true;
+ private boolean mIsNetworkUp = true;
+
+ public SocketProcessConnection(@NonNull final ServiceAllocator allocator) {
+ super(allocator, GeckoProcessType.SOCKET);
+ GeckoProcessManager.INSTANCE.mConnections.enableNetworkNotifications();
+ }
+
+ public void onNetworkStateChange(final boolean isNetworkUp) {
+ mIsNetworkUp = isNetworkUp;
+ prioritize();
+ }
+
+ @Override
+ protected void onAppForeground() {
+ mIsForeground = true;
+ prioritize();
+ }
+
+ @Override
+ protected void onAppBackground() {
+ mIsForeground = false;
+ prioritize();
+ }
+
+ private static final PriorityLevel[][] sPriorityStates = initPriorityStates();
+
+ private static PriorityLevel[][] initPriorityStates() {
+ final PriorityLevel[][] states = new PriorityLevel[2][2];
+ // Background, no network
+ states[0][0] = PriorityLevel.IDLE;
+ // Background, network
+ states[0][1] = PriorityLevel.BACKGROUND;
+ // Foreground, no network
+ states[1][0] = PriorityLevel.IDLE;
+ // Foreground, network
+ states[1][1] = PriorityLevel.FOREGROUND;
+ return states;
+ }
+
+ private void prioritize() {
+ final PriorityLevel nextPriority =
+ sPriorityStates[mIsForeground ? 1 : 0][mIsNetworkUp ? 1 : 0];
+ setPriorityLevel(nextPriority);
+ }
+ }
+
+ private static final class ContentConnection extends ChildConnection {
+ private static final String TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME =
+ "GV_CONTENT_PROCESS_LIFETIME_MS";
+
+ private TelemetryUtils.UptimeTimer mLifetimeTimer = null;
+
+ public ContentConnection(
+ @NonNull final ServiceAllocator allocator, @NonNull final PriorityLevel initialPriority) {
+ super(allocator, GeckoProcessType.CONTENT, initialPriority);
+ }
+
+ @Override
+ protected void onBinderConnected(final IBinder service) {
+ mLifetimeTimer = new TelemetryUtils.UptimeTimer(TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME);
+ super.onBinderConnected(service);
+ }
+
+ @Override
+ protected void onReleaseResources() {
+ if (mLifetimeTimer != null) {
+ mLifetimeTimer.stop();
+ mLifetimeTimer = null;
+ }
+
+ super.onReleaseResources();
+ }
+ }
+
+ /** This class manages the state surrounding existing connections and their priorities. */
+ private static final class ConnectionManager extends JNIObject {
+ // Connections to non-content processes
+ private final ArrayMap<GeckoProcessType, NonContentConnection> mNonContentConnections;
+ // Mapping of pid to content process
+ private final SimpleArrayMap<Integer, ContentConnection> mContentPids;
+ // Set of initialized content process connections
+ private final ArraySet<ContentConnection> mContentConnections;
+ // Set of bound but uninitialized content connections
+ private final ArraySet<ContentConnection> mNonStartedContentConnections;
+ // Allocator for service IDs
+ private final ServiceAllocator mServiceAllocator;
+ private boolean mIsObservingNetwork = false;
+
+ public ConnectionManager() {
+ mNonContentConnections = new ArrayMap<GeckoProcessType, NonContentConnection>();
+ mContentPids = new SimpleArrayMap<Integer, ContentConnection>();
+ mContentConnections = new ArraySet<ContentConnection>();
+ mNonStartedContentConnections = new ArraySet<ContentConnection>();
+ mServiceAllocator = new ServiceAllocator();
+
+ // Attach to native once JNI is ready.
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ attachTo(this);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.JNI_READY, ConnectionManager.class, "attachTo", this);
+ }
+ }
+
+ private void enableNetworkNotifications() {
+ if (mIsObservingNetwork) {
+ return;
+ }
+
+ mIsObservingNetwork = true;
+
+ // Ensure that GeckoNetworkManager is monitoring network events so that we can
+ // prioritize the socket process.
+ ThreadUtils.runOnUiThread(
+ () -> {
+ GeckoNetworkManager.getInstance().enableNotifications();
+ });
+
+ observeNetworkNotifications();
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void attachTo(ConnectionManager instance);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void observeNetworkNotifications();
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onBackground() {
+ XPCOMEventTarget.runOnLauncherThread(() -> onAppBackgroundInternal());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onForeground() {
+ XPCOMEventTarget.runOnLauncherThread(() -> onAppForegroundInternal());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onNetworkStateChange(final boolean isUp) {
+ XPCOMEventTarget.runOnLauncherThread(() -> onNetworkStateChangeInternal(isUp));
+ }
+
+ @Override
+ protected native void disposeNative();
+
+ private void onAppBackgroundInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ for (final NonContentConnection conn : mNonContentConnections.values()) {
+ conn.onAppBackground();
+ }
+ }
+
+ private void onAppForegroundInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ for (final NonContentConnection conn : mNonContentConnections.values()) {
+ conn.onAppForeground();
+ }
+ }
+
+ private void onNetworkStateChangeInternal(final boolean isUp) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final SocketProcessConnection conn =
+ (SocketProcessConnection) mNonContentConnections.get(GeckoProcessType.SOCKET);
+ if (conn == null) {
+ return;
+ }
+
+ conn.onNetworkStateChange(isUp);
+ }
+
+ private void removeContentConnection(@NonNull final ChildConnection conn) {
+ if (!mContentConnections.remove(conn)) {
+ throw new RuntimeException("Attempt to remove non-registered connection");
+ }
+ mNonStartedContentConnections.remove(conn);
+
+ final int pid;
+
+ try {
+ pid = conn.getPid();
+ } catch (final IncompleteChildConnectionException e) {
+ // conn lost its binding before it was able to retrieve its pid. It follows that
+ // mContentPids does not have an entry for this connection, so we can just return.
+ return;
+ }
+
+ if (pid == INVALID_PID) {
+ return;
+ }
+
+ final ChildConnection removed = mContentPids.remove(Integer.valueOf(pid));
+ if (removed != null && removed != conn) {
+ throw new RuntimeException(
+ "Integrity error - connection mismatch for pid " + Integer.toString(pid));
+ }
+ }
+
+ public void removeConnection(@NonNull final ChildConnection conn) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (conn.getType() == GeckoProcessType.CONTENT) {
+ removeContentConnection(conn);
+ return;
+ }
+
+ final ChildConnection removed = mNonContentConnections.remove(conn.getType());
+ if (removed != conn) {
+ throw new RuntimeException(
+ "Integrity error - connection mismatch for process type " + conn.getType().toString());
+ }
+ }
+
+ /** Saves any state information that was acquired upon start completion. */
+ public void onBindComplete(@NonNull final ChildConnection conn) {
+ if (conn.getType() == GeckoProcessType.CONTENT) {
+ final int pid = conn.getPid();
+ if (pid == INVALID_PID) {
+ throw new AssertionError(
+ "PID is invalid even though our caller just successfully retrieved it after binding");
+ }
+
+ mContentPids.put(pid, (ContentConnection) conn);
+ }
+ }
+
+ /** Retrieve the ChildConnection for an already running content process. */
+ private ContentConnection getExistingContentConnection(@NonNull final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (selector.getType() != GeckoProcessType.CONTENT) {
+ throw new IllegalArgumentException("Selector is not for content!");
+ }
+
+ return mContentPids.get(selector.getPid());
+ }
+
+ /** Unconditionally create a new content connection for the specified priority. */
+ private ContentConnection getNewContentConnection(@NonNull final PriorityLevel newPriority) {
+ final ContentConnection result = new ContentConnection(mServiceAllocator, newPriority);
+ mContentConnections.add(result);
+
+ return result;
+ }
+
+ /** Retrieve the ChildConnection for an already running child process of any type. */
+ public ChildConnection getExistingConnection(@NonNull final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final GeckoProcessType type = selector.getType();
+
+ if (type == GeckoProcessType.CONTENT) {
+ return getExistingContentConnection(selector);
+ }
+
+ return mNonContentConnections.get(type);
+ }
+
+ /**
+ * Retrieve a ChildConnection for a content process for the purposes of starting. If there are
+ * any preloaded content processes already running, we will use one of those. Otherwise we will
+ * allocate a new ChildConnection.
+ */
+ private ChildConnection getContentConnectionForStart() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mNonStartedContentConnections.isEmpty()) {
+ return getNewContentConnection(PriorityLevel.FOREGROUND);
+ }
+
+ final ChildConnection conn =
+ mNonStartedContentConnections.removeAt(mNonStartedContentConnections.size() - 1);
+ conn.setPriorityLevel(PriorityLevel.FOREGROUND);
+ return conn;
+ }
+
+ /** Retrieve or create a new child process for the specified non-content process. */
+ private ChildConnection getNonContentConnection(@NonNull final GeckoProcessType type) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (type == GeckoProcessType.CONTENT) {
+ throw new IllegalArgumentException("Content processes not supported by this method");
+ }
+
+ NonContentConnection connection = mNonContentConnections.get(type);
+ if (connection == null) {
+ if (type == GeckoProcessType.SOCKET) {
+ connection = new SocketProcessConnection(mServiceAllocator);
+ } else if (type == GeckoProcessType.GPU) {
+ connection = new GpuProcessConnection(mServiceAllocator);
+ } else {
+ connection = new NonContentConnection(mServiceAllocator, type);
+ }
+
+ mNonContentConnections.put(type, connection);
+ }
+
+ return connection;
+ }
+
+ /** Retrieve a ChildConnection for the purposes of starting a new child process. */
+ public ChildConnection getConnectionForStart(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ return getContentConnectionForStart();
+ }
+
+ return getNonContentConnection(type);
+ }
+
+ /** Retrieve a ChildConnection for the purposes of preloading a new child process. */
+ public ChildConnection getConnectionForPreload(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ final ContentConnection conn = getNewContentConnection(PriorityLevel.BACKGROUND);
+ mNonStartedContentConnections.add(conn);
+ return conn;
+ }
+
+ return getNonContentConnection(type);
+ }
+ }
+
+ private final ConnectionManager mConnections;
+
+ private GeckoProcessManager() {
+ mConnections = new ConnectionManager();
+ mInstanceId = UUID.randomUUID().toString();
+ }
+
+ public void preload(final GeckoProcessType... types) {
+ XPCOMEventTarget.launcherThread()
+ .execute(
+ () -> {
+ for (final GeckoProcessType type : types) {
+ final ChildConnection connection = mConnections.getConnectionForPreload(type);
+ connection.bind();
+ }
+ });
+ }
+
+ public void crashChild(@NonNull final Selector selector) {
+ XPCOMEventTarget.launcherThread()
+ .execute(
+ () -> {
+ final ChildConnection conn = mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.bind()
+ .accept(
+ proc -> {
+ try {
+ proc.crash();
+ } catch (final RemoteException e) {
+ }
+ });
+ });
+ }
+
+ @WrapForJNI
+ private static void shutdownProcess(final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.unbind();
+ }
+
+ @WrapForJNI
+ private static void setProcessPriority(
+ @NonNull final Selector selector,
+ @NonNull final PriorityLevel priorityLevel,
+ final int relativeImportance) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.setPriorityLevel(priorityLevel, relativeImportance);
+ });
+ }
+
+ @WrapForJNI
+ private static GeckoResult<Integer> start(
+ final GeckoProcessType type,
+ final String[] args,
+ final int prefsFd,
+ final int prefMapFd,
+ final int ipcFd,
+ final int crashFd,
+ final int crashAnnotationFd) {
+ final GeckoResult<Integer> result = new GeckoResult<>();
+ final StartInfo info =
+ new StartInfo(
+ type,
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .userSerialNumber(System.getenv("MOZ_ANDROID_USER_SERIAL_NUMBER"))
+ .extras(GeckoThread.getActiveExtras())
+ .flags(filterFlagsForChild(GeckoThread.getActiveFlags()))
+ .fds(
+ FileDescriptors.builder()
+ .prefs(prefsFd)
+ .prefMap(prefMapFd)
+ .ipc(ipcFd)
+ .crashReporter(crashFd)
+ .crashAnnotation(crashAnnotationFd)
+ .build())
+ .build());
+
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ INSTANCE
+ .start(info)
+ .accept(result::complete, result::completeExceptionally)
+ .finally_(info.pfds::close);
+ });
+
+ return result;
+ }
+
+ private static int filterFlagsForChild(final int flags) {
+ return flags & GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ }
+
+ private static class StartInfo {
+ final GeckoProcessType type;
+ final String crashHandler;
+ final GeckoThread.InitInfo init;
+
+ final ParcelFileDescriptors pfds;
+
+ private StartInfo(final GeckoProcessType type, final GeckoThread.InitInfo initInfo) {
+ this.type = type;
+ this.init = initInfo;
+ crashHandler =
+ GeckoAppShell.getCrashHandlerService() != null
+ ? GeckoAppShell.getCrashHandlerService().getName()
+ : null;
+ // The native side owns the File Descriptors so we cannot call adopt here.
+ pfds = ParcelFileDescriptors.from(initInfo.fds);
+ }
+ }
+
+ private static final int MAX_RETRIES = 3;
+
+ private GeckoResult<Integer> start(final StartInfo info) {
+ return start(info, new ArrayList<>());
+ }
+
+ private GeckoResult<Integer> retry(
+ final StartInfo info, final List<Throwable> retryLog, final Throwable error) {
+ retryLog.add(error);
+
+ if (error instanceof StartException) {
+ final StartException startError = (StartException) error;
+ if (startError.errorCode == IChildProcess.STARTED_BUSY) {
+ // This process is owned by a different runtime, so we can't use
+ // it. We will keep retrying indefinitely until we find a non-busy process.
+ // Note: this strategy is pretty bad, we go through each process in
+ // sequence until one works, the multiple runtime case is test-only
+ // for now, so that's ok. We can improve on this if we eventually
+ // end up needing something fancier.
+ return start(info, retryLog);
+ }
+ }
+
+ // If we couldn't unbind there's something very wrong going on and we bail
+ // immediately.
+ if (retryLog.size() >= MAX_RETRIES || error instanceof UnbindException) {
+ return GeckoResult.fromException(fromRetryLog(retryLog));
+ }
+
+ return start(info, retryLog);
+ }
+
+ private String serializeLog(final List<Throwable> retryLog) {
+ if (retryLog == null || retryLog.size() == 0) {
+ return "Empty log.";
+ }
+
+ final StringBuilder message = new StringBuilder();
+
+ for (final Throwable error : retryLog) {
+ if (error instanceof UnbindException) {
+ message.append("Could not unbind: ");
+ } else if (error instanceof StartException) {
+ message.append("Cannot restart child: ");
+ } else {
+ message.append("Error while binding: ");
+ }
+ message.append(error);
+ message.append(";");
+ }
+
+ return message.toString();
+ }
+
+ private RuntimeException fromRetryLog(final List<Throwable> retryLog) {
+ return new RuntimeException(serializeLog(retryLog), retryLog.get(retryLog.size() - 1));
+ }
+
+ private GeckoResult<Integer> start(final StartInfo info, final List<Throwable> retryLog) {
+ return startInternal(info).then(GeckoResult::fromValue, error -> retry(info, retryLog, error));
+ }
+
+ private static class StartException extends RuntimeException {
+ public final int errorCode;
+
+ public StartException(final int errorCode, final int pid) {
+ super("Could not start process, errorCode: " + errorCode + " PID: " + pid);
+ this.errorCode = errorCode;
+ }
+ }
+
+ private GeckoResult<Integer> startInternal(final StartInfo info) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final ChildConnection connection = mConnections.getConnectionForStart(info.type);
+ return connection
+ .bind()
+ .map(
+ child -> {
+ final int result =
+ child.start(
+ this,
+ mInstanceId,
+ info.init.args,
+ info.init.extras,
+ info.init.flags,
+ info.init.userSerialNumber,
+ info.crashHandler,
+ info.pfds.prefs,
+ info.pfds.prefMap,
+ info.pfds.ipc,
+ info.pfds.crashReporter,
+ info.pfds.crashAnnotation);
+ if (result == IChildProcess.STARTED_OK) {
+ return connection.getPid();
+ } else {
+ throw new StartException(result, connection.getPid());
+ }
+ })
+ .then(GeckoResult::fromValue, error -> handleBindError(connection, error));
+ }
+
+ private GeckoResult<Integer> handleBindError(
+ final ChildConnection connection, final Throwable error) {
+ return connection
+ .unbind()
+ .then(
+ unused -> GeckoResult.fromException(error),
+ unbindError -> GeckoResult.fromException(new UnbindException(unbindError)));
+ }
+
+ private static class UnbindException extends RuntimeException {
+ public UnbindException(final Throwable cause) {
+ super(cause);
+ }
+ }
+} // GeckoProcessManager
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java
new file mode 100644
index 0000000000..812a27614c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java
@@ -0,0 +1,40 @@
+/* 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/. */
+
+package org.mozilla.gecko.process;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+@WrapForJNI
+public enum GeckoProcessType {
+ // These need to match the stringified names from the GeckoProcessType enum
+ PARENT("default"),
+ PLUGIN("plugin"),
+ CONTENT("tab"),
+ IPDLUNITTEST("ipdlunittest"),
+ GMPLUGIN("gmplugin"),
+ GPU("gpu"),
+ VR("vr"),
+ RDD("rdd"),
+ SOCKET("socket"),
+ REMOTESANDBOXBROKER("sandboxbroker"),
+ FORKSERVER("forkserver"),
+ UTILITY("utility");
+
+ private final String mGeckoName;
+
+ private GeckoProcessType(final String geckoName) {
+ mGeckoName = geckoName;
+ }
+
+ @Override
+ public String toString() {
+ return mGeckoName;
+ }
+
+ @WrapForJNI
+ private static final GeckoProcessType fromInt(final int type) {
+ return values()[type];
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java
new file mode 100644
index 0000000000..e030a47c74
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java
@@ -0,0 +1,213 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.Log;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.GeckoThread.FileDescriptors;
+import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.ICompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class GeckoServiceChildProcess extends Service {
+ private static final String LOGTAG = "ServiceChildProcess";
+
+ private static IProcessManager sProcessManager;
+ private static String sOwnerProcessId;
+ private final MemoryController mMemoryController = new MemoryController();
+
+ // Makes sure we don't reuse this process
+ private static boolean sCreateCalled;
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void getEditableParent(
+ final IGeckoEditableChild child, final long contentId, final long tabId) {
+ try {
+ sProcessManager.getEditableParent(child, contentId, tabId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Cannot get editable", e);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.i(LOGTAG, "onCreate");
+
+ if (sCreateCalled) {
+ // We don't support reusing processes, and this could get us in a really weird state,
+ // so let's throw here.
+ throw new RuntimeException("Cannot reuse process.");
+ }
+ sCreateCalled = true;
+
+ GeckoAppShell.setApplicationContext(getApplicationContext());
+ GeckoThread.launch(); // Preload Gecko.
+ }
+
+ protected static class ChildProcessBinder extends IChildProcess.Stub {
+ @Override
+ public int getPid() {
+ return Process.myPid();
+ }
+
+ @Override
+ public int start(
+ final IProcessManager procMan,
+ final String mainProcessId,
+ final String[] args,
+ final Bundle extras,
+ final int flags,
+ final String userSerialNumber,
+ final String crashHandlerService,
+ final ParcelFileDescriptor prefsPfd,
+ final ParcelFileDescriptor prefMapPfd,
+ final ParcelFileDescriptor ipcPfd,
+ final ParcelFileDescriptor crashReporterPfd,
+ final ParcelFileDescriptor crashAnnotationPfd) {
+
+ final ParcelFileDescriptors pfds =
+ ParcelFileDescriptors.builder()
+ .prefs(prefsPfd)
+ .prefMap(prefMapPfd)
+ .ipc(ipcPfd)
+ .crashReporter(crashReporterPfd)
+ .crashAnnotation(crashAnnotationPfd)
+ .build();
+
+ synchronized (GeckoServiceChildProcess.class) {
+ if (sOwnerProcessId != null && !sOwnerProcessId.equals(mainProcessId)) {
+ Log.w(
+ LOGTAG,
+ "This process belongs to a different GeckoRuntime owner: "
+ + sOwnerProcessId
+ + " process: "
+ + mainProcessId);
+ // We need to close the File Descriptors here otherwise we will leak them causing a
+ // shutdown hang.
+ pfds.close();
+ return IChildProcess.STARTED_BUSY;
+ }
+ if (sProcessManager != null) {
+ Log.e(LOGTAG, "Child process already started");
+ pfds.close();
+ return IChildProcess.STARTED_FAIL;
+ }
+ sProcessManager = procMan;
+ sOwnerProcessId = mainProcessId;
+ }
+
+ final FileDescriptors fds = pfds.detach();
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (crashHandlerService != null) {
+ try {
+ @SuppressWarnings("unchecked")
+ final Class<? extends Service> crashHandler =
+ (Class<? extends Service>) Class.forName(crashHandlerService);
+
+ // Native crashes are reported through pipes, so we don't have to
+ // do anything special for that.
+ GeckoAppShell.setCrashHandlerService(crashHandler);
+ GeckoAppShell.ensureCrashHandling(crashHandler);
+ } catch (final ClassNotFoundException e) {
+ Log.w(LOGTAG, "Couldn't find crash handler service " + crashHandlerService);
+ }
+ }
+
+ final GeckoThread.InitInfo info =
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .extras(extras)
+ .flags(flags)
+ .userSerialNumber(userSerialNumber)
+ .fds(fds)
+ .build();
+
+ if (GeckoThread.init(info)) {
+ GeckoThread.launch();
+ }
+ }
+ });
+ return IChildProcess.STARTED_OK;
+ }
+
+ @Override
+ public void crash() {
+ GeckoThread.crash();
+ }
+
+ @Override
+ public ICompositorSurfaceManager getCompositorSurfaceManager() {
+ Log.e(
+ LOGTAG, "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process");
+ throw new AssertionError(
+ "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process.");
+ }
+
+ @Override
+ public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) {
+ Log.e(LOGTAG, "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process");
+ throw new AssertionError(
+ "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process.");
+ }
+ }
+
+ protected Binder createBinder() {
+ return new ChildProcessBinder();
+ }
+
+ private final Binder mBinder = createBinder();
+
+ @Override
+ public void onDestroy() {
+ Log.i(LOGTAG, "Destroying GeckoServiceChildProcess");
+ System.exit(0);
+ }
+
+ @Override
+ public IBinder onBind(final Intent intent) {
+ // Calling stopSelf ensures that whenever the client unbinds the process dies immediately.
+ stopSelf();
+ return mBinder;
+ }
+
+ @Override
+ public void onTrimMemory(final int level) {
+ mMemoryController.onTrimMemory(level);
+
+ // This is currently a no-op in Service, but let's future-proof.
+ super.onTrimMemory(level);
+ }
+
+ @Override
+ public void onLowMemory() {
+ mMemoryController.onLowMemory();
+ super.onLowMemory();
+ }
+
+ /**
+ * Returns the surface allocator interface that should be used by this process to allocate
+ * Surfaces, for consumption in either the GPU process or parent process.
+ */
+ public static ISurfaceAllocator getSurfaceAllocator() throws RemoteException {
+ return sProcessManager.getSurfaceAllocator();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java
new file mode 100644
index 0000000000..e4312c7e67
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.os.Binder;
+import android.util.SparseArray;
+import android.view.Surface;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.ICompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.gfx.RemoteSurfaceAllocator;
+
+public class GeckoServiceGpuProcess extends GeckoServiceChildProcess {
+ private static final String LOGTAG = "ServiceGpuProcess";
+
+ private static final class GpuProcessBinder extends GeckoServiceChildProcess.ChildProcessBinder {
+ @Override
+ public ICompositorSurfaceManager getCompositorSurfaceManager() {
+ return RemoteCompositorSurfaceManager.getInstance();
+ }
+
+ @Override
+ public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) {
+ return RemoteSurfaceAllocator.getInstance(allocatorId);
+ }
+ }
+
+ @Override
+ protected Binder createBinder() {
+ return new GpuProcessBinder();
+ }
+
+ public static final class RemoteCompositorSurfaceManager extends ICompositorSurfaceManager.Stub {
+ private static RemoteCompositorSurfaceManager mInstance;
+
+ @WrapForJNI
+ private static synchronized RemoteCompositorSurfaceManager getInstance() {
+ if (mInstance == null) {
+ mInstance = new RemoteCompositorSurfaceManager();
+ }
+ return mInstance;
+ }
+
+ private final SparseArray<Surface> mSurfaces = new SparseArray<Surface>();
+
+ @Override
+ public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) {
+ if (surface != null) {
+ mSurfaces.put(widgetId, surface);
+ } else {
+ mSurfaces.remove(widgetId);
+ }
+ }
+
+ @WrapForJNI
+ public synchronized Surface getCompositorSurface(final int widgetId) {
+ return mSurfaces.get(widgetId);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java
new file mode 100644
index 0000000000..f2dcb7a52b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java
@@ -0,0 +1,74 @@
+/* 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.content.ComponentCallbacks2;
+import android.content.res.Configuration;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoAppShell;
+
+public class MemoryController implements ComponentCallbacks2 {
+ private static final String LOGTAG = "MemoryController";
+ private long mLastLowMemoryNotificationTime = 0;
+
+ // Allowed elapsed time between full GCs while under constant memory pressure
+ private static final long LOW_MEMORY_ONGOING_RESET_TIME_MS = 10000;
+
+ private static final int LOW = 0;
+ private static final int MODERATE = 1;
+ private static final int CRITICAL = 2;
+
+ private int memoryLevelFromTrim(final int level) {
+ if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE
+ || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
+ return CRITICAL;
+ } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
+ return MODERATE;
+ }
+ return LOW;
+ }
+
+ public void onTrimMemory(final int level) {
+ Log.i(LOGTAG, "onTrimMemory(" + level + ")");
+ onMemoryNotification(memoryLevelFromTrim(level));
+ }
+
+ @Override
+ public void onConfigurationChanged(final @NonNull Configuration newConfig) {}
+
+ public void onLowMemory() {
+ Log.i(LOGTAG, "onLowMemory");
+ onMemoryNotification(CRITICAL);
+ }
+
+ private void onMemoryNotification(final int level) {
+ if (level == LOW) {
+ // The trim level is too low to be actionable
+ return;
+ }
+
+ // See nsIMemory.idl for descriptions of the various arguments to the "memory-pressure"
+ // observer.
+ final String observerArg;
+
+ final long currentNotificationTime = System.currentTimeMillis();
+ if (level == CRITICAL
+ || (currentNotificationTime - mLastLowMemoryNotificationTime)
+ >= LOW_MEMORY_ONGOING_RESET_TIME_MS) {
+ // We do a full "low-memory" notification for both new and last-ditch onTrimMemory requests.
+ observerArg = "low-memory";
+ mLastLowMemoryNotificationTime = currentNotificationTime;
+ } else {
+ // If it has been less than ten seconds since the last time we sent a "low-memory"
+ // notification, we send a "low-memory-ongoing" notification instead.
+ // This prevents Gecko from re-doing full GC's repeatedly over and over in succession,
+ // as they are expensive and quickly result in diminishing returns.
+ observerArg = "low-memory-ongoing";
+ }
+
+ GeckoAppShell.notifyObservers("memory-pressure", observerArg);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java
new file mode 100644
index 0000000000..8058d71601
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java
@@ -0,0 +1,613 @@
+/* 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import java.security.SecureRandom;
+import java.util.BitSet;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.UUID;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+
+/* package */ final class ServiceAllocator {
+ private static final String LOGTAG = "ServiceAllocator";
+ private static final int MAX_NUM_ISOLATED_CONTENT_SERVICES =
+ GeckoChildProcessServices.MAX_NUM_ISOLATED_CONTENT_SERVICES;
+
+ private static boolean hasQApis() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+ }
+
+ /**
+ * Possible priority levels that are available to child services. Each one maps to a flag that is
+ * passed into Context.bindService().
+ */
+ @WrapForJNI
+ public static enum PriorityLevel {
+ FOREGROUND(Context.BIND_IMPORTANT),
+ BACKGROUND(0),
+ IDLE(Context.BIND_WAIVE_PRIORITY);
+
+ private final int mAndroidFlag;
+
+ private PriorityLevel(final int androidFlag) {
+ mAndroidFlag = androidFlag;
+ }
+
+ public int getAndroidFlag() {
+ return mAndroidFlag;
+ }
+ }
+
+ public static final class BindException extends RuntimeException {
+ public BindException(@NonNull final String msg) {
+ super(msg);
+ }
+ }
+
+ private interface BindServiceDelegate {
+ boolean bindService(ServiceConnection binding, PriorityLevel priority);
+
+ String getServiceName();
+ }
+
+ /**
+ * Abstract class that holds the essential per-service data that is required to work with
+ * ServiceAllocator. ServiceAllocator clients should extend this class when implementing their
+ * per-service connection objects.
+ */
+ public abstract static class InstanceInfo {
+ private class Binding implements ServiceConnection {
+ /**
+ * This implementation of ServiceConnection.onServiceConnected simply bounces the connection
+ * notification over to the launcher thread (if it is not already on it).
+ */
+ @Override
+ public final void onServiceConnected(final ComponentName name, final IBinder service) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ onBinderConnectedInternal(service);
+ });
+ }
+
+ /**
+ * This implementation of ServiceConnection.onServiceDisconnected simply bounces the
+ * disconnection notification over to the launcher thread (if it is not already on it).
+ */
+ @Override
+ public final void onServiceDisconnected(final ComponentName name) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ onBinderConnectionLostInternal();
+ });
+ }
+ }
+
+ private class DefaultBindDelegate implements BindServiceDelegate {
+ @Override
+ public boolean bindService(
+ @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent intent = new Intent();
+ intent.setClassName(context, getServiceName());
+ return bindServiceDefault(context, intent, binding, getAndroidFlags(priority));
+ }
+
+ @Override
+ public String getServiceName() {
+ return getSvcClassNameDefault(InstanceInfo.this);
+ }
+ }
+
+ private class IsolatedBindDelegate implements BindServiceDelegate {
+ @Override
+ public boolean bindService(
+ @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent intent = new Intent();
+ intent.setClassName(context, getServiceName());
+ return bindServiceIsolated(
+ context, intent, getAndroidFlags(priority), getIdInternal(), binding);
+ }
+
+ @Override
+ public String getServiceName() {
+ return ServiceUtils.buildIsolatedSvcName(getType());
+ }
+ }
+
+ private final ServiceAllocator mAllocator;
+ private final GeckoProcessType mType;
+ private final String mId;
+ private final EnumMap<PriorityLevel, Binding> mBindings;
+ private final BindServiceDelegate mBindDelegate;
+
+ private boolean mCalledConnected = false;
+ private boolean mCalledConnectionLost = false;
+ private boolean mIsDefunct = false;
+
+ private PriorityLevel mCurrentPriority;
+ private int mRelativeImportance = 0;
+
+ protected InstanceInfo(
+ @NonNull final ServiceAllocator allocator,
+ @NonNull final GeckoProcessType type,
+ @NonNull final PriorityLevel initialPriority) {
+ mAllocator = allocator;
+ mType = type;
+ mId = mAllocator.allocate(type);
+ mBindings = new EnumMap<PriorityLevel, Binding>(PriorityLevel.class);
+ mBindDelegate = getBindServiceDelegate();
+
+ mCurrentPriority = initialPriority;
+ }
+
+ private BindServiceDelegate getBindServiceDelegate() {
+ if (mType != GeckoProcessType.CONTENT) {
+ // Non-content services just use default binding
+ return this.new DefaultBindDelegate();
+ }
+
+ // Content services defer to the alloc policy
+ return mAllocator.mContentAllocPolicy.getBindServiceDelegate(this);
+ }
+
+ public PriorityLevel getPriorityLevel() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ return mCurrentPriority;
+ }
+
+ public boolean setPriorityLevel(@NonNull final PriorityLevel newPriority) {
+ return setPriorityLevel(newPriority, 0);
+ }
+
+ public boolean setPriorityLevel(
+ @NonNull final PriorityLevel newPriority, final int relativeImportance) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ mCurrentPriority = newPriority;
+ mRelativeImportance = relativeImportance;
+
+ // If we haven't bound yet then we can just return
+ if (mBindings.size() == 0) {
+ return true;
+ }
+
+ // Otherwise we need to update our bindings
+ return updateBindings();
+ }
+
+ /**
+ * Only content services have unique IDs. This method throws if called for a non-content service
+ * type.
+ */
+ public String getId() {
+ if (mId == null) {
+ throw new RuntimeException("This service does not have a unique id");
+ }
+
+ return mId;
+ }
+
+ /** This method is infallible and returns an empty string for non-content services. */
+ private String getIdInternal() {
+ return mId == null ? "" : mId;
+ }
+
+ public boolean isContent() {
+ return mType == GeckoProcessType.CONTENT;
+ }
+
+ public GeckoProcessType getType() {
+ return mType;
+ }
+
+ protected boolean bindService() {
+ if (mIsDefunct) {
+ final String errorMsg =
+ "Attempt to bind a defunct InstanceInfo for " + mType + " child process";
+ throw new BindException(errorMsg);
+ }
+
+ return updateBindings();
+ }
+
+ /**
+ * Unbinds the service described by |this| and releases our unique ID. This method may safely be
+ * called multiple times even if we are already defunct.
+ */
+ protected void unbindService() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ // This could happen if a service death races with our attempt to shut it down.
+ if (mIsDefunct) {
+ return;
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ // Make a clone of mBindings to iterate over since we're going to mutate the original
+ final EnumMap<PriorityLevel, Binding> cloned = mBindings.clone();
+ for (final Entry<PriorityLevel, Binding> entry : cloned.entrySet()) {
+ try {
+ context.unbindService(entry.getValue());
+ } catch (final IllegalArgumentException e) {
+ // The binding was already dead. That's okay.
+ }
+
+ mBindings.remove(entry.getKey());
+ }
+
+ if (mBindings.size() != 0) {
+ throw new IllegalStateException("Unable to release all bindings");
+ }
+
+ mIsDefunct = true;
+ mAllocator.release(this);
+ onReleaseResources();
+ }
+
+ private void onBinderConnectedInternal(@NonNull final IBinder service) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ // We only care about the first time this is called; subsequent bindings can be ignored.
+ if (mCalledConnected) {
+ return;
+ }
+
+ mCalledConnected = true;
+
+ onBinderConnected(service);
+ }
+
+ private void onBinderConnectionLostInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ // We only care about the first time this is called; subsequent connection errors can be
+ // ignored.
+ if (mCalledConnectionLost) {
+ return;
+ }
+
+ mCalledConnectionLost = true;
+
+ onBinderConnectionLost();
+ }
+
+ protected abstract void onBinderConnected(@NonNull final IBinder service);
+
+ protected abstract void onReleaseResources();
+
+ // Optionally overridable by subclasses, but this is a sane default
+ protected void onBinderConnectionLost() {
+ // The binding has lost its connection, but the binding itself might still be active.
+ // Gecko itself will request a process restart, so here we attempt to unbind so that
+ // Android does not try to automatically restart and reconnect the service.
+ unbindService();
+ }
+
+ /**
+ * This function relies on the fact that the PriorityLevel enum is ordered from highest priority
+ * to lowest priority. We examine the ordinal of the current priority setting, and then iterate
+ * across all possible priority levels, adjusting as necessary. Any priority levels whose
+ * ordinals are less than then current priority level ordinal must be unbound, while all
+ * priority levels whose ordinals are greater than or equal to the current priority level
+ * ordinal must be bound.
+ */
+ @TargetApi(29)
+ private boolean updateBindings() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ int numBindSuccesses = 0;
+ int numBindFailures = 0;
+ int numUnbindSuccesses = 0;
+
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ // This code assumes that the order of the PriorityLevel enum is highest to lowest
+ final int curPriorityOrdinal = mCurrentPriority.ordinal();
+ final PriorityLevel[] levels = PriorityLevel.values();
+
+ for (int curLevelIdx = 0; curLevelIdx < levels.length; ++curLevelIdx) {
+ final PriorityLevel curLevel = levels[curLevelIdx];
+ final Binding existingBinding = mBindings.get(curLevel);
+ final boolean hasExistingBinding = existingBinding != null;
+
+ if (curLevelIdx < curPriorityOrdinal) {
+ // Remove if present
+ if (hasExistingBinding) {
+ try {
+ context.unbindService(existingBinding);
+ ++numUnbindSuccesses;
+ mBindings.remove(curLevel);
+ } catch (final IllegalArgumentException e) {
+ // The binding was already dead. That's okay.
+ ++numUnbindSuccesses;
+ mBindings.remove(curLevel);
+ }
+ }
+ } else {
+ // Normally we only need to do a bind if we do not yet have an existing binding
+ // for this priority level.
+ boolean bindNeeded = !hasExistingBinding;
+
+ // We only update the service group when the binding for this level already
+ // exists and no binds have occurred yet during the current updateBindings call.
+ if (hasExistingBinding && hasQApis() && (numBindSuccesses + numBindFailures) == 0) {
+ // NB: Right now we're passing 0 as the |group| argument, indicating that
+ // the process is not grouped with any other processes. Once we support
+ // Fission we should re-evaluate this.
+ context.updateServiceGroup(existingBinding, 0, mRelativeImportance);
+ // Now we need to call bindService with the existing binding to make this
+ // change take effect.
+ bindNeeded = true;
+ }
+
+ if (bindNeeded) {
+ final Binding useBinding = hasExistingBinding ? existingBinding : this.new Binding();
+ if (mBindDelegate.bindService(useBinding, curLevel)) {
+ ++numBindSuccesses;
+ if (!hasExistingBinding) {
+ mBindings.put(curLevel, useBinding);
+ }
+ } else {
+ ++numBindFailures;
+ }
+ }
+ }
+ }
+
+ final String svcName = mBindDelegate.getServiceName();
+ final StringBuilder builder = new StringBuilder(svcName);
+ builder
+ .append(" updateBindings: ")
+ .append(mCurrentPriority)
+ .append(" priority, ")
+ .append(mRelativeImportance)
+ .append(" importance, ")
+ .append(numBindSuccesses)
+ .append(" successful binds, ")
+ .append(numBindFailures)
+ .append(" failed binds, ")
+ .append(numUnbindSuccesses)
+ .append(" successful unbinds");
+ Log.d(LOGTAG, builder.toString());
+
+ return numBindFailures == 0;
+ }
+ }
+
+ private interface ContentAllocationPolicy {
+ /**
+ * @return BindServiceDelegate that will be used for binding a new content service.
+ */
+ BindServiceDelegate getBindServiceDelegate(InstanceInfo info);
+
+ /**
+ * Allocate an unused service ID for use by the caller.
+ *
+ * @return The new service id.
+ */
+ String allocate();
+
+ /**
+ * Release a previously used service ID.
+ *
+ * @param id The service id being released.
+ */
+ void release(final String id);
+ }
+
+ /**
+ * This policy is intended for Android versions &lt; 10, as well as for content process services
+ * that are not defined as isolated processes. In this case, the number of possible content
+ * service IDs has a fixed upper bound, so we use a BitSet to manage their allocation.
+ */
+ private static final class DefaultContentPolicy implements ContentAllocationPolicy {
+ private final int mMaxNumSvcs;
+ private final BitSet mAllocator;
+ private final SecureRandom mRandom;
+
+ public DefaultContentPolicy() {
+ mMaxNumSvcs = getContentServiceCount();
+ mAllocator = new BitSet(mMaxNumSvcs);
+ mRandom = new SecureRandom();
+ }
+
+ @Override
+ public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) {
+ return info.new DefaultBindDelegate();
+ }
+
+ @Override
+ public String allocate() {
+ final int[] available = new int[mMaxNumSvcs];
+ int size = 0;
+ for (int i = 0; i < mMaxNumSvcs; i++) {
+ if (!mAllocator.get(i)) {
+ available[size] = i;
+ size++;
+ }
+ }
+
+ if (size == 0) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ final int next = available[mRandom.nextInt(size)];
+ mAllocator.set(next);
+ return Integer.toString(next);
+ }
+
+ @Override
+ public void release(final String stringId) {
+ final int id = Integer.valueOf(stringId);
+ if (!mAllocator.get(id)) {
+ throw new IllegalStateException("Releasing an unallocated id=" + id);
+ }
+
+ mAllocator.clear(id);
+ }
+
+ /**
+ * @return The number of content services defined in our manifest.
+ */
+ private static int getContentServiceCount() {
+ return ServiceUtils.getServiceCount(
+ GeckoAppShell.getApplicationContext(), GeckoProcessType.CONTENT);
+ }
+ }
+
+ /**
+ * This policy is intended for Android versions &gt;= 10 when our content process services are
+ * defined in our manifest as having isolated processes. Since isolated services share a single
+ * service definition, there is no longer an Android-induced hard limit on the number of content
+ * processes that may be started. We simply use a monotonically-increasing counter to generate
+ * unique instance IDs in this case.
+ */
+ private static final class IsolatedContentPolicy implements ContentAllocationPolicy {
+ private final Set<String> mRunningServiceIds = new HashSet<>();
+
+ @Override
+ public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) {
+ return info.new IsolatedBindDelegate();
+ }
+
+ /**
+ * We generate a new instance ID simply by incrementing a counter. We do track how many content
+ * services are currently active for the purposes of maintaining the configured limit on number
+ * of simultaneous content processes.
+ */
+ @Override
+ public String allocate() {
+ if (mRunningServiceIds.size() >= MAX_NUM_ISOLATED_CONTENT_SERVICES) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ final String newId = UUID.randomUUID().toString();
+ mRunningServiceIds.add(newId);
+ return newId;
+ }
+
+ /** Just drop the count of active services. */
+ @Override
+ public void release(final String id) {
+ if (!mRunningServiceIds.remove(id)) {
+ throw new IllegalStateException("Releasing an unallocated id");
+ }
+ }
+ }
+
+ /** The policy used for allocating content processes. */
+ private ContentAllocationPolicy mContentAllocPolicy = null;
+
+ /**
+ * Allocate a service ID.
+ *
+ * @param type The type of service.
+ * @return Integer encapsulating the service ID, or null if no ID is necessary.
+ */
+ private String allocate(@NonNull final GeckoProcessType type) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (type != GeckoProcessType.CONTENT) {
+ // No unique id necessary
+ return null;
+ }
+
+ // Lazy initialization of mContentAllocPolicy to ensure that it is constructed on the
+ // launcher thread.
+ if (mContentAllocPolicy == null) {
+ if (canBindIsolated(GeckoProcessType.CONTENT)) {
+ mContentAllocPolicy = new IsolatedContentPolicy();
+ } else {
+ mContentAllocPolicy = new DefaultContentPolicy();
+ }
+ }
+
+ return mContentAllocPolicy.allocate();
+ }
+
+ /**
+ * Free a defunct service's ID if necessary.
+ *
+ * @param info The InstanceInfo-derived object that contains essential information for tearing
+ * down the child service.
+ */
+ private void release(@NonNull final InstanceInfo info) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (!info.isContent()) {
+ return;
+ }
+
+ mContentAllocPolicy.release(info.getId());
+ }
+
+ /**
+ * Find out whether the desired service type is defined in our manifest as having an isolated
+ * process.
+ *
+ * @param type Service type to query
+ * @return true if this service type may use isolated binding, otherwise false.
+ */
+ private static boolean canBindIsolated(@NonNull final GeckoProcessType type) {
+ if (!hasQApis()) {
+ return false;
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ final int svcFlags = ServiceUtils.getServiceFlags(context, type);
+ return (svcFlags & ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
+ }
+
+ /** Convert PriorityLevel into the flags argument to Context.bindService() et al */
+ private static int getAndroidFlags(@NonNull final PriorityLevel priority) {
+ return Context.BIND_AUTO_CREATE | priority.getAndroidFlag();
+ }
+
+ /** Obtain the class name to use for service binding in the default (ie, non-isolated) case. */
+ private static String getSvcClassNameDefault(@NonNull final InstanceInfo info) {
+ return ServiceUtils.buildSvcName(info.getType(), info.getIdInternal());
+ }
+
+ /**
+ * Wrapper for bindService() that utilizes the Context.bindService() overload that accepts an
+ * Executor argument, when available. Otherwise it falls back to the legacy overload.
+ */
+ @TargetApi(29)
+ private static boolean bindServiceDefault(
+ @NonNull final Context context,
+ @NonNull final Intent intent,
+ @NonNull final ServiceConnection conn,
+ final int flags) {
+ if (hasQApis()) {
+ // We always specify the launcher thread as our Executor.
+ return context.bindService(intent, flags, XPCOMEventTarget.launcherThread(), conn);
+ }
+
+ return context.bindService(intent, conn, flags);
+ }
+
+ @TargetApi(29)
+ private static boolean bindServiceIsolated(
+ @NonNull final Context context,
+ @NonNull final Intent intent,
+ final int flags,
+ @NonNull final String instanceId,
+ @NonNull final ServiceConnection conn) {
+ // We always specify the launcher thread as our Executor.
+ return context.bindIsolatedService(
+ intent, flags, instanceId, XPCOMEventTarget.launcherThread(), conn);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java
new file mode 100644
index 0000000000..695c69666b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java
@@ -0,0 +1,141 @@
+/* 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/. */
+
+package org.mozilla.gecko.process;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import androidx.annotation.NonNull;
+
+/* package */ final class ServiceUtils {
+ private static final String DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX = "0";
+
+ private ServiceUtils() {}
+
+ /**
+ * @return StringBuilder containing the name of a service class but not qualifed with any unique
+ * identifiers.
+ */
+ private static StringBuilder startSvcName(@NonNull final GeckoProcessType type) {
+ final StringBuilder builder = new StringBuilder(GeckoChildProcessServices.class.getName());
+ builder.append("$").append(type);
+ return builder;
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the name of its class, including any qualifiers that
+ * are needed to uniquely identify its manifest definition.
+ */
+ public static String buildSvcName(
+ @NonNull final GeckoProcessType type, final String... suffixes) {
+ final StringBuilder builder = startSvcName(type);
+
+ for (final String suffix : suffixes) {
+ builder.append(suffix);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the name of its class to be used for the purpose of
+ * binding as an isolated service.
+ *
+ * <p>Content services are defined in the manifest as "tab0" through "tabN" for some value of N.
+ * For the purposes of binding to an isolated content service, we simply need to repeatedly re-use
+ * the definition of "tab0", the "0" being stored as the
+ * DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX constant.
+ */
+ public static String buildIsolatedSvcName(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ return buildSvcName(type, DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX);
+ }
+
+ // Non-content services do not require any unique IDs
+ return buildSvcName(type);
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the unqualified name of its class.
+ *
+ * @return The name of the class that hosts the implementation of the service corresponding to
+ * type, but without any unique identifiers that may be required to actually instantiate it.
+ */
+ private static String buildSvcNamePrefix(@NonNull final GeckoProcessType type) {
+ return startSvcName(type).toString();
+ }
+
+ /**
+ * Extracts flags from the manifest definition of a service.
+ *
+ * @param context Context to use for extraction
+ * @param type Service type
+ * @return flags that are specified in the service's definition in our manifest.
+ * @see android.content.pm.ServiceInfo for explanation of the various flags.
+ */
+ public static int getServiceFlags(
+ @NonNull final Context context, @NonNull final GeckoProcessType type) {
+ final ComponentName component = new ComponentName(context, buildIsolatedSvcName(type));
+ final PackageManager pkgMgr = context.getPackageManager();
+
+ try {
+ final ServiceInfo svcInfo = pkgMgr.getServiceInfo(component, 0);
+ // svcInfo is never null
+ return svcInfo.flags;
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Obtain the list of all services defined for |context|. */
+ private static ServiceInfo[] getServiceList(@NonNull final Context context) {
+ final PackageInfo packageInfo;
+ try {
+ packageInfo =
+ context
+ .getPackageManager()
+ .getPackageInfo(context.getPackageName(), PackageManager.GET_SERVICES);
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new AssertionError("Should not happen: Can't get package info of own package");
+ }
+ return packageInfo.services;
+ }
+
+ /**
+ * Count the number of service definitions in our manifest that satisfy bindings for a particular
+ * service type.
+ *
+ * @param context Context object to use for extracting the service definitions
+ * @param type The type of service to count
+ * @return The number of available service definitions.
+ */
+ public static int getServiceCount(
+ @NonNull final Context context, @NonNull final GeckoProcessType type) {
+ final ServiceInfo[] svcList = getServiceList(context);
+ final String serviceNamePrefix = buildSvcNamePrefix(type);
+
+ int result = 0;
+ for (final ServiceInfo svc : svcList) {
+ final String svcName = svc.name;
+ // If svcName starts with serviceNamePrefix, then both strings must either be equal
+ // or else the first subsequent character in svcName must be a digit.
+ // This guards against any future GeckoProcessType whose string representation shares
+ // a common prefix with another GeckoProcessType value.
+ if (svcName.startsWith(serviceNamePrefix)
+ && (svcName.length() == serviceNamePrefix.length()
+ || Character.isDigit(svcName.codePointAt(serviceNamePrefix.length())))) {
+ ++result;
+ }
+ }
+
+ if (result <= 0) {
+ throw new RuntimeException("Could not count " + serviceNamePrefix + " services in manifest");
+ }
+
+ return result;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
new file mode 100644
index 0000000000..b8d7ea3107
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
@@ -0,0 +1,21 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+@RobocopTarget
+public interface BundleEventListener {
+ /**
+ * Handles a message sent from Gecko.
+ *
+ * @param event The name of the event being sent.
+ * @param message The message data.
+ * @param callback The callback interface for this message. A callback is provided only if the
+ * originating call included a callback argument; otherwise, callback will be null.
+ */
+ void handleMessage(String event, GeckoBundle message, EventCallback callback);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java
new file mode 100644
index 0000000000..c1be67e356
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java
@@ -0,0 +1,134 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.yaml.snakeyaml.TypeDescription;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.Constructor;
+import org.yaml.snakeyaml.error.YAMLException;
+
+// Raptor writes a *-config.yaml file to specify Gecko runtime settings (e.g.
+// the profile dir). This file gets deserialized into a DebugConfig object.
+// Yaml uses reflection to create this class so we have to tell PG to keep it.
+@ReflectionTarget
+public class DebugConfig {
+ private static final String LOGTAG = "GeckoDebugConfig";
+
+ protected Map<String, Object> prefs;
+ protected Map<String, String> env;
+ protected List<String> args;
+
+ public static class ConfigException extends RuntimeException {
+ public ConfigException(final String message) {
+ super(message);
+ }
+ }
+
+ public static @NonNull DebugConfig fromFile(final @NonNull File configFile)
+ throws FileNotFoundException {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ // There are a lot of problems with SnakeYaml on older version let's just bail.
+ throw new ConfigException("Config version is only supported for SDK_INT >= 21.");
+ }
+
+ final Constructor constructor = new Constructor(DebugConfig.class);
+ final TypeDescription description = new TypeDescription(DebugConfig.class);
+ description.putMapPropertyType("prefs", String.class, Object.class);
+ description.putMapPropertyType("env", String.class, String.class);
+ description.putListPropertyType("args", String.class);
+
+ final Yaml yaml = new Yaml(constructor);
+ yaml.addTypeDescription(description);
+
+ final FileInputStream fileInputStream = new FileInputStream(configFile);
+ try {
+ return yaml.load(fileInputStream);
+ } catch (final YAMLException e) {
+ throw new ConfigException(e.getMessage());
+ } finally {
+ try {
+ if (fileInputStream != null) {
+ ((Closeable) fileInputStream).close();
+ }
+ } catch (final IOException e) {
+ }
+ }
+ }
+
+ @Nullable
+ public Bundle mergeIntoExtras(final @Nullable Bundle extras) {
+ if (env == null) {
+ return extras;
+ }
+
+ Log.d(LOGTAG, "Adding environment variables from debug config: " + env);
+
+ final Bundle result = extras != null ? extras : new Bundle();
+
+ int c = 0;
+ while (result.getString("env" + c) != null) {
+ c += 1;
+ }
+
+ for (final Map.Entry<String, String> entry : env.entrySet()) {
+ result.putString("env" + c, entry.getKey() + "=" + entry.getValue());
+ c += 1;
+ }
+
+ return result;
+ }
+
+ @Nullable
+ public String[] mergeIntoArgs(final @Nullable String[] initArgs) {
+ if (args == null) {
+ return initArgs;
+ }
+
+ Log.d(LOGTAG, "Adding arguments from debug config: " + args);
+
+ final ArrayList<String> combinedArgs = new ArrayList<>();
+ if (initArgs != null) {
+ combinedArgs.addAll(Arrays.asList(initArgs));
+ }
+ combinedArgs.addAll(args);
+
+ return combinedArgs.toArray(new String[combinedArgs.size()]);
+ }
+
+ @Nullable
+ public Map<String, Object> mergeIntoPrefs(final @Nullable Map<String, Object> initPrefs) {
+ if (prefs == null) {
+ return initPrefs;
+ }
+
+ Log.d(LOGTAG, "Adding prefs from debug config: " + prefs);
+
+ final Map<String, Object> combinedPrefs = new HashMap<>();
+ if (initPrefs != null) {
+ combinedPrefs.putAll(initPrefs);
+ }
+ combinedPrefs.putAll(prefs);
+
+ return Collections.unmodifiableMap(combinedPrefs);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
new file mode 100644
index 0000000000..3ef469ac1b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
@@ -0,0 +1,58 @@
+/* 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/. */
+
+package org.mozilla.gecko.util;
+
+import androidx.annotation.Nullable;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.geckoview.GeckoResult;
+
+/**
+ * Callback interface for Gecko requests.
+ *
+ * <p>For each instance of EventCallback, exactly one of sendResponse, sendError, or sendCancel must
+ * be called to prevent observer leaks. If more than one send* method is called, or if a single send
+ * method is called multiple times, an {@link IllegalStateException} will be thrown.
+ */
+@RobocopTarget
+@WrapForJNI(calledFrom = "gecko")
+public interface EventCallback {
+ /**
+ * Sends a success response with the given data.
+ *
+ * @param response The response data to send to Gecko. Can be any of the types accepted by
+ * JSONObject#put(String, Object).
+ */
+ void sendSuccess(Object response);
+
+ /**
+ * Sends an error response with the given data.
+ *
+ * @param response The response data to send to Gecko. Can be any of the types accepted by
+ * JSONObject#put(String, Object).
+ */
+ void sendError(Object response);
+
+ /**
+ * Resolve this Event callback with the result from the {@link GeckoResult}.
+ *
+ * @param response the result that will be used for this callback.
+ */
+ default <T> void resolveTo(final @Nullable GeckoResult<T> response) {
+ if (response == null) {
+ sendSuccess(null);
+ return;
+ }
+ response.accept(
+ this::sendSuccess,
+ throwable -> {
+ // Don't propagate Errors, just crash
+ if (!(throwable instanceof Exception)) {
+ throw new GeckoResult.UncaughtException(throwable);
+ }
+ sendError(throwable.getMessage());
+ });
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
new file mode 100644
index 0000000000..01b177fe21
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
@@ -0,0 +1,72 @@
+/* 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.os.Handler;
+import android.os.Looper;
+
+final class GeckoBackgroundThread extends Thread {
+ private static final String LOOPER_NAME = "GeckoBackgroundThread";
+
+ // Guarded by 'GeckoBackgroundThread.class'.
+ private static Handler handler;
+ private static Thread thread;
+
+ // The initial Runnable to run on the new thread. Its purpose
+ // is to avoid us having to wait for the new thread to start.
+ private Runnable mInitialRunnable;
+
+ // Singleton, so private constructor.
+ private GeckoBackgroundThread(final Runnable initialRunnable) {
+ mInitialRunnable = initialRunnable;
+ }
+
+ @Override
+ public void run() {
+ setName(LOOPER_NAME);
+ Looper.prepare();
+
+ synchronized (GeckoBackgroundThread.class) {
+ handler = new Handler();
+ GeckoBackgroundThread.class.notifyAll();
+ }
+
+ if (mInitialRunnable != null) {
+ mInitialRunnable.run();
+ mInitialRunnable = null;
+ }
+
+ Looper.loop();
+ }
+
+ private static void startThread(final Runnable initialRunnable) {
+ thread = new GeckoBackgroundThread(initialRunnable);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ // Get a Handler for a looper thread, or create one if it doesn't yet exist.
+ /*package*/ static synchronized Handler getHandler() {
+ if (thread == null) {
+ startThread(null);
+ }
+
+ while (handler == null) {
+ try {
+ GeckoBackgroundThread.class.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ return handler;
+ }
+
+ /*package*/ static synchronized void post(final Runnable runnable) {
+ if (thread == null) {
+ startThread(runnable);
+ return;
+ }
+ getHandler().post(runnable);
+ }
+}
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
new file mode 100644
index 0000000000..de7de66597
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java
@@ -0,0 +1,1152 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.collection.SimpleArrayMap;
+import java.lang.reflect.Array;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Iterator;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * A lighter-weight version of Bundle that adds support for type coercion (e.g. int to double) in
+ * order to better cooperate with JS objects.
+ */
+@RobocopTarget
+public final class GeckoBundle implements Parcelable {
+ private static final String LOGTAG = "GeckoBundle";
+ private static final boolean DEBUG = false;
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static final boolean[] EMPTY_BOOLEAN_ARRAY = new boolean[0];
+
+ private static final int[] EMPTY_INT_ARRAY = new int[0];
+ private static final long[] EMPTY_LONG_ARRAY = new long[0];
+ private static final double[] EMPTY_DOUBLE_ARRAY = new double[0];
+ private static final String[] EMPTY_STRING_ARRAY = new String[0];
+ private static final GeckoBundle[] EMPTY_BUNDLE_ARRAY = new GeckoBundle[0];
+
+ private SimpleArrayMap<String, Object> mMap;
+
+ /** Construct an empty GeckoBundle. */
+ public GeckoBundle() {
+ mMap = new SimpleArrayMap<>();
+ }
+
+ /**
+ * Construct an empty GeckoBundle with specific capacity.
+ *
+ * @param capacity Initial capacity.
+ */
+ public GeckoBundle(final int capacity) {
+ mMap = new SimpleArrayMap<>(capacity);
+ }
+
+ /**
+ * Construct a copy of another GeckoBundle.
+ *
+ * @param bundle GeckoBundle to copy from.
+ */
+ public GeckoBundle(final GeckoBundle bundle) {
+ mMap = new SimpleArrayMap<>(bundle.mMap);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private GeckoBundle(final String[] keys, final Object[] values) {
+ final int len = keys.length;
+ mMap = new SimpleArrayMap<>(len);
+ for (int i = 0; i < len; i++) {
+ mMap.put(keys[i], values[i]);
+ }
+ }
+
+ /** Clear all mappings. */
+ public void clear() {
+ mMap.clear();
+ }
+
+ /**
+ * Returns whether a mapping exists. Null String, Bundle, or arrays are treated as nonexistent.
+ *
+ * @param key Key to look for.
+ * @return True if the specified key exists and the value is not null.
+ */
+ public boolean containsKey(final String key) {
+ return mMap.get(key) != null;
+ }
+
+ /**
+ * Returns the value associated with a mapping as an Object.
+ *
+ * @param key Key to look for.
+ * @return Mapping value or null if the mapping does not exist.
+ */
+ public Object get(final String key) {
+ return mMap.get(key);
+ }
+
+ /**
+ * Returns the value associated with a boolean mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Boolean value
+ */
+ public boolean getBoolean(final String key, final boolean defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : (Boolean) value;
+ }
+
+ /**
+ * Returns the value associated with a boolean mapping, or false if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean value
+ */
+ public boolean getBoolean(final String key) {
+ return getBoolean(key, false);
+ }
+
+ /**
+ * Returns the value associated with a Boolean mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Boolean value
+ */
+ public Boolean getBooleanObject(final String key, final Boolean defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : (Boolean) value;
+ }
+
+ /**
+ * Returns the value associated with a Boolean mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean value
+ */
+ public Boolean getBooleanObject(final String key) {
+ return getBooleanObject(key, null);
+ }
+
+ /**
+ * Returns the value associated with a boolean array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean array value
+ */
+ public boolean[] getBooleanArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0 ? EMPTY_BOOLEAN_ARRAY : (boolean[]) value;
+ }
+
+ /**
+ * Returns the value associated with a double mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Double value
+ */
+ public double getDouble(final String key, final double defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Number) value).doubleValue();
+ }
+
+ /**
+ * Returns the value associated with a double mapping, or 0.0 if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Double value
+ */
+ public double getDouble(final String key) {
+ return getDouble(key, 0.0);
+ }
+
+ private static double[] getDoubleArray(final int[] array) {
+ final int len = array.length;
+ final double[] ret = new double[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = (double) array[i];
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value associated with a double array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return Double array value
+ */
+ public double[] getDoubleArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_DOUBLE_ARRAY
+ : value instanceof int[] ? getDoubleArray((int[]) value) : (double[]) value;
+ }
+
+ /**
+ * Returns the value associated with an int mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Int value
+ */
+ public int getInt(final String key, final int defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Number) value).intValue();
+ }
+
+ /**
+ * Returns the value associated with an int mapping, or 0 if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int value
+ */
+ public int getInt(final String key) {
+ return getInt(key, 0);
+ }
+
+ /**
+ * Returns the value associated with an Integer mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Int value
+ */
+ public Integer getInteger(final String key, final Integer defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Integer) value);
+ }
+
+ /**
+ * Returns the value associated with an Integer mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int value
+ */
+ public Integer getInteger(final String key) {
+ return getInteger(key, null);
+ }
+
+ private static int[] getIntArray(final double[] array) {
+ final int len = array.length;
+ final int[] ret = new int[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = (int) array[i];
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value associated with an int array mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int array value
+ */
+ public int[] getIntArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_INT_ARRAY
+ : value instanceof double[] ? getIntArray((double[]) value) : (int[]) value;
+ }
+
+ /**
+ * Returns the value associated with an int/double mapping as a long value, or defaultValue if the
+ * mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Long value
+ */
+ public long getLong(final String key, final long defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Number) value).longValue();
+ }
+
+ /**
+ * Returns the value associated with an int/double mapping as a long value, or 0 if the mapping
+ * does not exist.
+ *
+ * @param key Key to look for.
+ * @return Long value
+ */
+ public long getLong(final String key) {
+ return getLong(key, 0L);
+ }
+
+ private static long[] getLongArray(final Object array) {
+ final int len = Array.getLength(array);
+ final long[] ret = new long[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = ((Number) Array.get(array, i)).longValue();
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value associated with an int/double array mapping as a long array, or null if the
+ * mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Long array value
+ */
+ public long[] getLongArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0 ? EMPTY_LONG_ARRAY : getLongArray(value);
+ }
+
+ /**
+ * Returns the value associated with a String mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping value is null or mapping does not exist.
+ * @return String value
+ */
+ public String getString(final String key, final String defaultValue) {
+ // If the key maps to null, technically we should return null because the mapping
+ // exists and null is a valid string value. However, people expect the default
+ // value to be returned instead, so we make an exception to return the default value.
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : (String) value;
+ }
+
+ /**
+ * Returns the value associated with a String mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return String value
+ */
+ public String getString(final String key) {
+ return getString(key, null);
+ }
+
+ // The only case where we convert String[] to/from GeckoBundle[] is if every element
+ // is null.
+ private static int getNullArrayLength(final Object array) {
+ final int len = Array.getLength(array);
+ for (int i = 0; i < len; i++) {
+ if (Array.get(array, i) != null) {
+ throw new ClassCastException("Cannot cast array type");
+ }
+ }
+ return len;
+ }
+
+ /**
+ * Returns the value associated with a String array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return String array value
+ */
+ public String[] getStringArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_STRING_ARRAY
+ : !(value instanceof String[])
+ ? new String[getNullArrayLength(value)]
+ : (String[]) value;
+ }
+
+ /*
+ * Returns the value associated with a RectF mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return RectF value
+ */
+ public RectF getRectF(final String key) {
+ final GeckoBundle rectBundle = getBundle(key);
+ if (rectBundle == null) {
+ return null;
+ }
+
+ return new RectF(
+ (float) rectBundle.getDouble("left"),
+ (float) rectBundle.getDouble("top"),
+ (float) rectBundle.getDouble("right"),
+ (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 PointF mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Point value
+ */
+ public PointF getPointF(final String key) {
+ final GeckoBundle ptBundle = getBundle(key);
+ if (ptBundle == null) {
+ return null;
+ }
+
+ return new PointF((float) ptBundle.getDouble("x"), (float) ptBundle.getDouble("y"));
+ }
+
+ /**
+ * Returns the value associated with a GeckoBundle mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return GeckoBundle value
+ */
+ public GeckoBundle getBundle(final String key) {
+ return (GeckoBundle) mMap.get(key);
+ }
+
+ /**
+ * Returns the value associated with a GeckoBundle array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return GeckoBundle array value
+ */
+ public GeckoBundle[] getBundleArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_BUNDLE_ARRAY
+ : !(value instanceof GeckoBundle[])
+ ? new GeckoBundle[getNullArrayLength(value)]
+ : (GeckoBundle[]) value;
+ }
+
+ /**
+ * Returns whether this GeckoBundle has no mappings.
+ *
+ * @return True if no mapping exists.
+ */
+ public boolean isEmpty() {
+ return mMap.isEmpty();
+ }
+
+ /**
+ * Returns an array of all mapped keys.
+ *
+ * @return String array containing all mapped keys.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public String[] keys() {
+ final int len = mMap.size();
+ final String[] ret = new String[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = mMap.keyAt(i);
+ }
+ return ret;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private Object[] values() {
+ final int len = mMap.size();
+ final Object[] ret = new Object[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = mMap.valueAt(i);
+ }
+ return ret;
+ }
+
+ private void put(final String key, final Object value) {
+ // We intentionally disallow a generic put() method for type safety and sanity. For
+ // example, we assume elsewhere in the code that a value belongs to a small list of
+ // predefined types, and cannot be any arbitrary object. If you want to put an
+ // Object in the bundle, check the type of the Object first and call the
+ // corresponding put methods. For example,
+ //
+ // if (obj instanceof Integer) {
+ // bundle.putInt(key, (Integer) key);
+ // } else if (obj instanceof String) {
+ // bundle.putString(key, (String) obj);
+ // } else {
+ // throw new IllegalArgumentException("unexpected type");
+ // }
+ throw new UnsupportedOperationException();
+ }
+
+ /**
+ * Map a key to a boolean value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBoolean(final String key, final boolean value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a boolean array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBooleanArray(final String key, final boolean[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a boolean array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBooleanArray(final String key, final Boolean[] value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final boolean[] array = new boolean[value.length];
+ for (int i = 0; i < value.length; i++) {
+ array[i] = value[i];
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a boolean array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBooleanArray(final String key, final Collection<Boolean> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final boolean[] array = new boolean[value.size()];
+ int i = 0;
+ for (final Boolean element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a double value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDouble(final String key, final double value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final double[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final Double[] value) {
+ putDoubleArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final Collection<Double> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.size()];
+ int i = 0;
+ for (final Double element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to an int value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putInt(final String key, final int value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to an int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final int[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final Integer[] value) {
+ putIntArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final Collection<Integer> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final int[] array = new int[value.size()];
+ int i = 0;
+ for (final Integer element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a long value stored as a double value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLong(final String key, final long value) {
+ mMap.put(key, (double) value);
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final long[] value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.length];
+ for (int i = 0; i < value.length; i++) {
+ array[i] = (double) value[i];
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final Long[] value) {
+ putLongArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final Collection<Long> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.size()];
+ int i = 0;
+ for (final Long element : value) {
+ array[i++] = (double) element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a String value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putString(final String key, final String value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a String array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putStringArray(final String key, final String[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a String array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putStringArray(final String key, final Collection<String> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final String[] array = new String[value.size()];
+ int i = 0;
+ for (final String element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a GeckoBundle value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundle(final String key, final GeckoBundle value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a GeckoBundle array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundleArray(final String key, final GeckoBundle[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a GeckoBundle array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundleArray(final String key, final Collection<GeckoBundle> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final GeckoBundle[] array = new GeckoBundle[value.size()];
+ int i = 0;
+ for (final GeckoBundle element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Remove a mapping.
+ *
+ * @param key Key to remove.
+ */
+ public void remove(final String key) {
+ mMap.remove(key);
+ }
+
+ /**
+ * Returns number of mappings in this GeckoBundle.
+ *
+ * @return Number of mappings.
+ */
+ public int size() {
+ return mMap.size();
+ }
+
+ private static Object normalizeValue(final Object value) {
+ if (value instanceof Integer) {
+ // We treat int and double as the same type.
+ return ((Integer) value).doubleValue();
+
+ } else if (value instanceof int[]) {
+ // We treat int[] and double[] as the same type.
+ final int[] array = (int[]) value;
+ return array.length == 0 ? EMPTY_STRING_ARRAY : getDoubleArray(array);
+
+ } else if (value != null && value.getClass().isArray()) {
+ // We treat arrays of all nulls as the same type, including empty arrays.
+ final int len = Array.getLength(value);
+ for (int i = 0; i < len; i++) {
+ if (Array.get(value, i) != null) {
+ return value;
+ }
+ }
+ return len == 0 ? EMPTY_STRING_ARRAY : new String[len];
+ }
+ return value;
+ }
+
+ @Override // Object
+ public boolean equals(final Object other) {
+ if (!(other instanceof GeckoBundle)) {
+ return false;
+ }
+
+ // Support library's SimpleArrayMap.equals is buggy, so roll our own version.
+ final SimpleArrayMap<String, Object> otherMap = ((GeckoBundle) other).mMap;
+ if (mMap == otherMap) {
+ return true;
+ }
+ if (mMap.size() != otherMap.size()) {
+ return false;
+ }
+
+ for (int i = 0; i < mMap.size(); i++) {
+ final String thisKey = mMap.keyAt(i);
+ final int otherKey = otherMap.indexOfKey(thisKey);
+ if (otherKey < 0) {
+ return false;
+ }
+ final Object thisValue = normalizeValue(mMap.valueAt(i));
+ final Object otherValue = normalizeValue(otherMap.valueAt(otherKey));
+ if (thisValue == otherValue) {
+ continue;
+ } else if (thisValue == null || otherValue == null) {
+ return false;
+ }
+
+ final Class<?> thisClass = thisValue.getClass();
+ final Class<?> otherClass = otherValue.getClass();
+ if (thisClass != otherClass && !thisClass.equals(otherClass)) {
+ return false;
+ } else if (!thisClass.isArray()) {
+ if (!thisValue.equals(otherValue)) {
+ return false;
+ }
+ continue;
+ }
+
+ // Work with both primitive arrays and Object arrays, unlike Arrays.equals().
+ final int thisLen = Array.getLength(thisValue);
+ final int otherLen = Array.getLength(otherValue);
+ if (thisLen != otherLen) {
+ return false;
+ }
+ for (int j = 0; j < thisLen; j++) {
+ final Object thisElem = Array.get(thisValue, j);
+ final Object otherElem = Array.get(otherValue, j);
+ if (thisElem != otherElem
+ && (thisElem == null || otherElem == null || !thisElem.equals(otherElem))) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ @Override // Object
+ public int hashCode() {
+ return mMap.hashCode();
+ }
+
+ @Override // Object
+ public String toString() {
+ return mMap.toString();
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject out = new JSONObject();
+ for (int i = 0; i < mMap.size(); i++) {
+ final Object value = mMap.valueAt(i);
+ final Object jsonValue;
+
+ if (value instanceof GeckoBundle) {
+ jsonValue = ((GeckoBundle) value).toJSONObject();
+ } else if (value instanceof GeckoBundle[]) {
+ final GeckoBundle[] array = (GeckoBundle[]) value;
+ final JSONArray jsonArray = new JSONArray();
+ for (final GeckoBundle element : array) {
+ jsonArray.put(element == null ? JSONObject.NULL : element.toJSONObject());
+ }
+ jsonValue = jsonArray;
+ } else if (Build.VERSION.SDK_INT >= 19) {
+ final Object wrapped = JSONObject.wrap(value);
+ jsonValue = wrapped != null ? wrapped : value.toString();
+ } else if (value == null) {
+ jsonValue = JSONObject.NULL;
+ } else if (value.getClass().isArray()) {
+ final JSONArray jsonArray = new JSONArray();
+ for (int j = 0; j < Array.getLength(value); j++) {
+ jsonArray.put(Array.get(value, j));
+ }
+ jsonValue = jsonArray;
+ } else {
+ jsonValue = value;
+ }
+ out.put(mMap.keyAt(i), jsonValue);
+ }
+ return out;
+ }
+
+ public Bundle toBundle() {
+ final Bundle out = new Bundle(mMap.size());
+ for (int i = 0; i < mMap.size(); i++) {
+ final String key = mMap.keyAt(i);
+ final Object val = mMap.valueAt(i);
+
+ if (val == null) {
+ out.putString(key, null);
+ } else if (val instanceof GeckoBundle) {
+ out.putBundle(key, ((GeckoBundle) val).toBundle());
+ } else if (val instanceof GeckoBundle[]) {
+ final GeckoBundle[] array = (GeckoBundle[]) val;
+ final Parcelable[] parcelables = new Parcelable[array.length];
+ for (int j = 0; j < array.length; j++) {
+ if (array[j] != null) {
+ parcelables[j] = array[j].toBundle();
+ }
+ }
+ out.putParcelableArray(key, parcelables);
+ } else if (val instanceof Boolean) {
+ out.putBoolean(key, (Boolean) val);
+ } else if (val instanceof boolean[]) {
+ out.putBooleanArray(key, (boolean[]) val);
+ } else if (val instanceof Byte || val instanceof Short || val instanceof Integer) {
+ out.putInt(key, ((Number) val).intValue());
+ } else if (val instanceof int[]) {
+ out.putIntArray(key, (int[]) val);
+ } else if (val instanceof Float || val instanceof Double || val instanceof Long) {
+ out.putDouble(key, ((Number) val).doubleValue());
+ } else if (val instanceof double[]) {
+ out.putDoubleArray(key, (double[]) val);
+ } else if (val instanceof CharSequence || val instanceof Character) {
+ out.putString(key, val.toString());
+ } else if (val instanceof String[]) {
+ out.putStringArray(key, (String[]) val);
+ } else {
+ throw new UnsupportedOperationException();
+ }
+ }
+ return out;
+ }
+
+ public static GeckoBundle fromBundle(final Bundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+
+ final String[] keys = new String[bundle.size()];
+ final Object[] values = new Object[bundle.size()];
+ int i = 0;
+
+ for (final String key : bundle.keySet()) {
+ final Object value = bundle.get(key);
+ keys[i] = key;
+
+ if (value instanceof Bundle || value == null) {
+ values[i] = fromBundle((Bundle) value);
+ } else if (value instanceof Parcelable[]) {
+ final Parcelable[] array = (Parcelable[]) value;
+ final GeckoBundle[] out = new GeckoBundle[array.length];
+ for (int j = 0; j < array.length; j++) {
+ out[j] = fromBundle((Bundle) array[j]);
+ }
+ values[i] = out;
+ } else if (value instanceof Boolean
+ || value instanceof Integer
+ || value instanceof Double
+ || value instanceof String
+ || value instanceof boolean[]
+ || value instanceof int[]
+ || value instanceof double[]
+ || value instanceof String[]) {
+ values[i] = value;
+ } else if (value instanceof Byte || value instanceof Short) {
+ values[i] = ((Number) value).intValue();
+ } else if (value instanceof Float || value instanceof Long) {
+ values[i] = ((Number) value).doubleValue();
+ } else if (value instanceof CharSequence || value instanceof Character) {
+ values[i] = value.toString();
+ } else {
+ throw new UnsupportedOperationException();
+ }
+
+ i++;
+ }
+ return new GeckoBundle(keys, values);
+ }
+
+ private static Object fromJSONValue(final Object value) throws JSONException {
+ if (value == null || value == JSONObject.NULL) {
+ return null;
+ } else if (value instanceof JSONObject) {
+ return fromJSONObject((JSONObject) value);
+ }
+ if (value instanceof JSONArray) {
+ final JSONArray array = (JSONArray) value;
+ final int len = array.length();
+ if (len == 0) {
+ return EMPTY_BOOLEAN_ARRAY;
+ }
+ Object out = null;
+ for (int i = 0; i < len; i++) {
+ final Object element = fromJSONValue(array.opt(i));
+ if (element == null) {
+ continue;
+ }
+ if (out == null) {
+ Class<?> type = element.getClass();
+ if (type == Boolean.class) {
+ type = boolean.class;
+ } else if (type == Integer.class) {
+ type = int.class;
+ } else if (type == Double.class) {
+ type = double.class;
+ }
+ out = Array.newInstance(type, len);
+ }
+ Array.set(out, i, element);
+ }
+ if (out == null) {
+ // Treat all-null arrays as String arrays.
+ return new String[len];
+ }
+ return out;
+ }
+ if (value instanceof Boolean
+ || value instanceof Integer
+ || value instanceof Double
+ || value instanceof String) {
+ return value;
+ }
+ if (value instanceof Byte || value instanceof Short) {
+ return ((Number) value).intValue();
+ }
+ if (value instanceof Float || value instanceof Long) {
+ return ((Number) value).doubleValue();
+ }
+ return value.toString();
+ }
+
+ public static GeckoBundle fromJSONObject(final JSONObject obj) throws JSONException {
+ if (obj == null || obj == JSONObject.NULL) {
+ return null;
+ }
+
+ final String[] keys = new String[obj.length()];
+ final Object[] values = new Object[obj.length()];
+
+ final Iterator<String> iter = obj.keys();
+ for (int i = 0; iter.hasNext(); i++) {
+ final String key = iter.next();
+ keys[i] = key;
+ values[i] = fromJSONValue(obj.opt(key));
+ }
+ return new GeckoBundle(keys, values);
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel dest, final int flags) {
+ final int len = mMap.size();
+ dest.writeInt(len);
+
+ for (int i = 0; i < len; i++) {
+ dest.writeString(mMap.keyAt(i));
+ dest.writeValue(mMap.valueAt(i));
+ }
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ public void readFromParcel(final Parcel source) {
+ final ClassLoader loader = getClass().getClassLoader();
+ final int len = source.readInt();
+ mMap.clear();
+ mMap.ensureCapacity(len);
+
+ for (int i = 0; i < len; i++) {
+ final String key = source.readString();
+ Object val = source.readValue(loader);
+
+ if (val instanceof Parcelable[]) {
+ final Parcelable[] array = (Parcelable[]) val;
+ val = Arrays.copyOf(array, array.length, GeckoBundle[].class);
+ }
+
+ mMap.put(key, val);
+ }
+ }
+
+ public static final Parcelable.Creator<GeckoBundle> CREATOR =
+ new Parcelable.Creator<GeckoBundle>() {
+ @Override
+ public GeckoBundle createFromParcel(final Parcel source) {
+ final GeckoBundle bundle = new GeckoBundle(0);
+ bundle.readFromParcel(source);
+ return bundle;
+ }
+
+ @Override
+ public GeckoBundle[] newArray(final int size) {
+ return new GeckoBundle[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java
new file mode 100644
index 0000000000..ba317e9b48
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java
@@ -0,0 +1,255 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCodecList;
+import android.os.Build;
+import android.util.Log;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class HardwareCodecCapabilityUtils {
+ private static final String LOGTAG = "HardwareCodecCapability";
+
+ // List of supported HW VP8 encoders.
+ private static final String[] supportedVp8HwEncCodecPrefixes = {"OMX.qcom.", "OMX.Intel."};
+ // List of supported HW VP8 decoders.
+ private static final String[] supportedVp8HwDecCodecPrefixes = {
+ "OMX.qcom.", "OMX.Nvidia.", "OMX.Exynos.", "c2.exynos", "OMX.Intel."
+ };
+ private static final String VP8_MIME_TYPE = "video/x-vnd.on2.vp8";
+ // List of supported HW VP9 codecs.
+ private static final String[] supportedVp9HwCodecPrefixes = {
+ "OMX.qcom.", "OMX.Exynos.", "c2.exynos"
+ };
+ private static final String VP9_MIME_TYPE = "video/x-vnd.on2.vp9";
+ // List of supported HW H.264 codecs.
+ private static final String[] supportedH264HwCodecPrefixes = {
+ "OMX.qcom.",
+ "OMX.Intel.",
+ "OMX.Exynos.",
+ "c2.exynos",
+ "OMX.Nvidia",
+ "OMX.SEC.",
+ "OMX.IMG.",
+ "OMX.k3.",
+ "OMX.hisi.",
+ "OMX.TI.",
+ "OMX.MTK."
+ };
+ private static final String H264_MIME_TYPE = "video/avc";
+ // NV12 color format supported by QCOM codec, but not declared in MediaCodec -
+ // see /hardware/qcom/media/mm-core/inc/OMX_QCOMExtns.h
+ private static final int COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m = 0x7FA30C04;
+ // Allowable color formats supported by codec - in order of preference.
+ private static final int[] supportedColorList = {
+ CodecCapabilities.COLOR_FormatYUV420Planar,
+ CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
+ CodecCapabilities.COLOR_QCOM_FormatYUV420SemiPlanar,
+ COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m
+ };
+ private static final String[] adaptivePlaybackBlacklist = {
+ "GT-I9300", // S3 (I9300 / I9300I)
+ "SCH-I535", // S3
+ "SGH-T999", // S3 (T-Mobile)
+ "SAMSUNG-SGH-T999", // S3 (T-Mobile)
+ "SGH-M919", // S4
+ "GT-I9505", // S4
+ "GT-I9515", // S4
+ "SCH-R970", // S4
+ "SGH-I337", // S4
+ "SPH-L720", // S4 (Sprint)
+ "SAMSUNG-SGH-I337", // S4
+ "GT-I9195", // S4 Mini
+ "300E5EV/300E4EV/270E5EV/270E4EV/2470EV/2470EE",
+ "LG-D605" // LG Optimus L9 II
+ };
+
+ private static MediaCodecInfo[] getCodecListWithOldAPI() {
+ int numCodecs = 0;
+ try {
+ numCodecs = MediaCodecList.getCodecCount();
+ } catch (final RuntimeException e) {
+ Log.e(LOGTAG, "Failed to retrieve media codec count", e);
+ return new MediaCodecInfo[numCodecs];
+ }
+
+ final MediaCodecInfo[] codecList = new MediaCodecInfo[numCodecs];
+
+ for (int i = 0; i < numCodecs; ++i) {
+ final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ codecList[i] = info;
+ }
+
+ return codecList;
+ }
+
+ @WrapForJNI
+ public static String[] getDecoderSupportedMimeTypes() {
+ final MediaCodecInfo[] codecList;
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ codecList = getCodecListWithOldAPI();
+ } else {
+ final MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS);
+ codecList = list.getCodecInfos();
+ }
+
+ final Set<String> supportedTypes = new HashSet<>();
+
+ for (final MediaCodecInfo codec : codecList) {
+ if (codec.isEncoder()) {
+ continue;
+ }
+ supportedTypes.addAll(Arrays.asList(codec.getSupportedTypes()));
+ }
+
+ return supportedTypes.toArray(new String[0]);
+ }
+
+ @WrapForJNI
+ public static boolean checkSupportsAdaptivePlayback(
+ final MediaCodec aCodec, final String aMimeType) {
+ // isFeatureSupported supported on API level >= 19.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT
+ || isAdaptivePlaybackBlacklisted(aMimeType)) {
+ return false;
+ }
+
+ try {
+ final MediaCodecInfo info = aCodec.getCodecInfo();
+ final MediaCodecInfo.CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType);
+ return capabilities != null
+ && capabilities.isFeatureSupported(
+ MediaCodecInfo.CodecCapabilities.FEATURE_AdaptivePlayback);
+ } catch (final IllegalArgumentException e) {
+ Log.e(LOGTAG, "Retrieve codec information failed", e);
+ }
+ return false;
+ }
+
+ // See Bug1360626 and
+ // https://codereview.chromium.org/1869103002 for details.
+ private static boolean isAdaptivePlaybackBlacklisted(final String aMimeType) {
+ Log.d(LOGTAG, "The device ModelID is " + Build.MODEL);
+ if (!aMimeType.equals("video/avc") && !aMimeType.equals("video/avc1")) {
+ return false;
+ }
+
+ if (!Build.MANUFACTURER.toLowerCase(Locale.ROOT).equals("samsung")) {
+ return false;
+ }
+
+ for (final String model : adaptivePlaybackBlacklist) {
+ if (Build.MODEL.startsWith(model)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static boolean getHWCodecCapability(final String aMimeType, final boolean aIsEncoder) {
+ if (Build.VERSION.SDK_INT >= 20) {
+ for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) {
+ final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder() != aIsEncoder) {
+ continue;
+ }
+ String name = null;
+ for (final String mimeType : info.getSupportedTypes()) {
+ if (mimeType.equals(aMimeType)) {
+ name = info.getName();
+ break;
+ }
+ }
+ if (name == null) {
+ continue; // No HW support in this codec; try the next one.
+ }
+ Log.d(LOGTAG, "Found candidate" + (aIsEncoder ? " encoder " : " decoder ") + name);
+
+ // Check if this is supported codec.
+ final String[] hwList = getSupportedHWCodecPrefixes(aMimeType, aIsEncoder);
+ if (hwList == null) {
+ continue;
+ }
+ boolean supportedCodec = false;
+ for (final String codecPrefix : hwList) {
+ if (name.startsWith(codecPrefix)) {
+ supportedCodec = true;
+ break;
+ }
+ }
+ if (!supportedCodec) {
+ continue;
+ }
+
+ // Check if codec supports either yuv420 or nv12.
+ final CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType);
+ for (final int colorFormat : capabilities.colorFormats) {
+ Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat));
+ }
+ for (final int supportedColorFormat : supportedColorList) {
+ for (final int codecColorFormat : capabilities.colorFormats) {
+ if (codecColorFormat == supportedColorFormat) {
+ // Found supported HW Codec.
+ Log.d(
+ LOGTAG,
+ "Found target"
+ + (aIsEncoder ? " encoder " : " decoder ")
+ + name
+ + ". Color: 0x"
+ + Integer.toHexString(codecColorFormat));
+ return true;
+ }
+ }
+ }
+ }
+ }
+ // No HW codec.
+ return false;
+ }
+
+ private static String[] getSupportedHWCodecPrefixes(
+ final String aMimeType, final boolean aIsEncoder) {
+ if (aMimeType.equals(H264_MIME_TYPE)) {
+ return supportedH264HwCodecPrefixes;
+ }
+ if (aMimeType.equals(VP9_MIME_TYPE)) {
+ return supportedVp9HwCodecPrefixes;
+ }
+ if (aMimeType.equals(VP8_MIME_TYPE)) {
+ return aIsEncoder ? supportedVp8HwEncCodecPrefixes : supportedVp8HwDecCodecPrefixes;
+ }
+ return null;
+ }
+
+ @WrapForJNI
+ public static boolean hasHWVP8(final boolean aIsEncoder) {
+ return getHWCodecCapability(VP8_MIME_TYPE, aIsEncoder);
+ }
+
+ @WrapForJNI
+ public static boolean hasHWVP9(final boolean aIsEncoder) {
+ return getHWCodecCapability(VP9_MIME_TYPE, aIsEncoder);
+ }
+
+ @WrapForJNI
+ public static boolean hasHWH264(final boolean aIsEncoder) {
+ return getHWCodecCapability(H264_MIME_TYPE, aIsEncoder);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean hasHWH264() {
+ return getHWCodecCapability(H264_MIME_TYPE, true)
+ && getHWCodecCapability(H264_MIME_TYPE, false);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java
new file mode 100644
index 0000000000..bab64b92d4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java
@@ -0,0 +1,46 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+
+public final class HardwareUtils {
+ private static final String LOGTAG = "GeckoHardwareUtils";
+
+ private static volatile boolean sInited;
+
+ // These are all set once, during init.
+ private static volatile boolean sIsLargeTablet;
+ private static volatile boolean sIsSmallTablet;
+
+ private HardwareUtils() {}
+
+ public static synchronized void init(final Context context) {
+ if (sInited) {
+ return;
+ }
+
+ // Pre-populate common flags from the context.
+ final int screenLayoutSize =
+ context.getResources().getConfiguration().screenLayout
+ & Configuration.SCREENLAYOUT_SIZE_MASK;
+ if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_XLARGE) {
+ sIsLargeTablet = true;
+ } else if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_LARGE) {
+ sIsSmallTablet = true;
+ }
+
+ sInited = true;
+ }
+
+ public static boolean isTablet(final Context context) {
+ if (!sInited) {
+ init(context);
+ }
+ return sIsLargeTablet || sIsSmallTablet;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java
new file mode 100644
index 0000000000..96e5c7b311
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java
@@ -0,0 +1,12 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import java.util.concurrent.Executor;
+
+public interface IXPCOMEventTarget extends Executor {
+ public boolean isOnCurrentThread();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java
new file mode 100644
index 0000000000..4ab330f182
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java
@@ -0,0 +1,88 @@
+/* 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Bitmap;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.geckoview.GeckoResult;
+
+/** Provides access to Gecko's Image processing library. */
+@AnyThread
+public class ImageDecoder {
+ private static ImageDecoder instance;
+
+ private ImageDecoder() {}
+
+ public static ImageDecoder instance() {
+ if (instance == null) {
+ instance = new ImageDecoder();
+ }
+
+ return instance;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Decode")
+ private static native void nativeDecode(
+ final String uri, final int desiredLength, GeckoResult<Bitmap> result);
+
+ /**
+ * Fetches and decodes an image at the specified location. This method supports SVG, PNG, Bitmap
+ * and other formats supported by Gecko.
+ *
+ * @param uri location of the image. Can be either a remote https:// location, file:/// if the
+ * file is local or a resource://android/ if the file is located inside the APK.
+ * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to
+ * resource://android/assets/test.png.
+ * @return A {@link GeckoResult} to the decoded image.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> decode(final @NonNull String uri) {
+ return decode(uri, 0);
+ }
+
+ /**
+ * Fetches and decodes an image at the specified location and resizes it to the desired length.
+ * This method supports SVG, PNG, Bitmap and other formats supported by Gecko.
+ *
+ * <p>Note: The final size might differ slightly from the requested output.
+ *
+ * @param uri location of the image. Can be either a remote https:// location, file:/// if the
+ * file is local or a resource://android/ if the file is located inside the APK.
+ * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to
+ * resource://android/assets/test.png.
+ * @param desiredLength Longest size for the image in device pixel units. The resulting image
+ * might be slightly different if the image cannot be resized efficiently. If desiredLength is
+ * 0 then the image will be decoded to its natural size.
+ * @return A {@link GeckoResult} to the decoded image.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> decode(final @NonNull String uri, final int desiredLength) {
+ if (uri == null) {
+ throw new IllegalArgumentException("Uri cannot be null");
+ }
+
+ final GeckoResult<Bitmap> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeDecode(uri, desiredLength, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeDecode",
+ String.class,
+ uri,
+ int.class,
+ desiredLength,
+ GeckoResult.class,
+ result);
+ }
+
+ return result;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java
new file mode 100644
index 0000000000..d57147f363
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java
@@ -0,0 +1,334 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.geckoview.GeckoResult;
+
+/**
+ * Represents an Web API image resource as used in web app manifests and media session metadata.
+ *
+ * @see <a href="https://www.w3.org/TR/image-resource">Image Resource</a>
+ */
+@AnyThread
+public class ImageResource {
+ private static final String LOGTAG = "ImageResource";
+ private static final boolean DEBUG = false;
+
+ /** Represents the size of an image resource option. */
+ public static class Size {
+ /** The width in pixels. */
+ public final int width;
+
+ /** The height in pixels. */
+ public final int height;
+
+ /**
+ * Size contructor.
+ *
+ * @param width The width in pixels.
+ * @param height The height in pixels.
+ */
+ public Size(final int width, final int height) {
+ this.width = width;
+ this.height = height;
+ }
+ }
+
+ /** The URI of the image resource. */
+ public final @NonNull String src;
+
+ /** The MIME type of the image resource. */
+ public final @Nullable String type;
+
+ /** A {@link Size} array of supported images sizes. */
+ public final @Nullable Size[] sizes;
+
+ /**
+ * ImageResource constructor.
+ *
+ * @param src The URI string of the image resource.
+ * @param type The MIME type of the image resource.
+ * @param sizes The supported images {@link Size} array.
+ */
+ public ImageResource(
+ final @NonNull String src, final @Nullable String type, final @Nullable Size[] sizes) {
+ this.src = src.toLowerCase(Locale.ROOT);
+ this.type = type != null ? type.toLowerCase(Locale.ROOT) : null;
+ this.sizes = sizes;
+ }
+
+ /**
+ * ImageResource constructor.
+ *
+ * @param src The URI string of the image resource.
+ * @param type The MIME type of the image resource.
+ * @param sizes The supported images sizes string.
+ * @see <a href="https://html.spec.whatwg.org/multipage/semantics.html#dom-link-sizes">Attribute
+ * spec for sizes</a>
+ */
+ public ImageResource(
+ final @NonNull String src, final @Nullable String type, final @Nullable String sizes) {
+ this(src, type, parseSizes(sizes));
+ }
+
+ private static @Nullable Size[] parseSizes(final @Nullable String sizesStr) {
+ if (sizesStr == null || sizesStr.isEmpty()) {
+ return null;
+ }
+
+ final String[] sizesStrs = sizesStr.toLowerCase(Locale.ROOT).split(" ");
+ final List<Size> sizes = new ArrayList<Size>();
+
+ for (final String sizeStr : sizesStrs) {
+ if (sizesStr.equals("any")) {
+ // 0-width size will always be favored.
+ sizes.add(new Size(0, 0));
+ continue;
+ }
+ final String[] widthHeight = sizeStr.split("x");
+ if (widthHeight.length != 2) {
+ // Not spec-compliant size.
+ continue;
+ }
+ try {
+ sizes.add(new Size(Integer.valueOf(widthHeight[0]), Integer.valueOf(widthHeight[1])));
+ } catch (final NumberFormatException e) {
+ Log.e(LOGTAG, "Invalid image resource size", e);
+ }
+ }
+ if (sizes.isEmpty()) {
+ return null;
+ }
+ return sizes.toArray(new Size[0]);
+ }
+
+ public static @NonNull ImageResource fromBundle(final GeckoBundle bundle) {
+ return new ImageResource(
+ bundle.getString("src"), bundle.getString("type"), bundle.getString("sizes"));
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("ImageResource {");
+ builder
+ .append("src=")
+ .append(src)
+ .append("type=")
+ .append(type)
+ .append("sizes=")
+ .append(sizes)
+ .append("}");
+ return builder.toString();
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return ImageDecoder.instance().decode(src, size);
+ }
+
+ /**
+ * Represents a collection of {@link ImageResource} options. Image resources are often used in a
+ * collection to provide multiple image options for various sizes. This data structure can be used
+ * to retrieve the best image resource for any given target image size.
+ */
+ public static class Collection {
+ private static class SizeIndexPair {
+ public final int width;
+ public final int idx;
+
+ public SizeIndexPair(final int width, final int idx) {
+ this.width = width;
+ this.idx = idx;
+ }
+ }
+
+ // The individual image resources, usually each with a unique src.
+ private final List<ImageResource> mImages;
+
+ // A sorted size-index list. The list is sorted based on the supported
+ // sizes of the images in ascending order.
+ private final List<SizeIndexPair> mSizeIndex;
+
+ /* package */ Collection() {
+ mImages = new ArrayList<>();
+ mSizeIndex = new ArrayList<>();
+ }
+
+ /** Builder class for the construction of a {@link Collection}. */
+ public static class Builder {
+ final Collection mCollection;
+
+ public Builder() {
+ mCollection = new Collection();
+ }
+
+ /**
+ * Add an image resource to the collection.
+ *
+ * @param image The {@link ImageResource} to be added.
+ * @return This builder instance.
+ */
+ public @NonNull Builder add(final ImageResource image) {
+ final int index = mCollection.mImages.size();
+
+ if (image.sizes == null) {
+ // Null-sizes are handled the same as `any`.
+ mCollection.mSizeIndex.add(new SizeIndexPair(0, index));
+ } else {
+ for (final Size size : image.sizes) {
+ mCollection.mSizeIndex.add(new SizeIndexPair(size.width, index));
+ }
+ }
+ mCollection.mImages.add(image);
+ return this;
+ }
+
+ /**
+ * Finalize the collection.
+ *
+ * @return The final collection.
+ */
+ public @NonNull Collection build() {
+ Collections.sort(mCollection.mSizeIndex, (a, b) -> Integer.compare(a.width, b.width));
+ return mCollection;
+ }
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("ImageResource.Collection {");
+ builder.append("images=[");
+
+ for (final ImageResource image : mImages) {
+ builder.append(image).append(", ");
+ }
+ builder.append("]}");
+ return builder.toString();
+ }
+
+ /**
+ * Returns the best suited {@link ImageResource} for the given size. This is usually determined
+ * based on the minimal difference between the given size and one of the supported widths of an
+ * image resource.
+ *
+ * @param size The target size for the image in pixels.
+ * @return The best {@link ImageResource} for the given size from this collection.
+ */
+ public @Nullable ImageResource getBest(final int size) {
+ if (mSizeIndex.isEmpty()) {
+ return null;
+ }
+ int bestMatchIdx = mSizeIndex.get(0).idx;
+ int lastDiff = size;
+ for (final SizeIndexPair sizeIndex : mSizeIndex) {
+ final int diff = Math.abs(sizeIndex.width - size);
+ if (lastDiff <= diff) {
+ // With increasing widths, the difference can only grow now.
+ // 0-width means "any", so we're finished at the first
+ // entry.
+ break;
+ }
+ lastDiff = diff;
+ bestMatchIdx = sizeIndex.idx;
+ }
+ return mImages.get(bestMatchIdx);
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ final ImageResource image = getBest(size);
+ if (image == null) {
+ return GeckoResult.fromValue(null);
+ }
+ return image.getBitmap(size);
+ }
+
+ public static Collection fromSizeSrcBundle(final GeckoBundle bundle) {
+ final Builder builder = new Builder();
+
+ for (final String key : bundle.keys()) {
+ final Integer intKey = Integer.valueOf(key);
+ if (intKey == null) {
+ Log.e(LOGTAG, "Non-integer image key: " + intKey);
+
+ if (DEBUG) {
+ throw new RuntimeException("Non-integer image key: " + key);
+ }
+ continue;
+ }
+
+ final String src = getImageValue(bundle.get(key));
+ if (src != null) {
+ // Given the bundle structure, we don't have insight on
+ // individual image resources so we have to create an
+ // instance for each size entry.
+ final ImageResource image =
+ new ImageResource(src, null, new Size[] {new Size(intKey, intKey)});
+ builder.add(image);
+ }
+ }
+ return builder.build();
+ }
+
+ private static String getImageValue(final Object value) {
+ // The image value can either be an object containing images for
+ // each theme...
+ if (value instanceof GeckoBundle) {
+ // We don't support theme_images yet, so let's just return the
+ // default value.
+ final GeckoBundle themeImages = (GeckoBundle) value;
+ final Object defaultImages = themeImages.get("default");
+
+ if (!(defaultImages instanceof String)) {
+ if (DEBUG) {
+ throw new RuntimeException("Unexpected themed_icon value.");
+ }
+ Log.e(LOGTAG, "Unexpected themed_icon value.");
+ return null;
+ }
+
+ return (String) defaultImages;
+ }
+
+ // ... or just a URL.
+ if (value instanceof String) {
+ return (String) value;
+ }
+
+ // We never expect it to be something else, so let's error out here.
+ if (DEBUG) {
+ throw new RuntimeException("Unexpected image value: " + value);
+ }
+
+ Log.e(LOGTAG, "Unexpected image value.");
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java
new file mode 100644
index 0000000000..e0a0d924a9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java
@@ -0,0 +1,20 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.view.InputDevice;
+
+public class InputDeviceUtils {
+ public static boolean isPointerTypeDevice(final InputDevice inputDevice) {
+ final int sources = inputDevice.getSources();
+ return (sources
+ & (InputDevice.SOURCE_CLASS_JOYSTICK
+ | InputDevice.SOURCE_CLASS_POINTER
+ | InputDevice.SOURCE_CLASS_POSITION
+ | InputDevice.SOURCE_CLASS_TRACKBALL))
+ != 0;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
new file mode 100644
index 0000000000..1255f238f7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
@@ -0,0 +1,117 @@
+/*
+ * 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/.
+ */
+
+package org.mozilla.gecko.util;
+
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.net.Uri;
+import java.net.URISyntaxException;
+import java.util.Locale;
+
+/** Utilities for Intents. */
+public class IntentUtils {
+ private IntentUtils() {}
+
+ /**
+ * Return a Uri instance which is equivalent to uri, but with a guaranteed-lowercase scheme as if
+ * the API level 16 method Uri.normalizeScheme had been called.
+ *
+ * @param uri The URI string to normalize.
+ * @return The corresponding normalized Uri.
+ */
+ private static Uri normalizeUriScheme(final Uri uri) {
+ final String scheme = uri.getScheme();
+ final String lower = scheme.toLowerCase(Locale.ROOT);
+ if (lower.equals(scheme)) {
+ return uri;
+ }
+
+ // Otherwise, return a new URI with a normalized scheme.
+ return uri.buildUpon().scheme(lower).build();
+ }
+
+ /**
+ * Return a normalized Uri instance that corresponds to the given URI string with cross-API-level
+ * compatibility.
+ *
+ * @param aUri The URI string to normalize.
+ * @return The corresponding normalized Uri.
+ */
+ public static Uri normalizeUri(final String aUri) {
+ final Uri normUri =
+ normalizeUriScheme(
+ aUri.indexOf(':') >= 0 ? Uri.parse(aUri) : new Uri.Builder().scheme(aUri).build());
+ return normUri;
+ }
+
+ public static boolean isUriSafeForScheme(final String aUri) {
+ return isUriSafeForScheme(normalizeUri(aUri));
+ }
+
+ /**
+ * Verify whether the given URI is considered safe to load in respect to its scheme. Unsafe URIs
+ * should be blocked from further handling.
+ *
+ * @param aUri The URI instance to test.
+ * @return Whether the provided URI is considered safe in respect to its scheme.
+ */
+ public static boolean isUriSafeForScheme(final Uri aUri) {
+ final String scheme = aUri.getScheme();
+ if ("tel".equals(scheme) || "sms".equals(scheme)) {
+ // Bug 794034 - We don't want to pass MWI or USSD codes to the
+ // dialer, and ensure the Uri class doesn't parse a URI
+ // containing a fragment ('#')
+ final String number = aUri.getSchemeSpecificPart();
+ if (number.contains("#") || number.contains("*") || aUri.getFragment() != null) {
+ return false;
+ }
+ }
+
+ if (("intent".equals(scheme) || "android-app".equals(scheme))) {
+ // Bug 1356893 - Rject intents with file data schemes.
+ return getSafeIntent(aUri) != null;
+ }
+
+ return true;
+ }
+
+ /**
+ * Create a safe intent for the given URI. Intents with file data schemes are considered unsafe.
+ *
+ * @param aUri The URI for the intent.
+ * @return A safe intent for the given URI or null if URI is considered unsafe.
+ */
+ public static Intent getSafeIntent(final Uri aUri) {
+ final Intent intent;
+ try {
+ intent = Intent.parseUri(aUri.toString(), 0);
+ } catch (final URISyntaxException e) {
+ return null;
+ }
+
+ final Uri data = intent.getData();
+ if (data != null && "file".equals(normalizeUriScheme(data).getScheme())) {
+ return null;
+ }
+
+ // Only open applications which can accept arbitrary data from a browser.
+ intent.addCategory(Intent.CATEGORY_BROWSABLE);
+
+ // Prevent site from explicitly opening our internal activities,
+ // which can leak data.
+ intent.setComponent(null);
+ nullIntentSelector(intent);
+
+ return intent;
+ }
+
+ // We create a separate method to better encapsulate the @TargetApi use.
+ @TargetApi(15)
+ private static void nullIntentSelector(final Intent intent) {
+ intent.setSelector(null);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java
new file mode 100644
index 0000000000..b8f15c04e3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java
@@ -0,0 +1,168 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.telephony.TelephonyManager;
+
+public class NetworkUtils {
+ /*
+ * Keep the below constants in sync with
+ * http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl
+ */
+ public enum ConnectionSubType {
+ CELL_2G("2g"),
+ CELL_3G("3g"),
+ CELL_4G("4g"),
+ ETHERNET("ethernet"),
+ WIFI("wifi"),
+ WIMAX("wimax"),
+ UNKNOWN("unknown");
+
+ public final String value;
+
+ ConnectionSubType(final String value) {
+ this.value = value;
+ }
+ }
+
+ /*
+ * Keep the below constants in sync with
+ * http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl
+ */
+ public enum NetworkStatus {
+ UP("up"),
+ DOWN("down"),
+ UNKNOWN("unknown");
+
+ public final String value;
+
+ NetworkStatus(final String value) {
+ this.value = value;
+ }
+ }
+
+ // Connection Type defined in Network Information API v3.
+ // See Bug 1270401 - current W3C Spec (Editor's Draft) is different, it also contains wimax,
+ // mixed, unknown.
+ // W3C spec: http://w3c.github.io/netinfo/#the-connectiontype-enum
+ public enum ConnectionType {
+ CELLULAR(0),
+ BLUETOOTH(1),
+ ETHERNET(2),
+ WIFI(3),
+ OTHER(4),
+ NONE(5);
+
+ public final int value;
+
+ ConnectionType(final int value) {
+ this.value = value;
+ }
+ }
+
+ public static boolean isConnected(final ConnectivityManager connectivityManager) {
+ if (connectivityManager == null) {
+ return false;
+ }
+
+ final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
+ return networkInfo != null && networkInfo.isConnected();
+ }
+
+ /** For mobile connections, maps particular connection subtype to a general 2G, 3G, 4G bucket. */
+ public static ConnectionSubType getConnectionSubType(
+ final ConnectivityManager connectivityManager) {
+ if (connectivityManager == null) {
+ return ConnectionSubType.UNKNOWN;
+ }
+
+ final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
+
+ if (networkInfo == null) {
+ return ConnectionSubType.UNKNOWN;
+ }
+
+ switch (networkInfo.getType()) {
+ case ConnectivityManager.TYPE_ETHERNET:
+ return ConnectionSubType.ETHERNET;
+ case ConnectivityManager.TYPE_MOBILE:
+ return getGenericMobileSubtype(networkInfo.getSubtype());
+ case ConnectivityManager.TYPE_WIMAX:
+ return ConnectionSubType.WIMAX;
+ case ConnectivityManager.TYPE_WIFI:
+ return ConnectionSubType.WIFI;
+ default:
+ return ConnectionSubType.UNKNOWN;
+ }
+ }
+
+ public static ConnectionType getConnectionType(final ConnectivityManager connectivityManager) {
+ if (connectivityManager == null) {
+ return ConnectionType.NONE;
+ }
+
+ final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo();
+ if (networkInfo == null) {
+ return ConnectionType.NONE;
+ }
+
+ switch (networkInfo.getType()) {
+ case ConnectivityManager.TYPE_BLUETOOTH:
+ return ConnectionType.BLUETOOTH;
+ case ConnectivityManager.TYPE_ETHERNET:
+ return ConnectionType.ETHERNET;
+ // Fallthrough, MOBILE and WIMAX both map to CELLULAR.
+ case ConnectivityManager.TYPE_MOBILE:
+ case ConnectivityManager.TYPE_WIMAX:
+ return ConnectionType.CELLULAR;
+ case ConnectivityManager.TYPE_WIFI:
+ return ConnectionType.WIFI;
+ default:
+ return ConnectionType.OTHER;
+ }
+ }
+
+ public static NetworkStatus getNetworkStatus(final ConnectivityManager connectivityManager) {
+ if (connectivityManager == null) {
+ return NetworkStatus.UNKNOWN;
+ }
+
+ if (isConnected(connectivityManager)) {
+ return NetworkStatus.UP;
+ }
+ return NetworkStatus.DOWN;
+ }
+
+ private static ConnectionSubType getGenericMobileSubtype(final int subtype) {
+ switch (subtype) {
+ // 2G types: fallthrough 5x
+ case TelephonyManager.NETWORK_TYPE_GPRS:
+ case TelephonyManager.NETWORK_TYPE_EDGE:
+ case TelephonyManager.NETWORK_TYPE_CDMA:
+ case TelephonyManager.NETWORK_TYPE_1xRTT:
+ case TelephonyManager.NETWORK_TYPE_IDEN:
+ return ConnectionSubType.CELL_2G;
+ // 3G types: fallthrough 9x
+ case TelephonyManager.NETWORK_TYPE_UMTS:
+ case TelephonyManager.NETWORK_TYPE_EVDO_0:
+ case TelephonyManager.NETWORK_TYPE_EVDO_A:
+ case TelephonyManager.NETWORK_TYPE_HSDPA:
+ case TelephonyManager.NETWORK_TYPE_HSUPA:
+ case TelephonyManager.NETWORK_TYPE_HSPA:
+ case TelephonyManager.NETWORK_TYPE_EVDO_B:
+ case TelephonyManager.NETWORK_TYPE_EHRPD:
+ case TelephonyManager.NETWORK_TYPE_HSPAP:
+ return ConnectionSubType.CELL_3G;
+ // 4G - just one type!
+ case TelephonyManager.NETWORK_TYPE_LTE:
+ return ConnectionSubType.CELL_4G;
+ default:
+ return ConnectionSubType.UNKNOWN;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java
new file mode 100644
index 0000000000..2fb4015f41
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java
@@ -0,0 +1,149 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// This code is based on AOSP /libcore/luni/src/main/java/java/net/ProxySelectorImpl.java
+
+package org.mozilla.gecko.util;
+
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.net.URI;
+import java.net.URLConnection;
+import java.util.List;
+
+public class ProxySelector {
+ public static URLConnection openConnectionWithProxy(final URI uri) throws IOException {
+ final java.net.ProxySelector ps = java.net.ProxySelector.getDefault();
+ Proxy proxy = Proxy.NO_PROXY;
+ if (ps != null) {
+ final List<Proxy> proxies = ps.select(uri);
+ if (proxies != null && !proxies.isEmpty()) {
+ proxy = proxies.get(0);
+ }
+ }
+
+ return uri.toURL().openConnection(proxy);
+ }
+
+ public ProxySelector() {}
+
+ public Proxy select(final String scheme, final String host) {
+ int port = -1;
+ Proxy proxy = null;
+ String nonProxyHostsKey = null;
+ boolean httpProxyOkay = true;
+ if ("http".equalsIgnoreCase(scheme)) {
+ port = 80;
+ nonProxyHostsKey = "http.nonProxyHosts";
+ proxy = lookupProxy("http.proxyHost", "http.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("https".equalsIgnoreCase(scheme)) {
+ port = 443;
+ nonProxyHostsKey = "https.nonProxyHosts"; // RI doesn't support this
+ proxy = lookupProxy("https.proxyHost", "https.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("ftp".equalsIgnoreCase(scheme)) {
+ port = 80; // not 21 as you might guess
+ nonProxyHostsKey = "ftp.nonProxyHosts";
+ proxy = lookupProxy("ftp.proxyHost", "ftp.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("socket".equalsIgnoreCase(scheme)) {
+ httpProxyOkay = false;
+ } else {
+ return Proxy.NO_PROXY;
+ }
+
+ if (nonProxyHostsKey != null && isNonProxyHost(host, System.getProperty(nonProxyHostsKey))) {
+ return Proxy.NO_PROXY;
+ }
+
+ if (proxy != null) {
+ return proxy;
+ }
+
+ if (httpProxyOkay) {
+ proxy = lookupProxy("proxyHost", "proxyPort", Proxy.Type.HTTP, port);
+ if (proxy != null) {
+ return proxy;
+ }
+ }
+
+ proxy = lookupProxy("socksProxyHost", "socksProxyPort", Proxy.Type.SOCKS, 1080);
+ if (proxy != null) {
+ return proxy;
+ }
+
+ return Proxy.NO_PROXY;
+ }
+
+ /** Returns the proxy identified by the {@code hostKey} system property, or null. */
+ @Nullable
+ private Proxy lookupProxy(
+ final String hostKey, final String portKey, final Proxy.Type type, final int defaultPort) {
+ final String host = System.getProperty(hostKey);
+ if (TextUtils.isEmpty(host)) {
+ return null;
+ }
+
+ final int port = getSystemPropertyInt(portKey, defaultPort);
+ if (port == -1) {
+ // Port can be -1. See bug 1270529.
+ return null;
+ }
+
+ return new Proxy(type, InetSocketAddress.createUnresolved(host, port));
+ }
+
+ private int getSystemPropertyInt(final String key, final int defaultValue) {
+ final String string = System.getProperty(key);
+ if (string != null) {
+ try {
+ return Integer.parseInt(string);
+ } catch (final NumberFormatException ignored) {
+ }
+ }
+ return defaultValue;
+ }
+
+ /**
+ * Returns true if the {@code nonProxyHosts} system property pattern exists and matches {@code
+ * host}.
+ */
+ private boolean isNonProxyHost(final String host, final String nonProxyHosts) {
+ if (host == null || nonProxyHosts == null) {
+ return false;
+ }
+
+ // construct pattern
+ final StringBuilder patternBuilder = new StringBuilder();
+ for (int i = 0; i < nonProxyHosts.length(); i++) {
+ final char c = nonProxyHosts.charAt(i);
+ switch (c) {
+ case '.':
+ patternBuilder.append("\\.");
+ break;
+ case '*':
+ patternBuilder.append(".*");
+ break;
+ default:
+ patternBuilder.append(c);
+ }
+ }
+ // check whether the host is the nonProxyHosts.
+ final String pattern = patternBuilder.toString();
+ return host.matches(pattern);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java
new file mode 100644
index 0000000000..00625800c9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+public final class ThreadUtils {
+ private static final String LOGTAG = "ThreadUtils";
+
+ /**
+ * Controls the action taken when a method like {@link
+ * ThreadUtils#assertOnUiThread(AssertBehavior)} detects a problem.
+ */
+ public enum AssertBehavior {
+ NONE,
+ THROW,
+ }
+
+ private static final Thread sUiThread = Looper.getMainLooper().getThread();
+ private static final Handler sUiHandler = new Handler(Looper.getMainLooper());
+
+ // Referenced directly from GeckoAppShell in highly performance-sensitive code (The extra
+ // function call of the getter was harming performance. (Bug 897123))
+ // Once Bug 709230 is resolved we should reconsider this as ProGuard should be able to optimise
+ // this out at compile time.
+ public static Handler sGeckoHandler;
+ public static volatile Thread sGeckoThread;
+
+ public static Thread getUiThread() {
+ return sUiThread;
+ }
+
+ public static Handler getUiHandler() {
+ return sUiHandler;
+ }
+
+ /**
+ * Runs the provided runnable on the UI thread. If this method is called on the UI thread the
+ * runnable will be executed synchronously.
+ *
+ * @param runnable the runnable to be executed.
+ */
+ public static void runOnUiThread(final Runnable runnable) {
+ // We're on the UI thread already, let's just run this
+ if (isOnUiThread()) {
+ runnable.run();
+ return;
+ }
+
+ postToUiThread(runnable);
+ }
+
+ public static void postToUiThread(final Runnable runnable) {
+ sUiHandler.post(runnable);
+ }
+
+ public static void postToUiThreadDelayed(final Runnable runnable, final long delayMillis) {
+ sUiHandler.postDelayed(runnable, delayMillis);
+ }
+
+ public static void removeUiThreadCallbacks(final Runnable runnable) {
+ sUiHandler.removeCallbacks(runnable);
+ }
+
+ public static Handler getBackgroundHandler() {
+ return GeckoBackgroundThread.getHandler();
+ }
+
+ public static void postToBackgroundThread(final Runnable runnable) {
+ GeckoBackgroundThread.post(runnable);
+ }
+
+ public static void assertOnUiThread(final AssertBehavior assertBehavior) {
+ assertOnThread(getUiThread(), assertBehavior);
+ }
+
+ public static void assertOnUiThread() {
+ assertOnThread(getUiThread(), AssertBehavior.THROW);
+ }
+
+ @RobocopTarget
+ public static void assertOnGeckoThread() {
+ assertOnThread(sGeckoThread, AssertBehavior.THROW);
+ }
+
+ public static void assertOnThread(final Thread expectedThread, final AssertBehavior behavior) {
+ assertOnThreadComparison(expectedThread, behavior, true);
+ }
+
+ private static void assertOnThreadComparison(
+ final Thread expectedThread, final AssertBehavior behavior, final boolean expected) {
+ final Thread currentThread = Thread.currentThread();
+ final long currentThreadId = currentThread.getId();
+ final long expectedThreadId = expectedThread.getId();
+
+ if ((currentThreadId == expectedThreadId) == expected) {
+ return;
+ }
+
+ final String message;
+ if (expected) {
+ message =
+ "Expected thread "
+ + expectedThreadId
+ + " (\""
+ + expectedThread.getName()
+ + "\"), but running on thread "
+ + currentThreadId
+ + " (\""
+ + currentThread.getName()
+ + "\")";
+ } else {
+ message =
+ "Expected anything but "
+ + expectedThreadId
+ + " (\""
+ + expectedThread.getName()
+ + "\"), but running there.";
+ }
+
+ final IllegalThreadStateException e = new IllegalThreadStateException(message);
+
+ switch (behavior) {
+ case THROW:
+ throw e;
+ default:
+ Log.e(LOGTAG, "Method called on wrong thread!", e);
+ }
+ }
+
+ public static boolean isOnUiThread() {
+ return isOnThread(getUiThread());
+ }
+
+ @RobocopTarget
+ public static boolean isOnThread(final Thread thread) {
+ return (Thread.currentThread().getId() == thread.getId());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja
new file mode 100644
index 0000000000..f704bbc775
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 2; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+public final class XPCOMError {
+ /** Check if the error code corresponds to a failure */
+ public static boolean failed(long err) {
+ return (err & 0x80000000L) != 0;
+ }
+
+ /** Check if the error code corresponds to a failure */
+ public static boolean succeeded(long err) {
+ return !failed(err);
+ }
+
+ /** Extract the error code part of the error message */
+ public static int getErrorCode(long err) {
+ return (int)(err & 0xffffL);
+ }
+
+ /** Extract the error module part of the error message */
+ public static int getErrorModule(long err) {
+ return (int)(((err >> 16) - NS_ERROR_MODULE_BASE_OFFSET) & 0x1fffL);
+ }
+
+ public static final int NS_ERROR_MODULE_BASE_OFFSET = {{ MODULE_BASE_OFFSET }};
+
+{% for mod, val in modules %}
+ public static final int NS_ERROR_MODULE_{{ mod }} = {{ val }};
+{% endfor %}
+
+{% for error, val in errors %}
+ public static final long {{ error }} = 0x{{ "%X" % val }}L;
+{% endfor %}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java
new file mode 100644
index 0000000000..31eac71a66
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java
@@ -0,0 +1,170 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.gecko.util;
+
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+/**
+ * Wrapper for nsIEventTarget, enabling seamless dispatch of java runnables to Gecko event queues.
+ */
+@WrapForJNI
+public final class XPCOMEventTarget extends JNIObject implements IXPCOMEventTarget {
+ @Override
+ public void execute(final Runnable runnable) {
+ dispatchNative(new JNIRunnable(runnable));
+ }
+
+ public static synchronized IXPCOMEventTarget mainThread() {
+ if (mMainThread == null) {
+ mMainThread = new AsyncProxy("main");
+ }
+ return mMainThread;
+ }
+
+ private static IXPCOMEventTarget mMainThread = null;
+
+ public static synchronized IXPCOMEventTarget launcherThread() {
+ if (mLauncherThread == null) {
+ mLauncherThread = new AsyncProxy("launcher");
+ }
+ return mLauncherThread;
+ }
+
+ private static IXPCOMEventTarget mLauncherThread = null;
+
+ /**
+ * Runs the provided runnable on the launcher thread. If this method is called from the launcher
+ * thread itself, the runnable will be executed immediately and synchronously.
+ */
+ public static void runOnLauncherThread(@NonNull final Runnable runnable) {
+ final IXPCOMEventTarget launcherThread = launcherThread();
+ if (launcherThread.isOnCurrentThread()) {
+ // We're already on the launcher thread, just execute the runnable
+ runnable.run();
+ return;
+ }
+
+ launcherThread.execute(runnable);
+ }
+
+ public static void assertOnLauncherThread() {
+ if (BuildConfig.DEBUG_BUILD && !launcherThread().isOnCurrentThread()) {
+ throw new AssertionError("Expected to be running on XPCOM launcher thread");
+ }
+ }
+
+ public static void assertNotOnLauncherThread() {
+ if (BuildConfig.DEBUG_BUILD && launcherThread().isOnCurrentThread()) {
+ throw new AssertionError("Expected to not be running on XPCOM launcher thread");
+ }
+ }
+
+ private static synchronized IXPCOMEventTarget getTarget(final String name) {
+ if (name.equals("launcher")) {
+ return mLauncherThread;
+ } else if (name.equals("main")) {
+ return mMainThread;
+ } else {
+ throw new RuntimeException("Attempt to assign to unknown thread named " + name);
+ }
+ }
+
+ @WrapForJNI
+ private static synchronized void setTarget(final String name, final XPCOMEventTarget target) {
+ if (name.equals("main")) {
+ mMainThread = target;
+ } else if (name.equals("launcher")) {
+ mLauncherThread = target;
+ } else {
+ throw new RuntimeException("Attempt to assign to unknown thread named " + name);
+ }
+
+ // Ensure that we see the right name in the Java debugger. We don't do this for mMainThread
+ // because its name was already set (in this context, "main" is the GeckoThread).
+ if (mMainThread != target) {
+ target.execute(
+ () -> {
+ Thread.currentThread().setName(name);
+ });
+ }
+ }
+
+ @Override
+ public native boolean isOnCurrentThread();
+
+ private native void dispatchNative(final JNIRunnable runnable);
+
+ @WrapForJNI
+ private static synchronized void resolveAndDispatch(final String name, final Runnable runnable) {
+ getTarget(name).execute(runnable);
+ }
+
+ private static native void resolveAndDispatchNative(final String name, final Runnable runnable);
+
+ @Override
+ protected native void disposeNative();
+
+ @WrapForJNI
+ private static final class JNIRunnable {
+ JNIRunnable(final Runnable inner) {
+ mInner = inner;
+ }
+
+ @WrapForJNI
+ void run() {
+ mInner.run();
+ }
+
+ private Runnable mInner;
+ }
+
+ private static final class AsyncProxy implements IXPCOMEventTarget {
+ private String mTargetName;
+
+ public AsyncProxy(final String targetName) {
+ mTargetName = targetName;
+ }
+
+ @Override
+ public void execute(final Runnable runnable) {
+ final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName);
+
+ if (target != null && target instanceof XPCOMEventTarget) {
+ target.execute(runnable);
+ return;
+ }
+
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.JNI_READY,
+ XPCOMEventTarget.class,
+ "resolveAndDispatchNative",
+ String.class,
+ mTargetName,
+ Runnable.class,
+ runnable);
+ }
+
+ @Override
+ public boolean isOnCurrentThread() {
+ final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName);
+
+ // If target is not yet a XPCOMEventTarget then JNI is not
+ // initialized yet. If JNI is not initialized yet, then we cannot
+ // possibly be running on a target with an XPCOMEventTarget.
+ if (target == null || !(target instanceof XPCOMEventTarget)) {
+ return false;
+ }
+
+ // Otherwise we have a real XPCOMEventTarget, so we can delegate
+ // this call to it.
+ return target.isOnCurrentThread();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
new file mode 100644
index 0000000000..f8342cbfa7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
@@ -0,0 +1,16 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/** This represents a decision to allow or deny a request. */
+@AnyThread
+public enum AllowOrDeny {
+ ALLOW,
+ DENY;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
new file mode 100644
index 0000000000..e8a004df17
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
@@ -0,0 +1,1445 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion.
+ *
+ * <p>The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates.
+ *
+ * <p>The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and
+ * storing of login entries.
+ *
+ * <p>The user-prompt delegates propagate decisions to the app that could require user choice, e.g.,
+ * saving or updating of login entries or the selection of a login entry out of multiple options.
+ *
+ * <p>Throughout the documentation, we will refer to the filling out of input forms using two terms:
+ * 1. Autofill: automatic filling without user interaction. 2. Autocomplete: semi-automatic filling
+ * that requires user prompting for the selection.
+ *
+ * <h2>Examples</h2>
+ *
+ * <h3>Autocomplete/Fetch API</h3>
+ *
+ * <p>GeckoView loads <code>https://example.com</code> which contains (for the purpose of this
+ * example) elements resembling a login form, e.g.,
+ *
+ * <pre><code>
+ * &lt;form&gt;
+ * &lt;input type=&quot;text&quot; placeholder=&quot;username&quot;&gt;
+ * &lt;input type=&quot;password&quot; placeholder=&quot;password&quot;&gt;
+ * &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
+ * &lt;/form&gt;
+ * </code></pre>
+ *
+ * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to fetch logins for the
+ * given domain.
+ *
+ * <p>Based on the provided login entries, GeckoView will attempt to autofill the login input
+ * fields, if there is only one suitable login entry option.
+ *
+ * <p>In the case of multiple valid login entry options, GeckoView dispatches a <code>
+ * GeckoSession.PromptDelegate.onLoginSelect</code> request, which allows for user-choice
+ * delegation.
+ *
+ * <p>Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login
+ * input fields.
+ *
+ * <h3>Update API</h3>
+ *
+ * <p>When the user submits some login input fields, GeckoView dispatches another <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to check whether the
+ * submitted login exists or whether it's a new or updated login entry.
+ *
+ * <p>If the submitted login is already contained as-is in the collection returned by <code>
+ * onLoginFetch</code>, then GeckoView dispatches <code>StorageDelegate.onLoginUsed</code> with the
+ * submitted login entry.
+ *
+ * <p>If the submitted login is a new or updated entry, GeckoView dispatches a sequence of requests
+ * to save/update the login entry, see the Save API example.
+ *
+ * <h3>Save API</h3>
+ *
+ * <p>The user enters new or updated (password) login credentials in some login input fields and
+ * submits explicitely (submit action) or by navigation. GeckoView identifies the entered
+ * credentials and dispatches a <code>GeckoSession.PromptDelegate.onLoginSave(session, request)
+ * </code> with the provided credentials.
+ *
+ * <p>The app may dismiss the prompt request via <code>
+ * return GeckoResult.fromValue(prompt.dismiss())</code> which terminates this saving request, or
+ * confirm it via <code>return GeckoResult.fromValue(prompt.confirm(login))</code> where <code>login
+ * </code> either holds the credentials originally provided by the prompt request (<code>
+ * prompt.logins[0]</code>) or a new or modified login entry.
+ *
+ * <p>The login entry returned in a confirmed save prompt is used to request for saving in the
+ * runtime delegate via <code>StorageDelegate.onLoginSave(login)</code>. If the app has already
+ * stored the entry during the prompt request handling, it may ignore this storage saving request.
+ * <br>
+ *
+ * @see GeckoRuntime#setAutocompleteStorageDelegate <br>
+ * @see GeckoSession#setPromptDelegate <br>
+ * @see GeckoSession.PromptDelegate#onLoginSave <br>
+ * @see GeckoSession.PromptDelegate#onLoginSelect
+ */
+public class Autocomplete {
+ private static final String LOGTAG = "Autocomplete";
+ private static final boolean DEBUG = false;
+
+ protected Autocomplete() {}
+
+ /** Holds credit card information for a specific entry. */
+ public static class CreditCard {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String NUMBER_KEY = "number";
+ private static final String EXP_MONTH_KEY = "expMonth";
+ private static final String EXP_YEAR_KEY = "expYear";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The full name as it appears on the credit card. */
+ public final @NonNull String name;
+
+ /** The credit card number. */
+ public final @NonNull String number;
+
+ /** The expiration month. */
+ public final @NonNull String expirationMonth;
+
+ /** The expiration year. */
+ public final @NonNull String expirationYear;
+
+ // For tests only.
+ @AnyThread
+ protected CreditCard() {
+ guid = null;
+ name = "";
+ number = "";
+ expirationMonth = "";
+ expirationYear = "";
+ }
+
+ @AnyThread
+ /* package */ CreditCard(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ number = bundle.getString(NUMBER_KEY, "");
+ expirationMonth = bundle.getString(EXP_MONTH_KEY, "");
+ expirationYear = bundle.getString(EXP_YEAR_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("CreditCard {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", name=")
+ .append(name)
+ .append(", number=")
+ .append(number)
+ .append(", expirationMonth=")
+ .append(expirationMonth)
+ .append(", expirationYear=")
+ .append(expirationYear)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(7);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(NUMBER_KEY, number);
+ if (expirationMonth != null) {
+ bundle.putString(EXP_MONTH_KEY, expirationMonth);
+ }
+ if (expirationYear != null) {
+ bundle.putString(EXP_YEAR_KEY, expirationYear);
+ }
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(7);
+ }
+
+ /**
+ * Finalize the {@link CreditCard} instance.
+ *
+ * @return The {@link CreditCard} instance.
+ */
+ @AnyThread
+ public @NonNull CreditCard build() {
+ return new CreditCard(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this credit card entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the name for this credit card entry.
+ *
+ * @param name The full name as it appears on the credit card.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the number for this credit card entry.
+ *
+ * @param number The credit card number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder number(final @Nullable String number) {
+ mBundle.putString(NUMBER_KEY, number);
+ return this;
+ }
+
+ /**
+ * Set the expiration month for this credit card entry.
+ *
+ * @param expMonth The expiration month string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationMonth(final @Nullable String expMonth) {
+ mBundle.putString(EXP_MONTH_KEY, expMonth);
+ return this;
+ }
+
+ /**
+ * Set the expiration year for this credit card entry.
+ *
+ * @param expYear The expiration year string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationYear(final @Nullable String expYear) {
+ mBundle.putString(EXP_YEAR_KEY, expYear);
+ return this;
+ }
+ }
+ }
+
+ /** Holds address information for a specific entry. */
+ public static class Address {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String GIVEN_NAME_KEY = "givenName";
+ private static final String ADDITIONAL_NAME_KEY = "additionalName";
+ private static final String FAMILY_NAME_KEY = "familyName";
+ private static final String ORGANIZATION_KEY = "organization";
+ private static final String STREET_ADDRESS_KEY = "streetAddress";
+ private static final String ADDRESS_LEVEL1_KEY = "addressLevel1";
+ private static final String ADDRESS_LEVEL2_KEY = "addressLevel2";
+ private static final String ADDRESS_LEVEL3_KEY = "addressLevel3";
+ private static final String POSTAL_CODE_KEY = "postalCode";
+ private static final String COUNTRY_KEY = "country";
+ private static final String TEL_KEY = "tel";
+ private static final String EMAIL_KEY = "email";
+ private static final byte bundleCapacity = 14;
+
+ /** The unique identifier for this address entry. */
+ public final @Nullable String guid;
+
+ /** The full name. */
+ public final @NonNull String name;
+
+ /** The given (first) name. */
+ public final @NonNull String givenName;
+
+ /** An additional name, if available. */
+ public final @NonNull String additionalName;
+
+ /** The family name. */
+ public final @NonNull String familyName;
+
+ /** The name of the company, if applicable. */
+ public final @NonNull String organization;
+
+ /** The (multiline) street address. */
+ public final @NonNull String streetAddress;
+
+ /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel1;
+
+ /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel2;
+
+ /**
+ * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided.
+ */
+ public final @NonNull String addressLevel3;
+
+ /** The postal code. */
+ public final @NonNull String postalCode;
+
+ /** The country string in ISO 3166. */
+ public final @NonNull String country;
+
+ /** The telephone number string. */
+ public final @NonNull String tel;
+
+ /** The email address. */
+ public final @NonNull String email;
+
+ // For tests only.
+ @AnyThread
+ protected Address() {
+ guid = null;
+ name = "";
+ givenName = "";
+ additionalName = "";
+ familyName = "";
+ organization = "";
+ streetAddress = "";
+ addressLevel1 = "";
+ addressLevel2 = "";
+ addressLevel3 = "";
+ postalCode = "";
+ country = "";
+ tel = "";
+ email = "";
+ }
+
+ @AnyThread
+ /* package */ Address(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ givenName = bundle.getString(GIVEN_NAME_KEY, "");
+ additionalName = bundle.getString(ADDITIONAL_NAME_KEY, "");
+ familyName = bundle.getString(FAMILY_NAME_KEY, "");
+ organization = bundle.getString(ORGANIZATION_KEY, "");
+ streetAddress = bundle.getString(STREET_ADDRESS_KEY, "");
+ addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, "");
+ addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, "");
+ addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, "");
+ postalCode = bundle.getString(POSTAL_CODE_KEY, "");
+ country = bundle.getString(COUNTRY_KEY, "");
+ tel = bundle.getString(TEL_KEY, "");
+ email = bundle.getString(EMAIL_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Address {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", givenName=")
+ .append(givenName)
+ .append(", additionalName=")
+ .append(additionalName)
+ .append(", familyName=")
+ .append(familyName)
+ .append(", organization=")
+ .append(organization)
+ .append(", streetAddress=")
+ .append(streetAddress)
+ .append(", addressLevel1=")
+ .append(addressLevel1)
+ .append(", addressLevel2=")
+ .append(addressLevel2)
+ .append(", addressLevel3=")
+ .append(addressLevel3)
+ .append(", postalCode=")
+ .append(postalCode)
+ .append(", country=")
+ .append(country)
+ .append(", tel=")
+ .append(tel)
+ .append(", email=")
+ .append(email)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(bundleCapacity);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(GIVEN_NAME_KEY, givenName);
+ bundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ bundle.putString(FAMILY_NAME_KEY, familyName);
+ bundle.putString(ORGANIZATION_KEY, organization);
+ bundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ bundle.putString(POSTAL_CODE_KEY, postalCode);
+ bundle.putString(COUNTRY_KEY, country);
+ bundle.putString(TEL_KEY, tel);
+ bundle.putString(EMAIL_KEY, email);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(bundleCapacity);
+ }
+
+ /**
+ * Finalize the {@link Address} instance.
+ *
+ * @return The {@link Address} instance.
+ */
+ @AnyThread
+ public @NonNull Address build() {
+ return new Address(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this address entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the full name for this address entry.
+ *
+ * @param name The full name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the given name for this address entry.
+ *
+ * @param givenName The given name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder givenName(final @Nullable String givenName) {
+ mBundle.putString(GIVEN_NAME_KEY, givenName);
+ return this;
+ }
+
+ /**
+ * Set the additional name for this address entry.
+ *
+ * @param additionalName The additional name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder additionalName(final @Nullable String additionalName) {
+ mBundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ return this;
+ }
+
+ /**
+ * Set the family name for this address entry.
+ *
+ * @param familyName The family name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder familyName(final @Nullable String familyName) {
+ mBundle.putString(FAMILY_NAME_KEY, familyName);
+ return this;
+ }
+
+ /**
+ * Set the company name for this address entry.
+ *
+ * @param organization The company name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder organization(final @Nullable String organization) {
+ mBundle.putString(ORGANIZATION_KEY, organization);
+ return this;
+ }
+
+ /**
+ * Set the street address for this address entry.
+ *
+ * @param streetAddress The street address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder streetAddress(final @Nullable String streetAddress) {
+ mBundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ return this;
+ }
+
+ /**
+ * Set the level 1 address for this address entry.
+ *
+ * @param addressLevel1 The level 1 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) {
+ mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ return this;
+ }
+
+ /**
+ * Set the level 2 address for this address entry.
+ *
+ * @param addressLevel2 The level 2 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) {
+ mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ return this;
+ }
+
+ /**
+ * Set the level 3 address for this address entry.
+ *
+ * @param addressLevel3 The level 3 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) {
+ mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ return this;
+ }
+
+ /**
+ * Set the postal code for this address entry.
+ *
+ * @param postalCode The postal code string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder postalCode(final @Nullable String postalCode) {
+ mBundle.putString(POSTAL_CODE_KEY, postalCode);
+ return this;
+ }
+
+ /**
+ * Set the country code for this address entry.
+ *
+ * @param country The country string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder country(final @Nullable String country) {
+ mBundle.putString(COUNTRY_KEY, country);
+ return this;
+ }
+
+ /**
+ * Set the telephone number for this address entry.
+ *
+ * @param tel The telephone number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder tel(final @Nullable String tel) {
+ mBundle.putString(TEL_KEY, tel);
+ return this;
+ }
+
+ /**
+ * Set the email address for this address entry.
+ *
+ * @param email The email address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder email(final @Nullable String email) {
+ mBundle.putString(EMAIL_KEY, email);
+ return this;
+ }
+ }
+ }
+
+ /** Holds login information for a specific entry. */
+ public static class LoginEntry {
+ private static final String GUID_KEY = "guid";
+ private static final String ORIGIN_KEY = "origin";
+ private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin";
+ private static final String HTTP_REALM_KEY = "httpRealm";
+ private static final String USERNAME_KEY = "username";
+ private static final String PASSWORD_KEY = "password";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The origin this login entry applies to. */
+ public final @NonNull String origin;
+
+ /**
+ * The origin this login entry was submitted to. This only applies to form-based login entries.
+ * It's derived from the action attribute set on the form element.
+ */
+ public final @Nullable String formActionOrigin;
+
+ /**
+ * The HTTP realm this login entry was requested for. This only applies to non-form-based login
+ * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see
+ * RFC2617 for details.
+ */
+ public final @Nullable String httpRealm;
+
+ /** The username for this login entry. */
+ public final @NonNull String username;
+
+ /** The password for this login entry. */
+ public final @NonNull String password;
+
+ // For tests only.
+ @AnyThread
+ protected LoginEntry() {
+ guid = null;
+ origin = "";
+ formActionOrigin = null;
+ httpRealm = null;
+ username = "";
+ password = "";
+ }
+
+ @AnyThread
+ /* package */ LoginEntry(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ origin = bundle.getString(ORIGIN_KEY, "");
+ formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY);
+ httpRealm = bundle.getString(HTTP_REALM_KEY);
+ username = bundle.getString(USERNAME_KEY, "");
+ password = bundle.getString(PASSWORD_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("LoginEntry {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", origin=")
+ .append(origin)
+ .append(", formActionOrigin=")
+ .append(formActionOrigin)
+ .append(", httpRealm=")
+ .append(httpRealm)
+ .append(", username=")
+ .append(username)
+ .append(", password=")
+ .append(password)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(6);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(ORIGIN_KEY, origin);
+ bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ bundle.putString(HTTP_REALM_KEY, httpRealm);
+ bundle.putString(USERNAME_KEY, username);
+ bundle.putString(PASSWORD_KEY, password);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(6);
+ }
+
+ /**
+ * Finalize the {@link LoginEntry} instance.
+ *
+ * @return The {@link LoginEntry} instance.
+ */
+ @AnyThread
+ public @NonNull LoginEntry build() {
+ return new LoginEntry(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this login entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry applies to.
+ *
+ * @param origin The origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder origin(final @NonNull String origin) {
+ mBundle.putString(ORIGIN_KEY, origin);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry was submitted to.
+ *
+ * @param formActionOrigin The form action origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) {
+ mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ return this;
+ }
+
+ /**
+ * Set the HTTP realm this login entry was requested for.
+ *
+ * @param httpRealm The HTTP realm string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder httpRealm(final @Nullable String httpRealm) {
+ mBundle.putString(HTTP_REALM_KEY, httpRealm);
+ return this;
+ }
+
+ /**
+ * Set the username for this login entry.
+ *
+ * @param username The username string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder username(final @NonNull String username) {
+ mBundle.putString(USERNAME_KEY, username);
+ return this;
+ }
+
+ /**
+ * Set the password for this login entry.
+ *
+ * @param password The password string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder password(final @NonNull String password) {
+ mBundle.putString(PASSWORD_KEY, password);
+ return this;
+ }
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {UsedField.PASSWORD})
+ public @interface LSUsedField {}
+
+ // Sync with UsedField in GeckoViewAutocomplete.jsm.
+ /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */
+ public static class UsedField {
+ /** The password field of a login entry. */
+ public static final int PASSWORD = 1;
+
+ protected UsedField() {}
+ }
+
+ /**
+ * Implement this interface to handle runtime login storage requests. Login storage events include
+ * login entry requests for autofill and autocompletion of login input fields. This delegate is
+ * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}.
+ */
+ public interface StorageDelegate {
+ /**
+ * Request login entries for a given domain. While processing the web document, we have
+ * identified elements resembling login input fields suitable for autofill. We will attempt to
+ * match the provided login information to the identified input fields.
+ *
+ * @param domain The domain string for the requested logins.
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins for the given domain.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch(@NonNull final String domain) {
+ return null;
+ }
+
+ /**
+ * Request login entries for all domains.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch() {
+ return null;
+ }
+
+ /**
+ * Request credit card entries. While processing the web document, we have identified elements
+ * resembling credit card input fields suitable for autofill. We will attempt to match the
+ * provided credit card information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing
+ * the existing credit cards.
+ */
+ @UiThread
+ default @Nullable GeckoResult<CreditCard[]> onCreditCardFetch() {
+ return null;
+ }
+
+ /**
+ * Request address entries. While processing the web document, we have identified elements
+ * resembling address input fields suitable for autofill. We will attempt to match the provided
+ * address information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the
+ * existing addresses.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Address[]> onAddressFetch() {
+ return null;
+ }
+
+ /**
+ * Request saving or updating of the given login entry. This is triggered by confirming a {@link
+ * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request.
+ *
+ * @param login The {@link LoginEntry} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onLoginSave(@NonNull final LoginEntry login) {}
+
+ /**
+ * Request saving or updating of the given credit card entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request.
+ *
+ * @param creditCard The {@link CreditCard} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onCreditCardSave(@NonNull CreditCard creditCard) {}
+
+ /**
+ * Request saving or updating of the given address entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request.
+ *
+ * @param address The {@link Address} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onAddressSave(@NonNull Address address) {}
+
+ /**
+ * Notify that the given login was used to autofill login input fields. This is triggered by
+ * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}.
+ *
+ * @param login The {@link LoginEntry} that was used for the autofilling.
+ * @param usedFields The login entry fields used for autofilling. A combination of {@link
+ * UsedField}.
+ */
+ @UiThread
+ default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {}
+ }
+
+ /**
+ * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and
+ * {@link Autocomplete.SelectOption}.
+ */
+ public abstract static class Option<T> {
+ /* package */ static final String VALUE_KEY = "value";
+ /* package */ static final String HINT_KEY = "hint";
+
+ public final @NonNull T value;
+ public final int hint;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Option(final @NonNull T value, final int hint) {
+ this.value = value;
+ this.hint = hint;
+ }
+
+ @AnyThread
+ /* package */ abstract @NonNull GeckoBundle toBundle();
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSaveOption}. */
+ public abstract static class SaveOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE})
+ public @interface SaveOptionHint {}
+
+ /** Hint types for login saving requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /** Auto-generated password. Notify but do not prompt the user for saving. */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Potentially non-login data. The form data entered may be not login credentials but other
+ * forms of input like credit card numbers. Note that this could be valid login data in same
+ * cases, e.g., some banks may expect credit card numbers in the username field.
+ */
+ public static final int LOW_CONFIDENCE = 1 << 1;
+
+ protected Hint() {}
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */
+ public abstract static class SelectOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Hint.NONE,
+ Hint.GENERATED,
+ Hint.INSECURE_FORM,
+ Hint.DUPLICATE_USERNAME,
+ Hint.MATCHING_ORIGIN
+ })
+ public @interface SelectOptionHint {}
+
+ /** Hint types for selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Auto-generated password. A new password-only login entry containing a secure generated
+ * password.
+ */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+
+ /**
+ * The username is shared with another login entry. There are multiple login entries in the
+ * options that share the same username. You may have to disambiguate the login entry, e.g.,
+ * using the last date of modification and its origin.
+ */
+ public static final int DUPLICATE_USERNAME = 1 << 2;
+
+ /**
+ * The login entry's origin matches the login form origin. The login was saved from the same
+ * origin it is being requested for, rather than for a subdomain.
+ */
+ public static final int MATCHING_ORIGIN = 1 << 3;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("SelectOption {");
+ builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds information required to process login saving requests. */
+ public static class LoginSaveOption extends SaveOption<LoginEntry> {
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ */
+ public LoginSaveOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address saving requests. */
+ public static class AddressSaveOption extends SaveOption<Address> {
+ /**
+ * Construct a address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct an address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ */
+ public AddressSaveOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card saving requests. */
+ public static class CreditCardSaveOption extends SaveOption<CreditCard> {
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSaveOption(
+ final @NonNull CreditCard value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ */
+ public CreditCardSaveOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process login selection requests. */
+ public static class LoginSelectOption extends SelectOption<LoginEntry> {
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSelectOption(
+ final @NonNull LoginEntry value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ */
+ public LoginSelectOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final LoginEntry value = new LoginEntry(bundle.getBundle("value"));
+
+ return new LoginSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card selection requests. */
+ public static class CreditCardSelectOption extends SelectOption<CreditCard> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface CreditCardSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSelectOption(
+ final @NonNull CreditCard value, final @CreditCardSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link CreditCard} credit card entry selection option.
+ */
+ public CreditCardSelectOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull CreditCardSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final CreditCard value = new CreditCard(bundle.getBundle("value"));
+
+ return new CreditCardSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address selection requests. */
+ public static class AddressSelectOption extends SelectOption<Address> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface AddressSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSelectOption(
+ final @NonNull Address value, final @AddressSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a address select option.
+ *
+ * @param value The {@link Address} address entry selection option.
+ */
+ public AddressSelectOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull AddressSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final Address value = new Address(bundle.getBundle("value"));
+
+ return new AddressSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /* package */ static final class StorageProxy implements BundleEventListener {
+ private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login";
+ private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard";
+ private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address";
+ private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login";
+ private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard";
+ private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address";
+ private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login";
+
+ private @Nullable StorageDelegate mDelegate;
+
+ public StorageProxy() {}
+
+ private void registerListener() {
+ EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null);
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ private void unregisterListener() {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ public synchronized void setDelegate(final @Nullable StorageDelegate delegate) {
+ if (mDelegate == delegate) {
+ return;
+ }
+ if (mDelegate != null) {
+ unregisterListener();
+ }
+
+ mDelegate = delegate;
+
+ if (mDelegate != null) {
+ registerListener();
+ }
+ }
+
+ public synchronized @Nullable StorageDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ @Override // BundleEventListener
+ public synchronized void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (mDelegate == null) {
+ if (callback != null) {
+ callback.sendError("No StorageDelegate attached");
+ }
+ return;
+ }
+
+ if (FETCH_LOGIN_EVENT.equals(event)) {
+ final String domain = message.getString("domain");
+ final GeckoResult<Autocomplete.LoginEntry[]> result =
+ domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ logins -> {
+ if (logins == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] loginBundles = new GeckoBundle[logins.length];
+ for (int i = 0; i < logins.length; ++i) {
+ loginBundles[i] = logins[i].toBundle();
+ }
+
+ return loginBundles;
+ }));
+ } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.CreditCard[]> result = mDelegate.onCreditCardFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ creditCards -> {
+ if (creditCards == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length];
+ for (int i = 0; i < creditCards.length; ++i) {
+ creditCardBundles[i] = creditCards[i].toBundle();
+ }
+
+ return creditCardBundles;
+ }));
+ } else if (FETCH_ADDRESS_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.Address[]> result = mDelegate.onAddressFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ addresses -> {
+ if (addresses == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length];
+ for (int i = 0; i < addresses.length; ++i) {
+ addressBundles[i] = addresses[i].toBundle();
+ }
+
+ return addressBundles;
+ }));
+ } else if (SAVE_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+
+ mDelegate.onLoginSave(login);
+ } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoBundle creditCardBundle = message.getBundle("creditCard");
+ final CreditCard creditCard = new CreditCard(creditCardBundle);
+
+ mDelegate.onCreditCardSave(creditCard);
+ } else if (SAVE_ADDRESS_EVENT.equals(event)) {
+ final GeckoBundle addressBundle = message.getBundle("address");
+ final Address address = new Address(addressBundle);
+
+ mDelegate.onAddressSave(address);
+ } else if (USED_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+ final int fields = message.getInt("usedFields");
+
+ mDelegate.onLoginUsed(login, fields);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
new file mode 100644
index 0000000000..c1625693cf
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
@@ -0,0 +1,1229 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillValue;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.collection.ArrayMap;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class Autofill {
+ private static final boolean DEBUG = false;
+
+ public @interface AutofillNotify {}
+
+ public static final class Hint {
+ private Hint() {}
+
+ /** Hint indicating that no special handling is required. */
+ public static final int NONE = -1;
+
+ /** Hint indicating that a node represents an email address. */
+ public static final int EMAIL_ADDRESS = 0;
+
+ /** Hint indicating that a node represents a password. */
+ public static final int PASSWORD = 1;
+
+ /** Hint indicating that a node represents an URI. */
+ public static final int URI = 2;
+
+ /** Hint indicating that a node represents a username. */
+ public static final int USERNAME = 3;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(final @AutofillHint int hint) {
+ final int idx = hint + 1;
+ final String[] map = new String[] {"NONE", "EMAIL", "PASSWORD", "URI", "USERNAME"};
+
+ if (idx < 0 || idx >= map.length) {
+ return null;
+ }
+ return map[idx];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Hint.NONE, Hint.EMAIL_ADDRESS, Hint.PASSWORD, Hint.URI, Hint.USERNAME})
+ public @interface AutofillHint {}
+
+ public static final class InputType {
+ private InputType() {}
+
+ /** Indicates that a node is not a known input type. */
+ public static final int NONE = -1;
+
+ /** Indicates that a node is a text input type. Example: {@code <input type="text">} */
+ public static final int TEXT = 0;
+
+ /** Indicates that a node is a number input type. Example: {@code <input type="number">} */
+ public static final int NUMBER = 1;
+
+ /** Indicates that a node is a phone input type. Example: {@code <input type="tel">} */
+ public static final int PHONE = 2;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(final @AutofillInputType int type) {
+ final int idx = type + 1;
+ final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"};
+
+ if (idx < 0 || idx >= map.length) {
+ return null;
+ }
+ return map[idx];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE})
+ public @interface AutofillInputType {}
+
+ /** Represents autofill data associated to a {@link Node}. */
+ public static class NodeData {
+ /** Autofill id for this node. */
+ final int id;
+
+ String value;
+ Node node;
+ EventCallback callback;
+
+ NodeData(final int id, final Node node) {
+ this.id = id;
+ this.node = node;
+ }
+
+ /**
+ * Gets the value for this node.
+ *
+ * @return a String representing the value for this node.
+ */
+ @AnyThread
+ public @Nullable String getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the autofill id for this node.
+ *
+ * @return an int representing the id for this node.
+ */
+ @AnyThread
+ public int getId() {
+ return id;
+ }
+ }
+
+ /** Represents an autofill session. A session holds the autofill nodes and state of a page. */
+ public static final class Session {
+ private static final String LOGTAG = "AutofillSession";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private Node mRoot;
+ private HashMap<String, NodeData> mUuidToNodeData;
+ private SparseArray<Node> mIdToNode;
+ private int mCurrentIndex = 0;
+ private String mId = null;
+
+ // We can't store the Node directly because it might be updated by subsequent NodeAdd calls.
+ private String mFocusedUuid = null;
+
+ /* package */ Session(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ // Dummy session until a real one gets created
+ clear(UUID.randomUUID().toString());
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Rect getDefaultDimensions() {
+ final Rect rect = new Rect();
+ mGeckoSession.getSurfaceBounds(rect);
+ return rect;
+ }
+
+ /* package */ void clear(final String newSessionId) {
+ mId = newSessionId;
+ mFocusedUuid = null;
+ mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId);
+ mIdToNode = new SparseArray<>();
+ mUuidToNodeData = new HashMap<>();
+ addNode(mRoot);
+ }
+
+ /* package */ boolean isEmpty() {
+ // Root data is always there
+ return mUuidToNodeData.size() == 1;
+ }
+
+ /**
+ * Get data for the given node.
+ *
+ * @param node the {@link Node} get data for.
+ * @return the {@link NodeData} for the given node.
+ */
+ @UiThread
+ public @NonNull NodeData dataFor(final @NonNull Node node) {
+ final NodeData data = mUuidToNodeData.get(node.getUuid());
+ Objects.requireNonNull(data);
+ return data;
+ }
+
+ /**
+ * Perform auto-fill using the specified values.
+ *
+ * @param values Map of auto-fill IDs to values.
+ */
+ @UiThread
+ public void autofill(@NonNull final SparseArray<CharSequence> values) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isEmpty()) {
+ return;
+ }
+
+ final HashMap<Node, GeckoBundle> valueBundles = new HashMap<>();
+
+ for (int i = 0; i < values.size(); i++) {
+ final int id = values.keyAt(i);
+ final Node node = getNode(id);
+ if (node == null) {
+ Log.w(LOGTAG, "Could not find node id=" + id);
+ continue;
+ }
+
+ final CharSequence value = values.valueAt(i);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value);
+ }
+
+ if (node == getRoot()) {
+ // We cannot autofill the session root as it does not correspond to a
+ // real element on the page.
+ Log.w(LOGTAG, "Ignoring autofill on session root.");
+ continue;
+ }
+
+ final Node root = node.getRoot();
+ if (!valueBundles.containsKey(root)) {
+ valueBundles.put(root, new GeckoBundle());
+ }
+ valueBundles.get(root).putString(node.getUuid(), String.valueOf(value));
+ }
+
+ for (final Node root : valueBundles.keySet()) {
+ final NodeData data = dataFor(root);
+ Objects.requireNonNull(data);
+ final EventCallback callback = data.callback;
+ callback.sendSuccess(valueBundles.get(root));
+ }
+ }
+
+ /* package */ void addRoot(@NonNull final Node node, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addRoot: " + node);
+ }
+
+ mRoot.addChild(node);
+ addNode(node);
+ dataFor(node).callback = callback;
+ }
+
+ /* package */ void addNode(@NonNull final Node node) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addNode: " + node);
+ }
+
+ NodeData data = mUuidToNodeData.get(node.getUuid());
+ if (data == null) {
+ final int nodeId = mCurrentIndex++;
+ data = new NodeData(nodeId, node);
+ mUuidToNodeData.put(node.getUuid(), data);
+ } else {
+ data.node = node;
+ }
+
+ mIdToNode.put(data.id, node);
+ for (final Node child : node.getChildren()) {
+ addNode(child);
+ }
+ }
+
+ /**
+ * Returns true if the node is currently visible in the page.
+ *
+ * @param node the {@link Node} instance
+ * @return true if the node is visible, false otherwise.
+ */
+ @UiThread
+ public boolean isVisible(final @NonNull Node node) {
+ if (!Objects.equals(node.mSessionId, mId)) {
+ Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId);
+ return false;
+ }
+ if (mRoot == node) {
+ // The root is always visible
+ return true;
+ }
+ final Node focused = getFocused();
+ if (focused == null) {
+ return false;
+ }
+ final Node focusedRoot = focused.getRoot();
+ final Node focusedParent = focused.getParent();
+
+ final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null;
+ final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null;
+
+ return (focusedParent != null && focusedParent.getUuid().equals(parentUuid))
+ || (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid));
+ }
+
+ /**
+ * Returns the currently focused node.
+ *
+ * @return a reference to the {@link Node} that is currently focused or null if no node is
+ * currently focused.
+ */
+ @UiThread
+ public @Nullable Node getFocused() {
+ return getNode(mFocusedUuid);
+ }
+
+ /* package */ void setFocus(final Node node) {
+ mFocusedUuid = node != null ? node.getUuid() : null;
+ }
+
+ /**
+ * Returns the currently focused node data.
+ *
+ * @return a refernce to {@link NodeData} or null if no node is focused.
+ */
+ @UiThread
+ public @Nullable NodeData getFocusedData() {
+ final Node focused = getFocused();
+ return focused != null ? dataFor(focused) : null;
+ }
+
+ /* package */ @Nullable
+ Node getNode(final String uuid) {
+ if (uuid == null) {
+ return null;
+ }
+ final NodeData nodeData = mUuidToNodeData.get(uuid);
+ if (nodeData == null) {
+ return null;
+ }
+ return nodeData.node;
+ }
+
+ /* package */ Node getNode(final int id) {
+ return mIdToNode.get(id);
+ }
+
+ /**
+ * Get the root node of the session tree. Each session is managed in a tree with a virtual root
+ * node for the document.
+ *
+ * @return The root {@link Node} for this session.
+ */
+ @AnyThread
+ public @NonNull Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */ String getId() {
+ return mId;
+ }
+
+ @Override
+ @UiThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Session {");
+ final Node focused = getFocused();
+ builder
+ .append("id=")
+ .append(mId)
+ .append(", focused=")
+ .append(mFocusedUuid)
+ .append(", focusedRoot=")
+ .append(
+ (focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null)
+ .append(", root=")
+ .append(getRoot())
+ .append("}");
+ return builder.toString();
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ @NonNull final View view, @NonNull final ViewStructure structure, final int flags) {
+ ThreadUtils.assertOnUiThread();
+ fillViewStructure(getRoot(), view, structure, flags);
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ final @NonNull Node node,
+ @NonNull final View view,
+ @NonNull final ViewStructure structure,
+ final int flags) {
+ ThreadUtils.assertOnUiThread();
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "fillViewStructure");
+ }
+
+ final NodeData data = dataFor(node);
+ if (data == null) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillId(view.getAutofillId(), data.id);
+ structure.setWebDomain(node.getDomain());
+ structure.setAutofillValue(AutofillValue.forText(data.value));
+ }
+
+ structure.setId(data.id, null, null, null);
+ // This dimensions doesn't seem to used for autofill service.
+ structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height());
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ final ViewStructure.HtmlInfo.Builder htmlBuilder =
+ structure.newHtmlInfoBuilder(node.getTag());
+ for (final String key : node.getAttributes().keySet()) {
+ htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key)));
+ }
+
+ structure.setHtmlInfo(htmlBuilder.build());
+ }
+
+ structure.setChildCount(node.getChildren().size());
+ int childCount = 0;
+
+ for (final Node child : node.getChildren()) {
+ final ViewStructure childStructure = structure.newChild(childCount);
+ fillViewStructure(child, view, childStructure, flags);
+ childCount++;
+ }
+
+ switch (node.getTag()) {
+ case "input":
+ case "textarea":
+ structure.setClassName("android.widget.EditText");
+ structure.setEnabled(node.getEnabled());
+ structure.setFocusable(node.getFocusable());
+ structure.setFocused(node.equals(getFocused()));
+ structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillType(View.AUTOFILL_TYPE_TEXT);
+ }
+ break;
+ default:
+ if (childCount > 0) {
+ structure.setClassName("android.view.ViewGroup");
+ } else {
+ structure.setClassName("android.view.View");
+ }
+ break;
+ }
+
+ if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) {
+ return;
+ }
+ // LastPass will fill password to the field where setAutofillHints
+ // is unset and setInputType is set.
+ switch (node.getHint()) {
+ case Hint.EMAIL_ADDRESS:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
+ break;
+ }
+ case Hint.PASSWORD:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD);
+ break;
+ }
+ case Hint.URI:
+ {
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_URI);
+ break;
+ }
+ case Hint.USERNAME:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
+ break;
+ }
+ case Hint.NONE:
+ {
+ // Nothing to do.
+ break;
+ }
+ }
+
+ switch (node.getInputType()) {
+ case InputType.NUMBER:
+ {
+ structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
+ break;
+ }
+ case InputType.PHONE:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE});
+ structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
+ break;
+ }
+ case InputType.TEXT:
+ case InputType.NONE:
+ // Nothing to do.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Represents an autofill node. A node is an input element and may contain child nodes forming a
+ * tree.
+ */
+ public static final class Node {
+ private final String mUuid;
+ private final Node mRoot;
+ private final Node mParent;
+ private final @NonNull Rect mDimens;
+ private final @NonNull Rect mScreenRect;
+ private final @NonNull Map<String, Node> mChildren;
+ private final @NonNull Map<String, String> mAttributes;
+ private final boolean mEnabled;
+ private final boolean mFocusable;
+ private final @AutofillHint int mHint;
+ private final @AutofillInputType int mInputType;
+ private final @NonNull String mTag;
+ private final @NonNull String mDomain;
+ private final String mSessionId;
+
+ /* package */
+ @NonNull
+ String getUuid() {
+ return mUuid;
+ }
+
+ /* package */
+ @Nullable
+ Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */
+ @Nullable
+ Node getParent() {
+ return mParent;
+ }
+
+ /**
+ * Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their
+ * proper dimensions.
+ *
+ * @return The dimensions of this node.
+ * @deprecated Use {@link #getScreenRect}.
+ */
+ @Deprecated
+ @DeprecationSchedule(id = "autofill-fission", version = 112) // should be package private
+ @AnyThread
+ public @NonNull Rect getDimensions() {
+ return mDimens;
+ }
+
+ /**
+ * Get the dimensions of this node in screen coordinates. This is valid when this node has an
+ * focus.
+ *
+ * @return The dimensions of this node.
+ */
+ @AnyThread
+ public @NonNull Rect getScreenRect() {
+ return mScreenRect;
+ }
+
+ /**
+ * Set the dimensions of this node in screen coordinates.
+ *
+ * @param screenRect The dimensions of this node.
+ */
+ /* package */ void setScreenRect(final @NonNull RectF screenRectF) {
+ screenRectF.roundOut(mScreenRect);
+ }
+
+ /**
+ * Get the child nodes for this node.
+ *
+ * @return The collection of child nodes for this node.
+ */
+ @AnyThread
+ public @NonNull Collection<Node> getChildren() {
+ return mChildren.values();
+ }
+
+ /* package */
+ @NonNull
+ Node addChild(@NonNull final Node child) {
+ mChildren.put(child.getUuid(), child);
+ return this;
+ }
+
+ /**
+ * Get HTML attributes for this node.
+ *
+ * @return The HTML attributes for this node.
+ */
+ @AnyThread
+ public @NonNull Map<String, String> getAttributes() {
+ return mAttributes;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable String getAttribute(@NonNull final String key) {
+ return mAttributes.get(key);
+ }
+
+ /**
+ * Get whether or not this node is enabled.
+ *
+ * @return True if the node is enabled, false otherwise.
+ */
+ @AnyThread
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ /**
+ * Get whether or not this node is focusable.
+ *
+ * @return True if the node is focusable, false otherwise.
+ */
+ @AnyThread
+ public boolean getFocusable() {
+ return mFocusable;
+ }
+
+ /**
+ * Get the hint for the type of data contained in this node.
+ *
+ * @return The input data hint for this node, one of {@link Hint}.
+ */
+ @AnyThread
+ public @AutofillHint int getHint() {
+ return mHint;
+ }
+
+ /**
+ * Get the input type of this node.
+ *
+ * @return The input type of this node, one of {@link InputType}.
+ */
+ @AnyThread
+ public @AutofillInputType int getInputType() {
+ return mInputType;
+ }
+
+ /**
+ * Get the HTML tag of this node.
+ *
+ * @return The HTML tag of this node.
+ */
+ @AnyThread
+ public @NonNull String getTag() {
+ return mTag;
+ }
+
+ /**
+ * Get web domain of this node.
+ *
+ * @return The domain of this node.
+ */
+ @AnyThread
+ public @NonNull String getDomain() {
+ return mDomain;
+ }
+
+ /* package */
+ static Node newDummyRoot(final Rect dimensions, final String sessionId) {
+ return new Node(dimensions, sessionId);
+ }
+
+ /* package */ Node(final Rect dimensions, final String sessionId) {
+ mRoot = null;
+ mParent = null;
+ mUuid = UUID.randomUUID().toString();
+ mDimens = dimensions;
+ mScreenRect = new Rect();
+ mSessionId = sessionId;
+ mAttributes = new ArrayMap<>();
+ mEnabled = false;
+ mFocusable = false;
+ mHint = Hint.NONE;
+ mInputType = InputType.NONE;
+ mTag = "";
+ mDomain = "";
+ mChildren = new HashMap<>();
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Node {");
+ builder
+ .append("uuid=")
+ .append(mUuid)
+ .append(", sessionId=")
+ .append(mSessionId)
+ .append(", parent=")
+ .append(mParent != null ? mParent.getUuid() : null)
+ .append(", root=")
+ .append(mRoot != null ? mRoot.getUuid() : null)
+ .append(", dims=")
+ .append(getDimensions().toShortString())
+ .append(", screenRect=")
+ .append(getScreenRect().toShortString())
+ .append(", children=[");
+
+ for (final Node child : mChildren.values()) {
+ builder.append(child.getUuid()).append(", ");
+ }
+
+ builder
+ .append("]")
+ .append(", attrs=")
+ .append(mAttributes)
+ .append(", enabled=")
+ .append(mEnabled)
+ .append(", focusable=")
+ .append(mFocusable)
+ .append(", hint=")
+ .append(Hint.toString(mHint))
+ .append(", type=")
+ .append(InputType.toString(mInputType))
+ .append(", tag=")
+ .append(mTag)
+ .append(", domain=")
+ .append(mDomain)
+ .append("}");
+
+ return builder.toString();
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) {
+ this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId);
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle,
+ final Node root,
+ final Node parent,
+ final Rect defaultDimensions,
+ final String sessionId) {
+ final GeckoBundle bounds = bundle.getBundle("bounds");
+
+ mSessionId = sessionId;
+ mUuid = bundle.getString("uuid");
+ mDomain = bundle.getString("origin", "");
+ final Rect dimens =
+ new Rect(
+ bounds.getInt("left"),
+ bounds.getInt("top"),
+ bounds.getInt("right"),
+ bounds.getInt("bottom"));
+ if (dimens.isEmpty()) {
+ // Some nodes like <html> will have null-dimensions,
+ // we need to set them to the virtual documents dimensions.
+ mDimens = defaultDimensions;
+ } else {
+ mDimens = dimens;
+ }
+ mScreenRect = new Rect();
+
+ mParent = parent;
+ // If the root is null, then this object is the root itself
+ mRoot = root != null ? root : this;
+
+ final GeckoBundle[] children = bundle.getBundleArray("children");
+ final Map<String, Node> childrenMap = new HashMap<>(children != null ? children.length : 0);
+
+ if (children != null) {
+ for (final GeckoBundle childBundle : children) {
+ final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId);
+ childrenMap.put(child.getUuid(), child);
+ }
+ }
+
+ mChildren = childrenMap;
+
+ mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT);
+
+ final GeckoBundle attrs = bundle.getBundle("attributes");
+ final Map<String, String> attributes = new HashMap<>();
+
+ for (final String key : attrs.keys()) {
+ attributes.put(key, String.valueOf(attrs.get(key)));
+ }
+
+ mAttributes = attributes;
+
+ mEnabled =
+ enabledFromBundle(
+ mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false));
+ mFocusable = mEnabled;
+
+ final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT);
+ final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT);
+ mInputType = typeFromBundle(type, hint);
+ mHint = hintFromBundle(type, hint);
+ }
+
+ private boolean enabledFromBundle(
+ final String tag, final boolean editable, final boolean disabled) {
+ switch (tag) {
+ case "input":
+ {
+ if (!editable) {
+ // Don't process non-editable inputs (e.g., type="button").
+ return false;
+ }
+ return !disabled;
+ }
+ case "textarea":
+ return !disabled;
+ default:
+ return false;
+ }
+ }
+
+ private @AutofillHint int hintFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "email":
+ return Hint.EMAIL_ADDRESS;
+ case "password":
+ return Hint.PASSWORD;
+ case "url":
+ return Hint.URI;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return Hint.USERNAME;
+ }
+ break;
+ }
+ }
+
+ return Hint.NONE;
+ }
+
+ private @AutofillInputType int typeFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "password":
+ case "url":
+ case "email":
+ return InputType.TEXT;
+ case "number":
+ return InputType.NUMBER;
+ case "tel":
+ return InputType.PHONE;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return InputType.TEXT;
+ }
+ break;
+ }
+ }
+
+ return InputType.NONE;
+ }
+ }
+
+ public interface Delegate {
+
+ /**
+ * An autofill session has started. Usually triggered by page load.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionStart(@NonNull final GeckoSession session) {}
+ /**
+ * An autofill session has been committed. Triggered by form submission or navigation.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node the node that is being committed.
+ * @param data the node data associated to the node being committed.
+ */
+ @UiThread
+ default void onSessionCommit(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+ /**
+ * An autofill session has been canceled. Triggered by page unload.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionCancel(@NonNull final GeckoSession session) {}
+ /**
+ * A node within the autofill session has been added.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was added.
+ * @param data The {@link NodeData} associated to the note that was added.
+ */
+ @UiThread
+ default void onNodeAdd(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+ /**
+ * A node within the autofill session has been removed.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was removed.
+ * @param data The {@link NodeData} associated to the note that was removed.
+ */
+ @UiThread
+ default void onNodeRemove(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+ /**
+ * A node within the autofill session has been updated.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was updated.
+ * @param data The {@link NodeData} associated to the note that was updated.
+ */
+ @UiThread
+ default void onNodeUpdate(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+ /**
+ * A node within the autofill session has gained focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param focused The {@link Node} that is now focused.
+ * @param data The {@link NodeData} associated to the note that is now focused.
+ */
+ @UiThread
+ default void onNodeFocus(
+ @NonNull final GeckoSession session,
+ @NonNull final Node focused,
+ @NonNull final NodeData data) {}
+ /**
+ * A node within the autofill session has lost focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param prev The {@link Node} that lost focus.
+ * @param data The {@link NodeData} associated to the note that lost focus.
+ */
+ @UiThread
+ default void onNodeBlur(
+ @NonNull final GeckoSession session,
+ @NonNull final Node prev,
+ @NonNull final NodeData data) {}
+ }
+
+ /* package */ static final class Support implements BundleEventListener {
+ private static final String LOGTAG = "AutofillSupport";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private @NonNull final Session mAutofillSession;
+ private Delegate mDelegate;
+
+ public Support(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ mAutofillSession = new Session(mGeckoSession);
+ }
+
+ public void registerListeners() {
+ mGeckoSession
+ .getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:StartAutofill",
+ "GeckoView:AddAutofill",
+ "GeckoView:ClearAutofill",
+ "GeckoView:CommitAutofill",
+ "GeckoView:OnAutofillFocus",
+ "GeckoView:UpdateAutofill");
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ if ("GeckoView:AddAutofill".equals(event)) {
+ addNode(message.getBundle("node"), callback);
+ } else if ("GeckoView:StartAutofill".equals(event)) {
+ start(message.getString("sessionId"));
+ } else if ("GeckoView:ClearAutofill".equals(event)) {
+ clear();
+ } else if ("GeckoView:OnAutofillFocus".equals(event)) {
+ onFocusChanged(message.getBundle("node"));
+ } else if ("GeckoView:CommitAutofill".equals(event)) {
+ commit(message.getBundle("node"));
+ } else if ("GeckoView:UpdateAutofill".equals(event)) {
+ update(message.getBundle("node"));
+ }
+ }
+
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ mDelegate = delegate;
+ }
+
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDelegate;
+ }
+
+ @UiThread
+ public @NonNull Session getAutofillSession() {
+ ThreadUtils.assertOnUiThread();
+
+ return mAutofillSession;
+ }
+
+ /* package */ void addNode(
+ @NonNull final GeckoBundle message, @NonNull final EventCallback callback) {
+ final Session session = getAutofillSession();
+ final Node node = new Node(message, session.getDefaultDimensions(), session.getId());
+
+ session.addRoot(node, callback);
+ addValues(message);
+
+ if (mDelegate != null) {
+ mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ private void addValues(final GeckoBundle message) {
+ final String uuid = message.getString("uuid");
+ if (uuid == null) {
+ return;
+ }
+
+ final String value = message.getString("value");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ Objects.requireNonNull(node);
+ final NodeData data = getAutofillSession().dataFor(node);
+ Objects.requireNonNull(data);
+ data.value = value;
+
+ final GeckoBundle[] children = message.getBundleArray("children");
+ if (children != null) {
+ for (final GeckoBundle child : children) {
+ addValues(child);
+ }
+ }
+ }
+
+ /* package */ void start(@Nullable final String sessionId) {
+ // Make sure we start with a clean session
+ getAutofillSession().clear(sessionId);
+ if (mDelegate != null) {
+ mDelegate.onSessionStart(mGeckoSession);
+ }
+ }
+
+ /* package */ void commit(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "commit(" + uuid + ")");
+ }
+
+ if (mDelegate != null) {
+ mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void update(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "update(" + uuid + ")");
+ }
+
+ final Node node = getAutofillSession().getNode(uuid);
+ final String value = message.getString("value", "");
+
+ if (node == null) {
+ Log.d(LOGTAG, "could not find node " + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ final NodeData data = getAutofillSession().dataFor(node);
+ Log.d(
+ LOGTAG,
+ "updating node " + uuid + " value from " + data != null
+ ? data.value
+ : null + " to " + value);
+ }
+
+ getAutofillSession().dataFor(node).value = value;
+
+ if (mDelegate != null) {
+ mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void clear() {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "clear()");
+ }
+
+ getAutofillSession().clear(null);
+ if (mDelegate != null) {
+ mDelegate.onSessionCancel(mGeckoSession);
+ }
+ }
+
+ /* package */ void onFocusChanged(@Nullable final GeckoBundle message) {
+ final Session session = getAutofillSession();
+ if (session.isEmpty()) {
+ return;
+ }
+
+ final Node prev = getAutofillSession().getFocused();
+ final String prevUuid = prev != null ? prev.getUuid() : null;
+ final String uuid = message != null ? message.getString("uuid") : null;
+
+ final Node focused;
+ if (uuid == null) {
+ focused = null;
+ } else {
+ focused = session.getNode(uuid);
+ if (focused == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ if (message != null) {
+ final RectF screenRectF = message.getRectF("screenRect");
+ focused.setScreenRect(screenRectF);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')');
+ }
+
+ if (Objects.equals(uuid, prevUuid)) {
+ // Nothing changed, nothing to do.
+ return;
+ }
+
+ session.setFocus(focused);
+
+ if (mDelegate != null) {
+ if (prev != null) {
+ mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev));
+ }
+ if (uuid != null) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+
+ @UiThread
+ public void onActiveChanged(final boolean active) {
+ ThreadUtils.assertOnUiThread();
+
+ final Node focused = getAutofillSession().getFocused();
+
+ if (focused == null) {
+ return;
+ }
+
+ if (mDelegate != null) {
+ if (active) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ } else {
+ mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
new file mode 100644
index 0000000000..d135194afa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
@@ -0,0 +1,20 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * This class exposes the Base64 URL encode/decode functions from Gecko. They are different from
+ * android.util.Base64 in that they always use URL encoding, no padding, and are constant time. The
+ * last bit is important when dealing with values that might be secret as we do with Web Push.
+ */
+/* package */ class Base64Utils {
+ @WrapForJNI
+ public static native byte[] decode(final String data);
+
+ @WrapForJNI
+ public static native String encode(final byte[] data);
+}
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
new file mode 100644
index 0000000000..cc288d987f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java
@@ -0,0 +1,692 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Matrix;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.TransactionTooLargeException;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default
+ * if the consumer does not explicitly set a SelectionActionDelegate.
+ *
+ * <p>To provide custom actions, extend this class and override the following methods,
+ *
+ * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This
+ * array must include all actions, available or not, and must not change over the class lifetime.
+ *
+ * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently
+ * available.
+ *
+ * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action.
+ *
+ * <p>4) Override {@link #performAction} to perform a custom action when used.
+ */
+@UiThread
+public class BasicSelectionActionDelegate
+ implements ActionMode.Callback, GeckoSession.SelectionActionDelegate {
+ private static final String LOGTAG = "BasicSelectionAction";
+
+ protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT;
+
+ private static final String[] FLOATING_TOOLBAR_ACTIONS =
+ new String[] {
+ ACTION_CUT,
+ ACTION_COPY,
+ ACTION_PASTE,
+ ACTION_SELECT_ALL,
+ ACTION_PASTE_AS_PLAIN_TEXT,
+ ACTION_PROCESS_TEXT
+ };
+ private static final String[] FIXED_TOOLBAR_ACTIONS =
+ new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE};
+
+ // This is limitation of intent text.
+ private static final int MAX_INTENT_TEXT_LENGTH = 100000;
+
+ protected final @NonNull Activity mActivity;
+ protected final boolean mUseFloatingToolbar;
+
+ @Deprecated
+ @DeprecationSchedule(id = "selection-fission", version = 112)
+ protected final @NonNull Matrix mTempMatrix = new Matrix();
+
+ @Deprecated
+ @DeprecationSchedule(id = "selection-fission", version = 112)
+ protected final @NonNull RectF mTempRect = new RectF();
+
+ private boolean mExternalActionsEnabled;
+
+ protected @Nullable ActionMode mActionMode;
+ protected @Nullable GeckoSession mSession;
+ protected @Nullable Selection mSelection;
+ protected boolean mRepopulatedMenu;
+
+ private @Nullable ActionMode mActionModeForClipboardPermission;
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private class Callback2Wrapper extends ActionMode.Callback2 {
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode);
+ }
+
+ @Override
+ public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
+ super.onGetContentRect(mode, view, outRect);
+ BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect);
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(final @NonNull Activity activity) {
+ this(activity, Build.VERSION.SDK_INT >= 23);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(
+ final @NonNull Activity activity, final boolean useFloatingToolbar) {
+ mActivity = activity;
+ mUseFloatingToolbar = useFloatingToolbar;
+ mExternalActionsEnabled = true;
+ }
+
+ /**
+ * Set whether to include text actions from other apps in the floating toolbar.
+ *
+ * @param enable True if external actions should be enabled.
+ */
+ public void enableExternalActions(final boolean enable) {
+ ThreadUtils.assertOnUiThread();
+ mExternalActionsEnabled = enable;
+
+ if (mActionMode != null) {
+ mActionMode.invalidate();
+ }
+ }
+
+ /**
+ * Get whether text actions from other apps are enabled.
+ *
+ * @return True if external actions are enabled.
+ */
+ public boolean areExternalActionsEnabled() {
+ return mExternalActionsEnabled;
+ }
+
+ /**
+ * Return list of all actions in proper order, regardless of their availability at present.
+ * Override to add to or remove from the default set.
+ *
+ * @return Array of action IDs in proper order.
+ */
+ protected @NonNull String[] getAllActions() {
+ return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS;
+ }
+
+ /**
+ * Return whether an action is presently available. Override to indicate availability for custom
+ * actions.
+ *
+ * @param id Action ID.
+ * @return True if the action is presently available.
+ */
+ protected boolean isActionAvailable(final @NonNull String id) {
+ if (mSelection == null) {
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) {
+ return false;
+ }
+
+ if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) {
+ return !getProcessTextExportedActivities().isEmpty();
+ }
+
+ return mSelection.isActionAvailable(id);
+ }
+
+ /**
+ * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text
+ * is selected.
+ *
+ * @return list of exported activities
+ */
+ private @NonNull List<ResolveInfo> getProcessTextExportedActivities() {
+ final PackageManager pm = mActivity.getPackageManager();
+ final List<ResolveInfo> resolvedList =
+ pm.queryIntentActivityOptions(
+ null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY);
+ final ArrayList<ResolveInfo> exportedList = new ArrayList<>();
+ for (final ResolveInfo info : resolvedList) {
+ if (info.activityInfo.exported) {
+ exportedList.add(info);
+ }
+ }
+
+ return exportedList;
+ }
+
+ /**
+ * Provides access to whether there are text selection actions available. Override to indicate
+ * availability for custom actions.
+ *
+ * @return True if there are text selection actions available.
+ */
+ public boolean isActionAvailable() {
+ if (mSelection == null) {
+ return false;
+ }
+
+ return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty();
+ }
+
+ /**
+ * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom
+ * action.
+ *
+ * @param id Action ID.
+ * @param item New menu item to prepare.
+ */
+ protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) {
+ switch (id) {
+ case ACTION_CUT:
+ item.setTitle(android.R.string.cut);
+ break;
+ case ACTION_COPY:
+ item.setTitle(android.R.string.copy);
+ break;
+ case ACTION_PASTE:
+ item.setTitle(android.R.string.paste);
+ break;
+ case ACTION_PASTE_AS_PLAIN_TEXT:
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ throw new IllegalStateException("Unexpected version for action");
+ }
+ item.setTitle(android.R.string.paste_as_plain_text);
+ break;
+ case ACTION_SELECT_ALL:
+ item.setTitle(android.R.string.selectAll);
+ break;
+ case ACTION_PROCESS_TEXT:
+ throw new IllegalStateException("Unexpected action");
+ }
+ }
+
+ /**
+ * Perform the specified action. Override to perform custom actions.
+ *
+ * @param id Action ID.
+ * @param item Nenu item for the action.
+ * @return True if the action was performed.
+ */
+ protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) {
+ if (ACTION_PROCESS_TEXT.equals(id)) {
+ try {
+ mActivity.startActivity(item.getIntent());
+ } catch (final ActivityNotFoundException e) {
+ Log.e(LOGTAG, "Cannot perform action", e);
+ return false;
+ }
+ return true;
+ }
+
+ if (mSelection == null) {
+ return false;
+ }
+ mSelection.execute(id);
+
+ // Android behavior is to clear selection on copy.
+ if (ACTION_COPY.equals(id)) {
+ if (mUseFloatingToolbar) {
+ clearSelection();
+ } else {
+ mActionMode.finish();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get the current selection object. This object should not be stored as it does not update when
+ * the selection becomes invalid. Stale actions are ignored.
+ *
+ * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current
+ * action menu. <code>null</code> if no action menu is active.
+ */
+ public @Nullable Selection getSelection() {
+ return mSelection;
+ }
+
+ /** Clear the current selection, if possible. */
+ public void clearSelection() {
+ if (mSelection == null) {
+ return;
+ }
+
+ if (isActionAvailable(ACTION_COLLAPSE_TO_END)) {
+ mSelection.collapseToEnd();
+ } else if (isActionAvailable(ACTION_UNSELECT)) {
+ mSelection.unselect();
+ } else {
+ mSelection.hide();
+ }
+ }
+
+ private String getSelectedText(final int maxLength) {
+ if (mSelection == null) {
+ return "";
+ }
+
+ if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) {
+ return mSelection.text;
+ }
+
+ return mSelection.text.substring(0, maxLength);
+ }
+
+ private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) {
+ final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT);
+ if (resolveInfo != null) {
+ intent.setComponent(
+ new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name));
+ }
+ intent.addCategory(Intent.CATEGORY_DEFAULT);
+ intent.setType("text/plain");
+ // If using large text, anything intent may throw RemoteException.
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH));
+ // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137).
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true);
+ return intent;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ for (final String actionId : allActions) {
+ if (isActionAvailable(actionId)) {
+ if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) {
+ // Android bug where onPrepareActionMode is not called initially.
+ onPrepareActionMode(actionMode, menu);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ boolean changed = false;
+
+ // Whether we are repopulating an existing menu.
+ mRepopulatedMenu = menu.size() != 0;
+
+ // For each action, see if it's available at present, and if necessary,
+ // add to or remove from menu.
+ for (int i = 0; i < allActions.length; i++) {
+ final String actionId = allActions[i];
+ final int menuId = i + Menu.FIRST;
+
+ if (ACTION_PROCESS_TEXT.equals(actionId)) {
+ if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) {
+ final List<ResolveInfo> exportedPackageInfo = getProcessTextExportedActivities();
+ if (!exportedPackageInfo.isEmpty()) {
+ for (final ResolveInfo info : exportedPackageInfo) {
+ final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info);
+ if (isMenuItemAdded) {
+ changed = true;
+ }
+ }
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeGroup(menuId);
+ changed = true;
+ }
+ continue;
+ }
+
+ if (isActionAvailable(actionId)) {
+ if (menu.findItem(menuId) == null) {
+ prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ ""));
+ changed = true;
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeItem(menuId);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ private boolean addProcessTextMenuItem(
+ final Menu menu, final int menuId, final ResolveInfo info) {
+ boolean isMenuItemAdded = false;
+ try {
+ menu.addIntentOptions(
+ menuId,
+ menuId,
+ menuId,
+ mActivity.getComponentName(),
+ /* specifiec */ null,
+ getProcessTextIntent(info),
+ /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */
+ null);
+ isMenuItemAdded = true;
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof TransactionTooLargeException) {
+ // Binder size error. MAX_INTENT_TEXT_LENGTH is still large?
+ Log.e(LOGTAG, "Cannot add intent option", e);
+ } else {
+ throw e;
+ }
+ }
+ return isMenuItemAdded;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ ThreadUtils.assertOnUiThread();
+ MenuItem realMenuItem = null;
+ if (mRepopulatedMenu) {
+ // When we repopulate an existing menu, Android can sometimes give us an old,
+ // deleted MenuItem. Find the current MenuItem that corresponds to the old one.
+ final Menu menu = actionMode.getMenu();
+ final int size = menu.size();
+ for (int i = 0; i < size; i++) {
+ final MenuItem item = menu.getItem(i);
+ if (item == menuItem
+ || (item.getItemId() == menuItem.getItemId()
+ && item.getTitle().equals(menuItem.getTitle()))) {
+ realMenuItem = item;
+ break;
+ }
+ }
+ } else {
+ realMenuItem = menuItem;
+ }
+
+ if (realMenuItem == null) {
+ return false;
+ }
+ final String[] allActions = getAllActions();
+ return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ ThreadUtils.assertOnUiThread();
+ if (!mUseFloatingToolbar) {
+ clearSelection();
+ }
+ mSession = null;
+ mSelection = null;
+ mActionMode = null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void onGetContentRect(
+ final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) {
+ ThreadUtils.assertOnUiThread();
+ if (mSelection == null || mSelection.screenRect == null) {
+ return;
+ }
+
+ // mTempMatrix and mTempRect are deprecated.
+ mSession.getClientToScreenMatrix(mTempMatrix);
+ mTempMatrix.mapRect(mTempRect, mSelection.clientRect);
+
+ mSelection.screenRect.roundOut(outRect);
+ }
+
+ @TargetApi(Build.VERSION_CODES.M)
+ @Override
+ public void onShowActionRequest(final GeckoSession session, final Selection selection) {
+ ThreadUtils.assertOnUiThread();
+ mSession = session;
+ mSelection = selection;
+
+ if (mActionMode != null) {
+ if (isActionAvailable()) {
+ mActionMode.invalidate();
+ } else {
+ mActionMode.finish();
+ }
+ return;
+ }
+
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ return;
+ }
+
+ if (mUseFloatingToolbar) {
+ mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING);
+ } else {
+ mActionMode = mActivity.startActionMode(this);
+ }
+ }
+
+ @Override
+ public void onHideAction(final GeckoSession session, final int reason) {
+ ThreadUtils.assertOnUiThread();
+ if (mActionMode == null) {
+ return;
+ }
+
+ switch (reason) {
+ case HIDE_REASON_ACTIVE_SCROLL:
+ case HIDE_REASON_ACTIVE_SELECTION:
+ case HIDE_REASON_INVISIBLE_SELECTION:
+ if (mUseFloatingToolbar) {
+ // Hide the floating toolbar when scrolling/selecting.
+ mActionMode.finish();
+ }
+ break;
+
+ case HIDE_REASON_NO_SELECTION:
+ mActionMode.finish();
+ break;
+ }
+ }
+
+ /** Callback class of clipboard permission. This is used on pre-M only */
+ private class ClipboardPermissionCallback implements ActionMode.Callback {
+ private GeckoResult<AllowOrDeny> mResult;
+
+ public ClipboardPermissionCallback(final GeckoResult<AllowOrDeny> 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<AllowOrDeny> mResult;
+ private final @NonNull GeckoSession mSession;
+ private final @Nullable Point mPoint;
+
+ public ClipboardPermissionCallbackM(
+ final @NonNull GeckoSession session,
+ final @Nullable Point screenPoint,
+ final @NonNull GeckoResult<AllowOrDeny> 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<AllowOrDeny> onShowClipboardPermissionRequest(
+ final GeckoSession session, final ClipboardPermission permission) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoResult<AllowOrDeny> 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/CallbackResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
new file mode 100644
index 0000000000..9162566666
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
@@ -0,0 +1,15 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.util.EventCallback;
+
+/* package */ abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
+ @Override
+ public void sendError(final Object response) {
+ completeExceptionally(
+ response != null ? new Exception(response.toString()) : new UnknownError());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
new file mode 100644
index 0000000000..77bca329c4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Color;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public final class CompositorController {
+ private final GeckoSession.Compositor mCompositor;
+
+ private List<Runnable> mDrawCallbacks;
+ private int mDefaultClearColor = Color.WHITE;
+ private Runnable mFirstPaintCallback;
+
+ /* package */ CompositorController(final GeckoSession session) {
+ mCompositor = session.mCompositor;
+ }
+
+ /* package */ void onCompositorReady() {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ mCompositor.enableLayerUpdateNotifications(mDrawCallbacks != null && !mDrawCallbacks.isEmpty());
+ }
+
+ /* package */ void onCompositorDetached() {
+ if (mDrawCallbacks != null) {
+ mDrawCallbacks.clear();
+ }
+ }
+
+ /* package */ void notifyDrawCallbacks() {
+ if (mDrawCallbacks != null) {
+ for (final Runnable callback : mDrawCallbacks) {
+ callback.run();
+ }
+ }
+ }
+
+ /**
+ * Add a callback to run when drawing (layer update) occurs.
+ *
+ * @param callback Callback to add.
+ */
+ @RobocopTarget
+ public void addDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ mDrawCallbacks = new ArrayList<Runnable>(2);
+ }
+
+ if (mDrawCallbacks.add(callback) && mDrawCallbacks.size() == 1 && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(true);
+ }
+ }
+
+ /**
+ * Remove a previous draw callback.
+ *
+ * @param callback Callback to remove.
+ */
+ @RobocopTarget
+ public void removeDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ return;
+ }
+
+ if (mDrawCallbacks.remove(callback) && mDrawCallbacks.isEmpty() && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(false);
+ }
+ }
+
+ /**
+ * Get the current clear color when drawing.
+ *
+ * @return Curent clear color.
+ */
+ public int getClearColor() {
+ ThreadUtils.assertOnUiThread();
+ return mDefaultClearColor;
+ }
+
+ /**
+ * Set the clear color when drawing. Default is Color.WHITE.
+ *
+ * @param color Clear color.
+ */
+ public void setClearColor(final int color) {
+ ThreadUtils.assertOnUiThread();
+
+ mDefaultClearColor = color;
+ if (mCompositor.isReady()) {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ }
+ }
+
+ /**
+ * Get the current first paint callback.
+ *
+ * @return Current first paint callback or null if not set.
+ */
+ public @Nullable Runnable getFirstPaintCallback() {
+ ThreadUtils.assertOnUiThread();
+ return mFirstPaintCallback;
+ }
+
+ /**
+ * Set a callback to run when a document is first drawn.
+ *
+ * @param callback First paint callback.
+ */
+ public void setFirstPaintCallback(final @Nullable Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+ mFirstPaintCallback = callback;
+ }
+
+ /* package */ void onFirstPaint() {
+ if (mFirstPaintCallback != null) {
+ mFirstPaintCallback.run();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
new file mode 100644
index 0000000000..fd06b2af48
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
@@ -0,0 +1,1656 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/** Content Blocking API to hold and control anti-tracking, cookie and Safe Browsing settings. */
+@AnyThread
+public class ContentBlocking {
+ /** {@link SafeBrowsingProvider} configuration for Google's legacy SafeBrowsing server. */
+ public static final SafeBrowsingProvider GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER =
+ SafeBrowsingProvider.withName("google")
+ .version("2.2")
+ .lists(
+ "goog-badbinurl-shavar",
+ "goog-downloadwhite-digest256",
+ "goog-phish-shavar",
+ "googpub-phish-shavar",
+ "goog-malware-shavar",
+ "goog-unwanted-shavar")
+ .updateUrl(
+ "https://safebrowsing.google.com/safebrowsing/downloads?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2&key=%GOOGLE_SAFEBROWSING_API_KEY%")
+ .getHashUrl(
+ "https://safebrowsing.google.com/safebrowsing/gethash?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2")
+ .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=")
+ .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=")
+ .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=")
+ .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory")
+ .advisoryName("Google Safe Browsing")
+ .build();
+
+ /** {@link SafeBrowsingProvider} configuration for Google's SafeBrowsing server. */
+ public static final SafeBrowsingProvider GOOGLE_SAFE_BROWSING_PROVIDER =
+ SafeBrowsingProvider.withName("google4")
+ .version("4")
+ .lists(
+ "goog-badbinurl-proto",
+ "goog-downloadwhite-proto",
+ "goog-phish-proto",
+ "googpub-phish-proto",
+ "goog-malware-proto",
+ "goog-unwanted-proto",
+ "goog-harmful-proto",
+ "goog-passwordwhite-proto")
+ .updateUrl(
+ "https://safebrowsing.googleapis.com/v4/threatListUpdates:fetch?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .getHashUrl(
+ "https://safebrowsing.googleapis.com/v4/fullHashes:find?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=")
+ .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=")
+ .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=")
+ .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory")
+ .advisoryName("Google Safe Browsing")
+ .dataSharingUrl(
+ "https://safebrowsing.googleapis.com/v4/threatHits?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST")
+ .dataSharingEnabled(false)
+ .build();
+
+ // This class shouldn't be instantiated
+ protected ContentBlocking() {}
+
+ @AnyThread
+ public static class Settings extends RuntimeSettings {
+ private final Map<String, SafeBrowsingProvider> mSafeBrowsingProviders = new HashMap<>();
+
+ private static final SafeBrowsingProvider[] DEFAULT_PROVIDERS = {
+ ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER,
+ ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER
+ };
+
+ @AnyThread
+ public static class Builder extends RuntimeSettings.Builder<Settings> {
+ @Override
+ protected @NonNull Settings newSettings(final @Nullable Settings settings) {
+ return new Settings(settings);
+ }
+
+ /**
+ * Set custom safe browsing providers.
+ *
+ * @param providers one or more custom providers.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ getSettings().setSafeBrowsingProviders(providers);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for phishing threats.
+ *
+ * @param safeBrowsingPhishingTable one or more lists for safe browsing phishing.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingPhishingTable(
+ final @NonNull String[] safeBrowsingPhishingTable) {
+ getSettings().setSafeBrowsingPhishingTable(safeBrowsingPhishingTable);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for malware threats.
+ *
+ * @param safeBrowsingMalwareTable one or more lists for safe browsing malware.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingMalwareTable(
+ final @NonNull String[] safeBrowsingMalwareTable) {
+ getSettings().setSafeBrowsingMalwareTable(safeBrowsingMalwareTable);
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.AntiTracking} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder antiTracking(final @CBAntiTracking int cat) {
+ getSettings().setAntiTracking(cat);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.SafeBrowsing} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder safeBrowsing(final @CBSafeBrowsing int cat) {
+ getSettings().setSafeBrowsing(cat);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehavior(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehavior(behavior);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior in private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehaviorPrivateMode(behavior);
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use. Only takes effect if cookie behavior is set
+ * to {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder enhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ getSettings().setEnhancedTrackingProtectionLevel(level);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled. This will block resources
+ * from loading if they are on the social tracking protection list, rather than just blocking
+ * cookies as with normal social tracking protection.
+ *
+ * @param enabled A boolean indicating whether or not strict social tracking protection should
+ * be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder strictSocialTrackingProtection(final boolean enabled) {
+ getSettings().setStrictSocialTrackingProtection(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not to automatically purge tracking cookies. This will purge cookies from
+ * tracking sites that do not have recent user interaction provided that the cookie behavior
+ * is set to either {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether or not cookie purging should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder cookiePurging(final boolean enabled) {
+ getSettings().setCookiePurging(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingMode(final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerMode(mode);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode for private browsing.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingModePrivateBrowsing(
+ final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerModePrivateBrowsing(mode);
+ return this;
+ }
+ }
+
+ /* package */ final Pref<String> mAt =
+ new Pref<String>(
+ "urlclassifier.trackingTable", ContentBlocking.catToAtPref(AntiTracking.DEFAULT));
+ /* package */ final Pref<Boolean> mCm =
+ new Pref<Boolean>("privacy.trackingprotection.cryptomining.enabled", false);
+ /* package */ final Pref<String> mCmList =
+ new Pref<String>(
+ "urlclassifier.features.cryptomining.blacklistTables",
+ ContentBlocking.catToCmListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mFp =
+ new Pref<Boolean>("privacy.trackingprotection.fingerprinting.enabled", false);
+ /* package */ final Pref<String> mFpList =
+ new Pref<String>(
+ "urlclassifier.features.fingerprinting.blacklistTables",
+ ContentBlocking.catToFpListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mSt =
+ new Pref<Boolean>("privacy.socialtracking.block_cookies.enabled", false);
+ /* package */ final Pref<Boolean> mStStrict =
+ new Pref<Boolean>("privacy.trackingprotection.socialtracking.enabled", false);
+ /* package */ final Pref<String> mStList =
+ new Pref<String>(
+ "urlclassifier.features.socialtracking.annotate.blacklistTables",
+ ContentBlocking.catToStListPref(AntiTracking.NONE));
+
+ /* package */ final Pref<Boolean> mSbMalware =
+ new Pref<Boolean>("browser.safebrowsing.malware.enabled", true);
+ /* package */ final Pref<Boolean> mSbPhishing =
+ new Pref<Boolean>("browser.safebrowsing.phishing.enabled", true);
+ /* package */ final Pref<Integer> mCookieBehavior =
+ new Pref<Integer>("network.cookie.cookieBehavior", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Integer> mCookieBehaviorPrivateMode =
+ new Pref<Integer>(
+ "network.cookie.cookieBehavior.pbmode", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Boolean> mCookiePurging =
+ new Pref<Boolean>("privacy.purge_trackers.enabled", false);
+
+ /* package */ final Pref<Boolean> mEtpEnabled =
+ new Pref<Boolean>("privacy.trackingprotection.annotate_channels", false);
+ /* package */ final Pref<Boolean> mEtpStrict =
+ new Pref<Boolean>("privacy.annotate_channels.strict_list.enabled", false);
+
+ /* package */ final Pref<Integer> mCbhMode =
+ new Pref<Integer>(
+ "cookiebanners.service.mode", CookieBannerMode.COOKIE_BANNER_MODE_DISABLED);
+ /* package */ final Pref<Integer> mCbhModePrivateBrowsing =
+ new Pref<Integer>(
+ "cookiebanners.service.mode.privateBrowsing",
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT);
+
+ /* package */ final Pref<String> mSafeBrowsingMalwareTable =
+ new Pref<>(
+ "urlclassifier.malwareTable",
+ ContentBlocking.listsToPref(
+ "goog-malware-proto",
+ "goog-unwanted-proto",
+ "moztest-harmful-simple",
+ "moztest-malware-simple",
+ "moztest-unwanted-simple"));
+ /* package */ final Pref<String> mSafeBrowsingPhishingTable =
+ new Pref<>(
+ "urlclassifier.phishTable",
+ ContentBlocking.listsToPref(
+ // In official builds, we are allowed to use Google's private phishing
+ // list (see bug 1288840).
+ BuildConfig.MOZILLA_OFFICIAL ? "goog-phish-proto" : "googpub-phish-proto",
+ "moztest-phish-simple"));
+
+ /** Construct default settings. */
+ /* package */ Settings() {
+ this(null /* settings */);
+ }
+
+ /**
+ * Copy-construct settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(final @Nullable Settings settings) {
+ this(null /* parent */, settings);
+ }
+
+ /**
+ * Copy-construct nested settings.
+ *
+ * @param parent The parent settings used for nesting.
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(
+ final @Nullable RuntimeSettings parent, final @Nullable Settings settings) {
+ super(parent);
+
+ if (settings != null) {
+ updatePrefs(settings);
+ } else {
+ // Set default browsing providers
+ setSafeBrowsingProviders(DEFAULT_PROVIDERS);
+ }
+ }
+
+ @Override
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ super.updatePrefs(settings);
+
+ final ContentBlocking.Settings source = (ContentBlocking.Settings) settings;
+ for (final SafeBrowsingProvider provider : source.mSafeBrowsingProviders.values()) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+ }
+
+ /**
+ * Get the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * @return an unmodifiable collection of {@link SafeBrowsingProvider}
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Collection<SafeBrowsingProvider> getSafeBrowsingProviders() {
+ return Collections.unmodifiableCollection(mSafeBrowsingProviders.values());
+ }
+
+ /**
+ * Sets the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * <p>By default the collection is composed of {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER}.
+ *
+ * @param providers {@link SafeBrowsingProvider} instances for this runtime.
+ * @return the {@link Settings} instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Settings setSafeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ mSafeBrowsingProviders.clear();
+
+ for (final SafeBrowsingProvider provider : providers) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Phishing. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Phishing feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingPhishingTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingPhishingTable.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Phishing.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Phishing feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingPhishingTable(final @NonNull String... table) {
+ mSafeBrowsingPhishingTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Malware. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Malware feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingMalwareTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingMalwareTable.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Malware.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Malware feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingMalwareTable(final @NonNull String... table) {
+ mSafeBrowsingMalwareTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.AntiTracking} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setAntiTracking(final @CBAntiTracking int cat) {
+ mAt.commit(ContentBlocking.catToAtPref(cat));
+
+ mCm.commit(ContentBlocking.catToCmPref(cat));
+ mCmList.commit(ContentBlocking.catToCmListPref(cat));
+
+ mFp.commit(ContentBlocking.catToFpPref(cat));
+ mFpList.commit(ContentBlocking.catToFpListPref(cat));
+
+ mSt.commit(ContentBlocking.catToStPref(cat));
+ mStList.commit(ContentBlocking.catToStListPref(cat));
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use; must be one of {@link
+ * ContentBlocking.EtpLevel} flags. Only takes effect if the cookie behavior is {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setEnhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ mEtpEnabled.commit(
+ level == ContentBlocking.EtpLevel.DEFAULT || level == ContentBlocking.EtpLevel.STRICT);
+ mEtpStrict.commit(level == ContentBlocking.EtpLevel.STRICT);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled (ie, whether to block content
+ * or just cookies). Will only block if social tracking protection lists are supplied to {@link
+ * #setAntiTracking}.
+ *
+ * @param enabled A boolean indicating whether or not to enable strict social tracking
+ * protection.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setStrictSocialTrackingProtection(final boolean enabled) {
+ mStStrict.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.SafeBrowsing} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setSafeBrowsing(final @CBSafeBrowsing int cat) {
+ mSbMalware.commit(ContentBlocking.catToSbMalware(cat));
+ mSbPhishing.commit(ContentBlocking.catToSbPhishing(cat));
+ return this;
+ }
+
+ /**
+ * Get the set anti-tracking categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBAntiTracking int getAntiTrackingCategories() {
+ return ContentBlocking.atListToAtCat(mAt.get())
+ | ContentBlocking.cmListToAtCat(mCmList.get())
+ | ContentBlocking.fpListToAtCat(mFpList.get())
+ | ContentBlocking.stListToAtCat(mStList.get());
+ }
+
+ /**
+ * Get the set ETP behavior level.
+ *
+ * @return The current ETP level; one of {@link ContentBlocking.EtpLevel}.
+ */
+ public @CBEtpLevel int getEnhancedTrackingProtectionLevel() {
+ if (mEtpStrict.get()) {
+ return ContentBlocking.EtpLevel.STRICT;
+ } else if (mEtpEnabled.get()) {
+ return ContentBlocking.EtpLevel.DEFAULT;
+ }
+ return ContentBlocking.EtpLevel.NONE;
+ }
+
+ /**
+ * Get whether or not strict social tracking protection is enabled.
+ *
+ * @return A boolean indicating whether or not strict social tracking protection is enabled.
+ */
+ public boolean getStrictSocialTrackingProtection() {
+ return mStStrict.get();
+ }
+
+ /**
+ * Get the set safe browsing categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBSafeBrowsing int getSafeBrowsingCategories() {
+ return ContentBlocking.sbMalwareToSbCat(mSbMalware.get())
+ | ContentBlocking.sbPhishingToSbCat(mSbPhishing.get());
+ }
+
+ /**
+ * Get the assigned cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehavior() {
+ return mCookieBehavior.get();
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehavior(final @CBCookieBehavior int behavior) {
+ mCookieBehavior.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get the assigned private mode cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehaviorPrivateMode() {
+ return mCookieBehaviorPrivateMode.get();
+ }
+
+ /**
+ * Set cookie storage behavior for private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ mCookieBehaviorPrivateMode.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get whether or not cookie purging is enabled.
+ *
+ * @return A boolean indicating whether or not cookie purging is enabled.
+ */
+ public boolean getCookiePurging() {
+ return mCookiePurging.get();
+ }
+
+ /**
+ * Enable or disable cookie purging. This will automatically purge cookies from tracking sites
+ * that have no recent user interaction, provided the cookie behavior is set to {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether to enable cookie purging.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookiePurging(final boolean enabled) {
+ mCookiePurging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode to the new provided {@link CBCookieBannerMode} value.
+ *
+ * @param mode Integer indicating the new mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerMode(final @CBCookieBannerMode int mode) {
+ mCbhMode.commit(mode);
+ return this;
+ }
+
+ /**
+ * Gets the current cookie banner handling mode.
+ *
+ * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBannerMode int getCookieBannerMode() {
+ return mCbhMode.get();
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode for private browsing to the new provided {@link
+ * CBCookieBannerMode} value.
+ *
+ * @param mode Integer indicating the new mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerModePrivateBrowsing(
+ final @CBCookieBannerMode int mode) {
+ mCbhModePrivateBrowsing.commit(mode);
+ return this;
+ }
+
+ /**
+ * Gets the current cookie banner handling mode for private browsing.
+ *
+ * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBannerMode int getCookieBannerModePrivateBrowsing() {
+ return mCbhModePrivateBrowsing.get();
+ }
+
+ public static final Parcelable.Creator<Settings> CREATOR =
+ new Parcelable.Creator<Settings>() {
+ @Override
+ public Settings createFromParcel(final Parcel in) {
+ final Settings settings = new Settings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public Settings[] newArray(final int size) {
+ return new Settings[size];
+ }
+ };
+ }
+
+ /**
+ * Holds configuration for a SafeBrowsing provider. <br>
+ * <br>
+ * This class can be used to modify existing configuration for SafeBrowsing providers or to add a
+ * custom SafeBrowsing provider to the app. <br>
+ * <br>
+ * Default configuration for Google's SafeBrowsing servers can be found at {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER}. <br>
+ * <br>
+ * This class is immutable, once constructed its values cannot be changed. <br>
+ * <br>
+ * You can, however, use the {@link #from} method to build upon an existing configuration. For
+ * example to override the Google's server configuration, you can do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider override = SafeBrowsingProvider
+ * .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ * .getHashUrl("http://my-custom-server.com/...")
+ * .updateUrl("http://my-custom-server.com/...")
+ * .build();
+ *
+ * runtime.getContentBlocking().setSafeBrowsingProviders(override);
+ * </code></pre>
+ *
+ * This will override the configuration. <br>
+ * <br>
+ * You can also add a custom SafeBrowsing provider using the {@link #withName} method. For
+ * example, to add a custom provider that provides the list <code>testprovider-phish-digest256
+ * </code> do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider custom = SafeBrowsingProvider
+ * .withName("custom-provider")
+ * .version("2.2")
+ * .lists("testprovider-phish-digest256")
+ * .updateUrl("http://my-custom-server2.com/...")
+ * .getHashUrl("http://my-custom-server2.com/...")
+ * .build();
+ * </code></pre>
+ *
+ * And then add the custom provider (adding optionally existing providers): <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingProviders(
+ * custom,
+ * // Add this if you want to keep the existing configuration too.
+ * ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER,
+ * ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER);
+ * </code></pre>
+ *
+ * And set the list in the phishing configuration <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingPhishingTable(
+ * "testprovider-phish-digest256",
+ * // Existing configuration
+ * "goog-phish-proto");
+ * </code></pre>
+ *
+ * Note that any list present in the phishing or malware tables need to appear in one safe
+ * browsing provider's {@link #getLists} property.
+ *
+ * <p>See also <a href="https://developers.google.com/safe-browsing/v4">safe-browsing/v4</a>.
+ */
+ @AnyThread
+ public static class SafeBrowsingProvider extends RuntimeSettings {
+ private static final String ROOT = "browser.safebrowsing.provider.";
+
+ private final String mName;
+
+ /* package */ final Pref<String> mVersion;
+ /* package */ final Pref<String> mLists;
+ /* package */ final Pref<String> mUpdateUrl;
+ /* package */ final Pref<String> mGetHashUrl;
+ /* package */ final Pref<String> mReportUrl;
+ /* package */ final Pref<String> mReportPhishingMistakeUrl;
+ /* package */ final Pref<String> mReportMalwareMistakeUrl;
+ /* package */ final Pref<String> mAdvisoryUrl;
+ /* package */ final Pref<String> mAdvisoryName;
+ /* package */ final Pref<String> mDataSharingUrl;
+ /* package */ final Pref<Boolean> mDataSharingEnabled;
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} for a provider with the given name.
+ *
+ * <p>Note: the <code>mozilla</code> name is reserved for internal use, and this method will
+ * throw if you attempt to build a provider with that name.
+ *
+ * @param name The name of the provider.
+ * @return a {@link Builder} instance that can be used to build a provider.
+ * @throws IllegalArgumentException if this method is called with <code>name="mozilla"</code>
+ */
+ @NonNull
+ public static Builder withName(final @NonNull String name) {
+ if ("mozilla".equals(name)) {
+ throw new IllegalArgumentException("The 'mozilla' name is reserved for internal use.");
+ }
+ return new Builder(name);
+ }
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} based on the given provider.
+ *
+ * <p>All properties not otherwise specified will be copied from the provider given in input.
+ *
+ * @param provider The source provider for this builder.
+ * @return a {@link Builder} instance that can be used to create a configuration based on the
+ * builder in input.
+ */
+ @NonNull
+ public static Builder from(final @NonNull SafeBrowsingProvider provider) {
+ return new Builder(provider);
+ }
+
+ @AnyThread
+ public static class Builder {
+ final SafeBrowsingProvider mProvider;
+
+ private Builder(final String name) {
+ mProvider = new SafeBrowsingProvider(name);
+ }
+
+ private Builder(final SafeBrowsingProvider source) {
+ mProvider = new SafeBrowsingProvider(source);
+ }
+
+ /**
+ * Sets the SafeBrowsing protocol session for this provider.
+ *
+ * @param version the version strong, e.g. "2.2" or "4".
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder version(final @NonNull String version) {
+ mProvider.mVersion.set(version);
+ return this;
+ }
+
+ /**
+ * Sets the lists provided by this provider.
+ *
+ * @param lists one or more lists for this provider, e.g. "goog-malware-proto",
+ * "goog-unwanted-proto"
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder lists(final @NonNull String... lists) {
+ mProvider.mLists.set(ContentBlocking.listsToPref(lists));
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @param updateUrl the update url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder updateUrl(final @NonNull String updateUrl) {
+ mProvider.mUpdateUrl.set(updateUrl);
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @param getHashUrl the gethash url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder getHashUrl(final @NonNull String getHashUrl) {
+ mProvider.mGetHashUrl.set(getHashUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @param reportUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportUrl(final @NonNull String reportUrl) {
+ mProvider.mReportUrl.set(reportUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @param reportPhishingMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportPhishingMistakeUrl(
+ final @NonNull String reportPhishingMistakeUrl) {
+ mProvider.mReportPhishingMistakeUrl.set(reportPhishingMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @param reportMalwareMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportMalwareMistakeUrl(
+ final @NonNull String reportMalwareMistakeUrl) {
+ mProvider.mReportMalwareMistakeUrl.set(reportMalwareMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @param advisoryUrl the adivisory page url for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryUrl(final @NonNull String advisoryUrl) {
+ mProvider.mAdvisoryUrl.set(advisoryUrl);
+ return this;
+ }
+
+ /**
+ * Set the advisory name for this provider.
+ *
+ * @param advisoryName the adivisory name for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryName(final @NonNull String advisoryName) {
+ mProvider.mAdvisoryName.set(advisoryName);
+ return this;
+ }
+
+ /**
+ * Set url to share threat data to the provider, if enabled by {@link #dataSharingEnabled}.
+ *
+ * @param dataSharingUrl the url endpoint
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingUrl(final @NonNull String dataSharingUrl) {
+ mProvider.mDataSharingUrl.set(dataSharingUrl);
+ return this;
+ }
+
+ /**
+ * Set whether to share threat data with the provider, off by default.
+ *
+ * @param dataSharingEnabled <code>true</code> if the browser should share threat data with
+ * the provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingEnabled(final boolean dataSharingEnabled) {
+ mProvider.mDataSharingEnabled.set(dataSharingEnabled);
+ return this;
+ }
+
+ /**
+ * Build the {@link SafeBrowsingProvider} based on this {@link Builder} instance.
+ *
+ * @return thie {@link SafeBrowsingProvider} instance.
+ */
+ public @NonNull SafeBrowsingProvider build() {
+ return new SafeBrowsingProvider(mProvider);
+ }
+ }
+
+ /* package */ SafeBrowsingProvider(final SafeBrowsingProvider source) {
+ this(/* name */ null, /* parent */ null, source);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ this(/* name */ null, parent, source);
+ }
+
+ /* package */ SafeBrowsingProvider(final String name) {
+ this(name, /* parent */ null, /* source */ null);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final String name, final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ super(parent);
+
+ if (name != null) {
+ mName = name;
+ } else if (source != null) {
+ mName = source.mName;
+ } else {
+ throw new IllegalArgumentException("Either name or source must be non-null");
+ }
+
+ mVersion = new Pref<>(ROOT + mName + ".pver", null);
+ mLists = new Pref<>(ROOT + mName + ".lists", null);
+ mUpdateUrl = new Pref<>(ROOT + mName + ".updateURL", null);
+ mGetHashUrl = new Pref<>(ROOT + mName + ".gethashURL", null);
+ mReportUrl = new Pref<>(ROOT + mName + ".reportURL", null);
+ mReportPhishingMistakeUrl = new Pref<>(ROOT + mName + ".reportPhishMistakeURL", null);
+ mReportMalwareMistakeUrl = new Pref<>(ROOT + mName + ".reportMalwareMistakeURL", null);
+ mAdvisoryUrl = new Pref<>(ROOT + mName + ".advisoryURL", null);
+ mAdvisoryName = new Pref<>(ROOT + mName + ".advisoryName", null);
+ mDataSharingUrl = new Pref<>(ROOT + mName + ".dataSharingURL", null);
+ mDataSharingEnabled = new Pref<>(ROOT + mName + ".dataSharing.enabled", false);
+
+ if (source != null) {
+ updatePrefs(source);
+ }
+ }
+
+ /**
+ * Get the name of this provider.
+ *
+ * @return a string containing the name.
+ */
+ public @NonNull String getName() {
+ return mName;
+ }
+
+ /**
+ * Get the version for this provider.
+ *
+ * @return a string representing the version, e.g. "2.2" or "4".
+ */
+ public @Nullable String getVersion() {
+ return mVersion.get();
+ }
+
+ /**
+ * Get the lists provided by this provider.
+ *
+ * @return an array of string identifiers for the lists
+ */
+ public @NonNull String[] getLists() {
+ return ContentBlocking.prefToLists(mLists.get());
+ }
+
+ /**
+ * Get the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getUpdateUrl() {
+ return mUpdateUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getGetHashUrl() {
+ return mGetHashUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportUrl() {
+ return mReportUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportPhishingMistakeUrl() {
+ return mReportPhishingMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportMalwareMistakeUrl() {
+ return mReportMalwareMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryUrl() {
+ return mAdvisoryUrl.get();
+ }
+
+ /**
+ * Get the advisory name for this provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryName() {
+ return mAdvisoryName.get();
+ }
+
+ /**
+ * Get the url to share threat data to the provider, if enabled by {@link
+ * #getDataSharingEnabled}.
+ *
+ * @return this {@link Builder} instance.
+ */
+ public @Nullable String getDataSharingUrl() {
+ return mDataSharingUrl.get();
+ }
+
+ /**
+ * Get whether to share threat data with the provider.
+ *
+ * @return <code>true</code> if the browser should whare threat data with the provider, <code>
+ * false</code> otherwise.
+ */
+ public @Nullable Boolean getDataSharingEnabled() {
+ return mDataSharingEnabled.get();
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeValue(mName);
+ super.writeToParcel(out, flags);
+ }
+
+ /** Creator instance for this class. */
+ public static final Parcelable.Creator<SafeBrowsingProvider> CREATOR =
+ new Parcelable.Creator<SafeBrowsingProvider>() {
+ @Override
+ public SafeBrowsingProvider createFromParcel(final Parcel source) {
+ final String name = (String) source.readValue(getClass().getClassLoader());
+ final SafeBrowsingProvider settings = new SafeBrowsingProvider(name);
+ settings.readFromParcel(source);
+ return settings;
+ }
+
+ @Override
+ public SafeBrowsingProvider[] newArray(final int size) {
+ return new SafeBrowsingProvider[size];
+ }
+ };
+ }
+
+ private static String listsToPref(final String... lists) {
+ final StringBuilder prefBuilder = new StringBuilder();
+
+ for (final String list : lists) {
+ if (list.contains(",")) {
+ // We use ',' as the separator, so the list name cannot contain it.
+ // Should never happen.
+ throw new IllegalArgumentException("List name cannot contain ',' character.");
+ }
+
+ prefBuilder.append(list);
+ prefBuilder.append(",");
+ }
+
+ // Remove trailing ","
+ if (lists.length > 0) {
+ prefBuilder.setLength(prefBuilder.length() - 1);
+ }
+
+ return prefBuilder.toString();
+ }
+
+ private static String[] prefToLists(final String pref) {
+ return pref != null ? pref.split(",") : new String[] {};
+ }
+
+ public static class AntiTracking {
+ public static final int NONE = 0;
+
+ /** Block advertisement trackers. */
+ public static final int AD = 1 << 1;
+
+ /** Block analytics trackers. */
+ public static final int ANALYTIC = 1 << 2;
+
+ /**
+ * Block social trackers. Note: This is not the same as "Social Tracking Protection", which is
+ * controlled by {@link #STP}.
+ */
+ public static final int SOCIAL = 1 << 3;
+
+ /** Block content trackers. May cause issues with some web sites. */
+ public static final int CONTENT = 1 << 4;
+
+ /** Block Gecko test trackers (used for tests). */
+ public static final int TEST = 1 << 5;
+
+ /** Block cryptocurrency miners. */
+ public static final int CRYPTOMINING = 1 << 6;
+
+ /** Block fingerprinting trackers. */
+ public static final int FINGERPRINTING = 1 << 7;
+
+ /** Block trackers on the Social Tracking Protection list. */
+ public static final int STP = 1 << 8;
+
+ /** Block ad, analytic, social and test trackers. */
+ public static final int DEFAULT = AD | ANALYTIC | SOCIAL | TEST;
+
+ /** Block all known trackers. May cause issues with some web sites. */
+ public static final int STRICT = DEFAULT | CONTENT | CRYPTOMINING | FINGERPRINTING;
+
+ protected AntiTracking() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ AntiTracking.AD,
+ AntiTracking.ANALYTIC,
+ AntiTracking.SOCIAL,
+ AntiTracking.CONTENT,
+ AntiTracking.TEST,
+ AntiTracking.CRYPTOMINING,
+ AntiTracking.FINGERPRINTING,
+ AntiTracking.DEFAULT,
+ AntiTracking.STRICT,
+ AntiTracking.STP,
+ AntiTracking.NONE
+ })
+ public @interface CBAntiTracking {}
+
+ public static class SafeBrowsing {
+ public static final int NONE = 0;
+
+ /** Block malware sites. */
+ public static final int MALWARE = 1 << 10;
+
+ /** Block unwanted sites. */
+ public static final int UNWANTED = 1 << 11;
+
+ /** Block harmful sites. */
+ public static final int HARMFUL = 1 << 12;
+
+ /** Block phishing sites. */
+ public static final int PHISHING = 1 << 13;
+
+ /** Block all unsafe sites. */
+ public static final int DEFAULT = MALWARE | UNWANTED | HARMFUL | PHISHING;
+
+ protected SafeBrowsing() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SafeBrowsing.MALWARE, SafeBrowsing.UNWANTED,
+ SafeBrowsing.HARMFUL, SafeBrowsing.PHISHING,
+ SafeBrowsing.DEFAULT, SafeBrowsing.NONE
+ })
+ public @interface CBSafeBrowsing {}
+
+ // Sync values with nsICookieService.idl.
+ public static class CookieBehavior {
+ /** Accept first-party and third-party cookies and site data. */
+ public static final int ACCEPT_ALL = 0;
+
+ /**
+ * Accept only first-party cookies and site data to block cookies which are not associated with
+ * the domain of the visited site.
+ */
+ public static final int ACCEPT_FIRST_PARTY = 1;
+
+ /** Do not store any cookies and site data. */
+ public static final int ACCEPT_NONE = 2;
+
+ /**
+ * Accept first-party and third-party cookies and site data only from sites previously visited
+ * in a first-party context.
+ */
+ public static final int ACCEPT_VISITED = 3;
+
+ /**
+ * Accept only first-party and non-tracking third-party cookies and site data to block cookies
+ * which are not associated with the domain of the visited site set by known trackers.
+ */
+ public static final int ACCEPT_NON_TRACKERS = 4;
+
+ /**
+ * Enable dynamic first party isolation (dFPI); this will block third-party tracking cookies in
+ * accordance with the ETP level and isolate non-tracking third-party cookies.
+ */
+ public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5;
+
+ protected CookieBehavior() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CookieBehavior.ACCEPT_ALL, CookieBehavior.ACCEPT_FIRST_PARTY,
+ CookieBehavior.ACCEPT_NONE, CookieBehavior.ACCEPT_VISITED,
+ CookieBehavior.ACCEPT_NON_TRACKERS
+ })
+ public @interface CBCookieBehavior {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT})
+ public @interface CBEtpLevel {}
+
+ /** Possible settings for ETP. */
+ public static class EtpLevel {
+ /** Do not enable ETP at all. */
+ public static final int NONE = 0;
+
+ /** Enable ETP for ads, analytic, and social tracking lists. */
+ public static final int DEFAULT = 1;
+
+ /**
+ * Enable ETP for all of the default lists as well as the content list. May break many sites!
+ */
+ public static final int STRICT = 2;
+ }
+
+ /** Holds content block event details. */
+ public static class BlockEvent {
+ /** The URI of the blocked resource. */
+ public final @NonNull String uri;
+
+ private final @CBAntiTracking int mAntiTrackingCat;
+ private final @CBSafeBrowsing int mSafeBrowsingCat;
+ private final @CBCookieBehavior int mCookieBehaviorCat;
+ private final boolean mIsBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BlockEvent(
+ @NonNull final String uri,
+ final @CBAntiTracking int atCat,
+ final @CBSafeBrowsing int sbCat,
+ final @CBCookieBehavior int cbCat,
+ final boolean isBlocking) {
+ this.uri = uri;
+ this.mAntiTrackingCat = atCat;
+ this.mSafeBrowsingCat = sbCat;
+ this.mCookieBehaviorCat = cbCat;
+ this.mIsBlocking = isBlocking;
+ }
+
+ /**
+ * The anti-tracking category types of the blocked resource.
+ *
+ * @return One or more of the {@link AntiTracking} flags.
+ */
+ @UiThread
+ public @CBAntiTracking int getAntiTrackingCategory() {
+ return mAntiTrackingCat;
+ }
+
+ /**
+ * The safe browsing category types of the blocked resource.
+ *
+ * @return One or more of the {@link SafeBrowsing} flags.
+ */
+ @UiThread
+ public @CBSafeBrowsing int getSafeBrowsingCategory() {
+ return mSafeBrowsingCat;
+ }
+
+ /**
+ * The cookie types of the blocked resource.
+ *
+ * @return One or more of the {@link CookieBehavior} flags.
+ */
+ @UiThread
+ public @CBCookieBehavior int getCookieBehaviorCategory() {
+ return mCookieBehaviorCat;
+ }
+
+ /* package */ static BlockEvent fromBundle(@NonNull final GeckoBundle bundle) {
+ final String uri = bundle.getString("uri");
+ final String blockedList = bundle.getString("blockedList");
+ final String loadedList = TextUtils.join(",", bundle.getStringArray("loadedLists"));
+ final long error = bundle.getLong("error", 0L);
+ final long category = bundle.getLong("category", 0L);
+
+ final String matchedList = blockedList != null ? blockedList : loadedList;
+
+ // Note: Even if loadedList is non-empty it does not necessarily
+ // mean that the event is not a blocking event.
+ final boolean blocking =
+ (blockedList != null || error != 0L || ContentBlocking.isBlockingGeckoCbCat(category));
+
+ return new BlockEvent(
+ uri,
+ ContentBlocking.atListToAtCat(matchedList)
+ | ContentBlocking.cmListToAtCat(matchedList)
+ | ContentBlocking.fpListToAtCat(matchedList)
+ | ContentBlocking.stListToAtCat(matchedList),
+ ContentBlocking.errorToSbCat(error),
+ ContentBlocking.geckoCatToCbCat(category),
+ blocking);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public boolean isBlocking() {
+ return mIsBlocking;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content blocking events. */
+ public interface Delegate {
+ /**
+ * A content element has been blocked from loading. Set blocked element categories via {@link
+ * GeckoRuntimeSettings} and enable content blocking via {@link GeckoSessionSettings}.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentBlocked(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+
+ /**
+ * A content element that could be blocked has been loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentLoaded(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+ }
+
+ private static final String TEST = "moztest-track-simple";
+ private static final String AD = "ads-track-digest256";
+ private static final String ANALYTIC = "analytics-track-digest256";
+ private static final String SOCIAL = "social-track-digest256";
+ private static final String CONTENT = "content-track-digest256";
+ private static final String CRYPTOMINING = "base-cryptomining-track-digest256";
+ private static final String FINGERPRINTING = "base-fingerprinting-track-digest256";
+ private static final String STP =
+ "social-tracking-protection-facebook-digest256,social-tracking-protection-linkedin-digest256,social-tracking-protection-twitter-digest256";
+
+ /* package */ static @CBSafeBrowsing int sbMalwareToSbCat(final boolean enabled) {
+ return enabled
+ ? (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)
+ : SafeBrowsing.NONE;
+ }
+
+ /* package */ static @CBSafeBrowsing int sbPhishingToSbCat(final boolean enabled) {
+ return enabled ? SafeBrowsing.PHISHING : SafeBrowsing.NONE;
+ }
+
+ /* package */ static boolean catToSbMalware(@CBAntiTracking final int cat) {
+ return (cat & (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)) != 0;
+ }
+
+ /* package */ static boolean catToSbPhishing(@CBAntiTracking final int cat) {
+ return (cat & SafeBrowsing.PHISHING) != 0;
+ }
+
+ /* package */ static String catToAtPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.TEST) != 0) {
+ builder.append(TEST).append(',');
+ }
+ if ((cat & AntiTracking.AD) != 0) {
+ builder.append(AD).append(',');
+ }
+ if ((cat & AntiTracking.ANALYTIC) != 0) {
+ builder.append(ANALYTIC).append(',');
+ }
+ if ((cat & AntiTracking.SOCIAL) != 0) {
+ builder.append(SOCIAL).append(',');
+ }
+ if ((cat & AntiTracking.CONTENT) != 0) {
+ builder.append(CONTENT).append(',');
+ }
+ if (builder.length() == 0) {
+ return "";
+ }
+ // Trim final ','.
+ return builder.substring(0, builder.length() - 1);
+ }
+
+ /* package */ static boolean catToCmPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.CRYPTOMINING) != 0;
+ }
+
+ /* package */ static String catToCmListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.CRYPTOMINING) != 0) {
+ builder.append(CRYPTOMINING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static boolean catToFpPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.FINGERPRINTING) != 0;
+ }
+
+ /* package */ static String catToFpListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.FINGERPRINTING) != 0) {
+ builder.append(FINGERPRINTING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static @CBAntiTracking int fpListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(FINGERPRINTING) != -1) {
+ cat |= AntiTracking.FINGERPRINTING;
+ }
+ return cat;
+ }
+
+ /* package */ static boolean catToStPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.STP) != 0;
+ }
+
+ /* package */ static String catToStListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.STP) != 0) {
+ builder.append(STP).append(",");
+ }
+ if (builder.length() == 0) {
+ return "";
+ }
+ // Trim final ','.
+ return builder.substring(0, builder.length() - 1);
+ }
+
+ /* package */ static @CBAntiTracking int atListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(TEST) != -1) {
+ cat |= AntiTracking.TEST;
+ }
+ if (list.indexOf(AD) != -1) {
+ cat |= AntiTracking.AD;
+ }
+ if (list.indexOf(ANALYTIC) != -1) {
+ cat |= AntiTracking.ANALYTIC;
+ }
+ if (list.indexOf(SOCIAL) != -1) {
+ cat |= AntiTracking.SOCIAL;
+ }
+ if (list.indexOf(CONTENT) != -1) {
+ cat |= AntiTracking.CONTENT;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int cmListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(CRYPTOMINING) != -1) {
+ cat |= AntiTracking.CRYPTOMINING;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int stListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(STP) != -1) {
+ cat |= AntiTracking.STP;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBSafeBrowsing int errorToSbCat(final long error) {
+ // Match flags with XPCOM ErrorList.h.
+ if (error == 0x805D001FL) {
+ return SafeBrowsing.PHISHING;
+ }
+ if (error == 0x805D001EL) {
+ return SafeBrowsing.MALWARE;
+ }
+ if (error == 0x805D0023L) {
+ return SafeBrowsing.UNWANTED;
+ }
+ if (error == 0x805D0026L) {
+ return SafeBrowsing.HARMFUL;
+ }
+ return SafeBrowsing.NONE;
+ }
+
+ // Match flags with nsIWebProgressListener.idl.
+ private static final long STATE_COOKIES_LOADED = 0x8000L;
+ private static final long STATE_COOKIES_LOADED_TRACKER = 0x40000L;
+ private static final long STATE_COOKIES_LOADED_SOCIALTRACKER = 0x80000L;
+ private static final long STATE_COOKIES_BLOCKED_TRACKER = 0x20000000L;
+ private static final long STATE_COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000L;
+ private static final long STATE_COOKIES_BLOCKED_ALL = 0x40000000L;
+ private static final long STATE_COOKIES_BLOCKED_FOREIGN = 0x80L;
+
+ /* package */ static boolean isBlockingGeckoCbCat(final long geckoCat) {
+ return (geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_BLOCKED_ALL
+ | STATE_COOKIES_BLOCKED_FOREIGN))
+ != 0;
+ }
+
+ /* package */ static @CBCookieBehavior int geckoCatToCbCat(final long geckoCat) {
+ if ((geckoCat & STATE_COOKIES_LOADED) != 0) {
+ // We don't know which setting would actually block this cookie, so
+ // we return the most strict value.
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_FOREIGN) != 0) {
+ return CookieBehavior.ACCEPT_FIRST_PARTY;
+ }
+ // If we receive STATE_COOKIES_LOADED_{SOCIAL,}TRACKER we know that this
+ // setting would block this cookie.
+ if ((geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_LOADED_TRACKER
+ | STATE_COOKIES_LOADED_SOCIALTRACKER))
+ != 0) {
+ return CookieBehavior.ACCEPT_NON_TRACKERS;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_ALL) != 0) {
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ // TODO: There are more reasons why cookies may be blocked.
+ return CookieBehavior.ACCEPT_ALL;
+ }
+
+ // Cookie Banner Handling feature.
+
+ public static class CookieBannerMode {
+ /** Do not enable handling cookie banners. */
+ public static final int COOKIE_BANNER_MODE_DISABLED = 0;
+
+ /** Only handle banners where selecting "reject all" is possible. */
+ public static final int COOKIE_BANNER_MODE_REJECT = 1;
+
+ /** Reject cookies when possible otherwise accept the cookies. */
+ public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2;
+
+ /** Detect cookie banners but do not handle them. */
+ public static final int COOKIE_BANNER_MODE_DETECT_ONLY = 3;
+
+ protected CookieBannerMode() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CookieBannerMode.COOKIE_BANNER_MODE_DISABLED,
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT,
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT,
+ CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY
+ })
+ public @interface CBCookieBannerMode {}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java
new file mode 100644
index 0000000000..73238b7eac
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java
@@ -0,0 +1,203 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * ContentBlockingController is used to manage and modify the content blocking exception list. This
+ * list is shared across all sessions.
+ */
+@AnyThread
+public class ContentBlockingController {
+ private static final String LOGTAG = "GeckoContentBlocking";
+
+ public static class Event {
+ // These values must be kept in sync with the corresponding values in
+ // nsIWebProgressListener.idl.
+ /** Tracking content has been blocked from loading. */
+ public static final int BLOCKED_TRACKING_CONTENT = 0x00001000;
+
+ /** Level 1 tracking content has been loaded. */
+ public static final int LOADED_LEVEL_1_TRACKING_CONTENT = 0x00002000;
+
+ /** Level 2 tracking content has been loaded. */
+ public static final int LOADED_LEVEL_2_TRACKING_CONTENT = 0x00100000;
+
+ /** Fingerprinting content has been blocked from loading. */
+ public static final int BLOCKED_FINGERPRINTING_CONTENT = 0x00000040;
+
+ /** Fingerprinting content has been loaded. */
+ public static final int LOADED_FINGERPRINTING_CONTENT = 0x00000400;
+
+ /** Cryptomining content has been blocked from loading. */
+ public static final int BLOCKED_CRYPTOMINING_CONTENT = 0x00000800;
+
+ /** Cryptomining content has been loaded. */
+ public static final int LOADED_CRYPTOMINING_CONTENT = 0x00200000;
+
+ /** Content which appears on the SafeBrowsing list has been blocked from loading. */
+ public static final int BLOCKED_UNSAFE_CONTENT = 0x00004000;
+
+ /**
+ * Performed a storage access check, which usually means something like a cookie or a storage
+ * item was loaded/stored on the current tab. Alternatively this could indicate that something
+ * in the current tab attempted to communicate with its same-origin counterparts in other tabs.
+ */
+ public static final int COOKIES_LOADED = 0x00008000;
+
+ /**
+ * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a
+ * third-party tracker when the active cookie policy imposes restrictions on such content.
+ */
+ public static final int COOKIES_LOADED_TRACKER = 0x00040000;
+
+ /**
+ * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a
+ * third-party social tracker when the active cookie policy imposes restrictions on such
+ * content.
+ */
+ public static final int COOKIES_LOADED_SOCIALTRACKER = 0x00080000;
+
+ /** Rejected for custom site permission. */
+ public static final int COOKIES_BLOCKED_BY_PERMISSION = 0x10000000;
+
+ /** Rejected because the resource is a tracker and cookie policy doesn't allow its loading. */
+ public static final int COOKIES_BLOCKED_TRACKER = 0x20000000;
+
+ /**
+ * Rejected because the resource is a tracker from a social origin and cookie policy doesn't
+ * allow its loading.
+ */
+ public static final int COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000;
+
+ /** Rejected because cookie policy blocks all cookies. */
+ public static final int COOKIES_BLOCKED_ALL = 0x40000000;
+
+ /**
+ * Rejected because the resource is a third-party and cookie policy forces third-party resources
+ * to be partitioned.
+ */
+ public static final int COOKIES_PARTITIONED_FOREIGN = 0x80000000;
+
+ /** Rejected because cookie policy blocks 3rd party cookies. */
+ public static final int COOKIES_BLOCKED_FOREIGN = 0x00000080;
+
+ /** SocialTracking content has been blocked from loading. */
+ public static final int BLOCKED_SOCIALTRACKING_CONTENT = 0x00010000;
+
+ /** SocialTracking content has been loaded. */
+ public static final int LOADED_SOCIALTRACKING_CONTENT = 0x00020000;
+
+ /**
+ * Indicates that content that would have been blocked has instead been replaced with a shim.
+ */
+ public static final int REPLACED_TRACKING_CONTENT = 0x00000010;
+
+ /** Indicates that content that would have been blocked has instead been allowed by a shim. */
+ public static final int ALLOWED_TRACKING_CONTENT = 0x00000020;
+
+ protected Event() {}
+ }
+
+ /** An entry in the content blocking log for a site. */
+ @AnyThread
+ public static class LogEntry {
+ /** Data about why a given entry was blocked. */
+ public static class BlockingData {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ Event.BLOCKED_TRACKING_CONTENT, Event.LOADED_LEVEL_1_TRACKING_CONTENT,
+ Event.LOADED_LEVEL_2_TRACKING_CONTENT, Event.BLOCKED_FINGERPRINTING_CONTENT,
+ Event.LOADED_FINGERPRINTING_CONTENT, Event.BLOCKED_CRYPTOMINING_CONTENT,
+ Event.LOADED_CRYPTOMINING_CONTENT, Event.BLOCKED_UNSAFE_CONTENT,
+ Event.COOKIES_LOADED, Event.COOKIES_LOADED_TRACKER,
+ Event.COOKIES_LOADED_SOCIALTRACKER, Event.COOKIES_BLOCKED_BY_PERMISSION,
+ Event.COOKIES_BLOCKED_TRACKER, Event.COOKIES_BLOCKED_SOCIALTRACKER,
+ Event.COOKIES_BLOCKED_ALL, Event.COOKIES_PARTITIONED_FOREIGN,
+ Event.COOKIES_BLOCKED_FOREIGN, Event.BLOCKED_SOCIALTRACKING_CONTENT,
+ Event.LOADED_SOCIALTRACKING_CONTENT, Event.REPLACED_TRACKING_CONTENT
+ })
+ public @interface LogEvent {}
+
+ /** A category the entry falls under. */
+ public final @LogEvent int category;
+
+ /** Indicates whether or not blocking occured for this category, where applicable. */
+ public final boolean blocked;
+
+ /** The count of consecutive repeated appearances. */
+ public final int count;
+
+ /* package */ BlockingData(final @NonNull GeckoBundle bundle) {
+ category = bundle.getInt("category");
+ blocked = bundle.getBoolean("blocked");
+ count = bundle.getInt("count");
+ }
+
+ protected BlockingData() {
+ category = Event.BLOCKED_TRACKING_CONTENT;
+ blocked = false;
+ count = 0;
+ }
+ }
+
+ /** The origin of this log entry. */
+ public final @NonNull String origin;
+
+ /** The blocking data for this origin, sorted chronologically. */
+ public final @NonNull List<BlockingData> blockingData;
+
+ /* package */ LogEntry(final @NonNull GeckoBundle bundle) {
+ origin = bundle.getString("origin");
+ final GeckoBundle[] data = bundle.getBundleArray("blockData");
+ final ArrayList<BlockingData> dataArray = new ArrayList<BlockingData>(data.length);
+ for (final GeckoBundle b : data) {
+ dataArray.add(new BlockingData(b));
+ }
+ blockingData = Collections.unmodifiableList(dataArray);
+ }
+
+ protected LogEntry() {
+ origin = null;
+ blockingData = null;
+ }
+ }
+
+ private List<LogEntry> logFromBundle(final GeckoBundle value) {
+ final GeckoBundle[] bundles = value.getBundleArray("log");
+ final ArrayList<LogEntry> logArray = new ArrayList<>(bundles.length);
+ for (final GeckoBundle b : bundles) {
+ logArray.add(new LogEntry(b));
+ }
+ return Collections.unmodifiableList(logArray);
+ }
+
+ /**
+ * Get a log of all content blocking information for the site currently loaded by the supplied
+ * {@link GeckoSession}.
+ *
+ * @param session A {@link GeckoSession} for which you want the content blocking log.
+ * @return A {@link GeckoResult} that resolves to the list of content blocking log entries.
+ */
+ @UiThread
+ public @NonNull GeckoResult<List<LogEntry>> getLog(final @NonNull GeckoSession session) {
+ return session
+ .getEventDispatcher()
+ .queryBundle("ContentBlocking:RequestLog")
+ .map(this::logFromBundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
new file mode 100644
index 0000000000..691686e230
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
@@ -0,0 +1,385 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.util.ProxySelector;
+
+/**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> crash
+ * report server.
+ */
+public class CrashReporter {
+ private static final String LOGTAG = "GeckoCrashReporter";
+ private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
+ private static final String PAGE_URL_KEY = "URL";
+ private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash";
+ private static final String NOTES_KEY = "Notes";
+ private static final String SERVER_URL_KEY = "ServerURL";
+ private static final String STACK_TRACES_KEY = "StackTraces";
+ private static final String PRODUCT_NAME_KEY = "ProductName";
+ private static final String PRODUCT_ID_KEY = "ProductID";
+ private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}";
+ private static final List<String> IGNORE_KEYS =
+ Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY);
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intent The Intent sent to the {@link GeckoRuntime} crash handler
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ return sendCrashReport(context, intent.getExtras(), appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intentExtras The Bundle of extras attached to the Intent received by a crash handler.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final Bundle intentExtras,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH));
+ final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH));
+
+ return sendCrashReport(context, dumpFile, extrasFile, appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current {@link Context}
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extrasFile A {@link File} referring to the extras file.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final File minidumpFile,
+ @NonNull final File extrasFile,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName);
+
+ final String url = annotations.optString(SERVER_URL_KEY, null);
+ if (url == null) {
+ return GeckoResult.fromException(new Exception("No server url present"));
+ }
+
+ for (final String key : IGNORE_KEYS) {
+ annotations.remove(key);
+ }
+
+ return sendCrashReport(url, minidumpFile, annotations);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server.
+ *
+ * @param serverURL The URL used to submit the crash report.
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extras A {@link JSONObject} holding the parsed JSON from the extra file.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final String serverURL,
+ @NonNull final File minidumpFile,
+ @NonNull final JSONObject extras)
+ throws IOException, URISyntaxException {
+ Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath());
+
+ HttpURLConnection conn = null;
+ try {
+ final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8"));
+ final URI uri =
+ new URI(
+ url.getProtocol(),
+ url.getUserInfo(),
+ url.getHost(),
+ url.getPort(),
+ url.getPath(),
+ url.getQuery(),
+ url.getRef());
+ conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri);
+ conn.setRequestMethod("POST");
+ final String boundary = generateBoundary();
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ conn.setRequestProperty("Content-Encoding", "gzip");
+
+ final OutputStream os = new GZIPOutputStream(conn.getOutputStream());
+ sendAnnotations(os, boundary, extras);
+ sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
+ os.write(("\r\n--" + boundary + "--\r\n").getBytes());
+ os.flush();
+ os.close();
+
+ BufferedReader br = null;
+ try {
+ br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ final HashMap<String, String> responseMap = readStringsFromReader(br);
+
+ if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ final String crashid = responseMap.get("CrashID");
+ if (crashid != null) {
+ Log.i(LOGTAG, "Successfully sent crash report: " + crashid);
+ return GeckoResult.fromValue(crashid);
+ } else {
+ Log.i(LOGTAG, "Server rejected crash report");
+ }
+ } else {
+ Log.w(
+ LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ try {
+ if (br != null) {
+ br.close();
+ }
+ } catch (final IOException e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ }
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ if (conn != null) {
+ conn.disconnect();
+ }
+ }
+ return GeckoResult.fromException(new Exception("Failed to submit crash report"));
+ }
+
+ private static String computeMinidumpHash(@NonNull final File minidump) throws IOException {
+ MessageDigest md = null;
+ final FileInputStream stream = new FileInputStream(minidump);
+ try {
+ md = MessageDigest.getInstance("SHA-256");
+
+ final byte[] buffer = new byte[4096];
+ int readBytes;
+
+ while ((readBytes = stream.read(buffer)) != -1) {
+ md.update(buffer, 0, readBytes);
+ }
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IOException(e);
+ } finally {
+ stream.close();
+ }
+
+ final byte[] digest = md.digest();
+ final StringBuilder hash = new StringBuilder(64);
+
+ for (int i = 0; i < digest.length; i++) {
+ hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
+ hash.append(Integer.toHexString(digest[i] & 0x0f));
+ }
+
+ return hash.toString();
+ }
+
+ private static HashMap<String, String> readStringsFromReader(final BufferedReader reader)
+ throws IOException {
+ String line;
+ final HashMap<String, String> map = new HashMap<>();
+ while ((line = reader.readLine()) != null) {
+ int equalsPos = -1;
+ if ((equalsPos = line.indexOf('=')) != -1) {
+ final String key = line.substring(0, equalsPos);
+ final String val = unescape(line.substring(equalsPos + 1));
+ map.put(key, val);
+ }
+ }
+ return map;
+ }
+
+ private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
+ final byte[] buffer = new byte[4096];
+ final FileInputStream inputStream = new FileInputStream(filePath);
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ int bytesRead = 0;
+
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+
+ final String contents = new String(outputStream.toByteArray(), "UTF-8");
+ return new JSONObject(contents);
+ }
+
+ private static JSONObject getCrashAnnotations(
+ @NonNull final Context context,
+ @NonNull final File minidump,
+ @NonNull final File extra,
+ @NonNull final String appName)
+ throws IOException {
+ try {
+ final JSONObject annotations = readExtraFile(extra.getPath());
+
+ // Compute the minidump hash and generate the stack traces
+ try {
+ final String hash = computeMinidumpHash(minidump);
+ annotations.put(MINIDUMP_SHA256_HASH_KEY, hash);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
+ }
+
+ annotations.put(PRODUCT_NAME_KEY, appName);
+ annotations.put(PRODUCT_ID_KEY, PRODUCT_ID);
+ annotations.put("Android_Manufacturer", Build.MANUFACTURER);
+ annotations.put("Android_Model", Build.MODEL);
+ annotations.put("Android_Board", Build.BOARD);
+ annotations.put("Android_Brand", Build.BRAND);
+ annotations.put("Android_Device", Build.DEVICE);
+ annotations.put("Android_Display", Build.DISPLAY);
+ annotations.put("Android_Fingerprint", Build.FINGERPRINT);
+ annotations.put("Android_CPU_ABI", Build.CPU_ABI);
+ annotations.put("Android_PackageName", context.getPackageName());
+ try {
+ annotations.put("Android_CPU_ABI2", Build.CPU_ABI2);
+ annotations.put("Android_Hardware", Build.HARDWARE);
+ } catch (final Exception ex) {
+ Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
+ }
+ annotations.put(
+ "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
+
+ return annotations;
+ } catch (final JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String generateBoundary() {
+ // Generate some random numbers to fill out the boundary
+ final int r0 = (int) (Integer.MAX_VALUE * Math.random());
+ final int r1 = (int) (Integer.MAX_VALUE * Math.random());
+ return String.format("---------------------------%08X%08X", r0, r1);
+ }
+
+ private static void sendAnnotations(
+ final OutputStream os, final String boundary, final JSONObject extras) throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\"extra\"; "
+ + "filename=\"extra.json\"\r\n"
+ + "Content-Type: application/json\r\n"
+ + "\r\n")
+ .getBytes());
+ os.write(extras.toString().getBytes("UTF-8"));
+ os.write('\n');
+ }
+
+ private static void sendFile(
+ final OutputStream os, final String boundary, final String name, final File file)
+ throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\""
+ + name
+ + "\"; "
+ + "filename=\""
+ + file.getName()
+ + "\"\r\n"
+ + "Content-Type: application/octet-stream\r\n"
+ + "\r\n")
+ .getBytes());
+ final FileChannel fc = new FileInputStream(file).getChannel();
+ fc.transferTo(0, fc.size(), Channels.newChannel(os));
+ fc.close();
+ }
+
+ private static String unescape(final String string) {
+ return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
new file mode 100644
index 0000000000..fe6b723983
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
@@ -0,0 +1,36 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PACKAGE;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Additional metadata about a deprecation notice. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})
+public @interface DeprecationSchedule {
+ /**
+ * @return Major version when we expect to remove the deprecated member attached to this
+ * annotation.
+ */
+ int version();
+
+ /**
+ * @return Identifier for a deprecation notice. All notices with the same identifier will be
+ * removed at the same time.
+ */
+ String id();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
new file mode 100644
index 0000000000..daf7913498
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
@@ -0,0 +1,483 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Bitmap;
+import android.graphics.Rect;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * Applications use a GeckoDisplay instance to provide {@link GeckoSession} with a {@link Surface}
+ * for displaying content. To ensure drawing only happens on a valid {@link Surface}, {@link
+ * GeckoSession} will only use the provided {@link Surface} after {@link
+ * #surfaceChanged(SurfaceInfo)} is called and before {@link #surfaceDestroyed()} returns.
+ */
+public class GeckoDisplay {
+ private final GeckoSession mSession;
+
+ protected GeckoDisplay(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Wrapper class containing a Surface and associated information that the compositor should render
+ * in to. Should be constructed using {@link SurfaceInfo.Builder}.
+ */
+ public static class SurfaceInfo {
+ /* package */ final @NonNull Surface mSurface;
+ /* package */ final @Nullable SurfaceControl mSurfaceControl;
+ /* package */ final int mLeft;
+ /* package */ final int mTop;
+ /* package */ final int mWidth;
+ /* package */ final int mHeight;
+
+ private SurfaceInfo(final @NonNull Builder builder) {
+ mSurface = builder.mSurface;
+ mSurfaceControl = builder.mSurfaceControl;
+ mLeft = builder.mLeft;
+ mTop = builder.mTop;
+ mWidth = builder.mWidth;
+ mHeight = builder.mHeight;
+ }
+
+ /** Helper class for constructing a {@link SurfaceInfo} object. */
+ public static class Builder {
+ private Surface mSurface;
+ private SurfaceControl mSurfaceControl;
+ private int mLeft;
+ private int mTop;
+ private int mWidth;
+ private int mHeight;
+
+ /**
+ * Creates a new Builder and sets the new Surface.
+ *
+ * @param surface The new Surface.
+ */
+ public Builder(final @NonNull Surface surface) {
+ mSurface = surface;
+ }
+
+ /**
+ * Sets the SurfaceControl associated with the new Surface's SurfaceView.
+ *
+ * <p>This must be called when rendering in to a {@link android.view.SurfaceView} on SDK level
+ * 29 or above. On earlier SDK levels, or when rendering in to something other than a
+ * SurfaceView, this call can be omitted or the value can be null.
+ *
+ * @param surfaceControl The SurfaceControl associated with the new Surface's SurfaceView, or
+ * null.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder surfaceControl(final @Nullable SurfaceControl surfaceControl) {
+ mSurfaceControl = surfaceControl;
+ return this;
+ }
+
+ /**
+ * Sets the new compositor origin offset.
+ *
+ * @param left The compositor origin offset in the X axis. Can not be negative.
+ * @param top The compositor origin offset in the Y axis. Can not be negative.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder offset(final int left, final int top) {
+ mLeft = left;
+ mTop = top;
+ return this;
+ }
+
+ /**
+ * Sets the new surface size.
+ *
+ * @param width New width of the Surface. Can not be negative.
+ * @param height New height of the Surface. Can not be negative.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder size(final int width, final int height) {
+ mWidth = width;
+ mHeight = height;
+ return this;
+ }
+
+ /**
+ * Builds the {@link SurfaceInfo} object with the specified properties.
+ *
+ * @return The SurfaceInfo object
+ */
+ @UiThread
+ public @NonNull SurfaceInfo build() {
+ if ((mLeft < 0) || (mTop < 0)) {
+ throw new IllegalArgumentException("Left and Top offsets can not be negative.");
+ }
+
+ return new SurfaceInfo(this);
+ }
+ }
+ }
+
+ /**
+ * Sets a surface for the compositor render a surface.
+ *
+ * <p>Required call. The display's Surface has been created or changed. Must be called on the
+ * application main thread. GeckoSession may block this call to ensure the Surface is valid while
+ * resuming drawing.
+ *
+ * <p>If rendering in to a {@link android.view.SurfaceView} on SDK level 29 or above, please
+ * ensure that the SurfaceControl field of the {@link SurfaceInfo} object is set.
+ *
+ * @param surfaceInfo Information about the new Surface.
+ */
+ @UiThread
+ public void surfaceChanged(@NonNull final SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceChanged(surfaceInfo);
+ }
+ }
+
+ /**
+ * Removes the current surface registered with the compositor.
+ *
+ * <p>Required call. The display's Surface has been destroyed. Must be called on the application
+ * main thread. GeckoSession may block this call to ensure the Surface is valid while pausing
+ * drawing.
+ */
+ @UiThread
+ public void surfaceDestroyed() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceDestroyed();
+ }
+ }
+
+ /**
+ * Update the position of the surface on the screen.
+ *
+ * <p>Optional call. The display's coordinates on the screen has changed. Must be called on the
+ * application main thread.
+ *
+ * @param left The X coordinate of the display on the screen, in screen pixels.
+ * @param top The Y coordinate of the display on the screen, in screen pixels.
+ */
+ @UiThread
+ public void screenOriginChanged(final int left, final int top) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onScreenOriginChanged(left, top);
+ }
+ }
+
+ /**
+ * Update the safe area insets of the surface on the screen.
+ *
+ * @param left left margin of safe area
+ * @param top top margin of safe area
+ * @param right right margin of safe area
+ * @param bottom bottom margin of safe area
+ */
+ @UiThread
+ public void safeAreaInsetsChanged(
+ final int top, final int right, final int bottom, final int left) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSafeAreaInsetsChanged(top, right, bottom, left);
+ }
+ }
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If the toolbar is dynamic, this function needs to be called with the maximum possible
+ * toolbar height so that Gecko can make the ICB static even during the dynamic toolbar height is
+ * being changed.
+ *
+ * @param height The maximum height of the dynamic toolbar(s).
+ */
+ @UiThread
+ public void setDynamicToolbarMaxHeight(final int height) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setDynamicToolbarMaxHeight(height);
+ }
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the display. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ @UiThread
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setFixedBottomOffset(clippingHeight);
+ }
+ }
+
+ /**
+ * Return whether the display should be pinned on the screen.
+ *
+ * <p>When pinned, the display should not be moved on the screen due to animation, scrolling, etc.
+ * A common reason for the display being pinned is when the user is dragging a selection caret
+ * inside the display; normal user interaction would be disrupted in that case if the display was
+ * moved on screen.
+ *
+ * @return True if display should be pinned on the screen.
+ */
+ @UiThread
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mSession.getDisplay() == this && mSession.shouldPinOnScreen();
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>Returned {@link Bitmap} will have the same dimensions as the {@link Surface} the {@link
+ * GeckoDisplay} is currently using.
+ *
+ * <p>If the {@link GeckoSession#isCompositorReady} is false the {@link GeckoResult} will complete
+ * with an {@link IllegalStateException}.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return screenshot().capture();
+ }
+
+ /** Builder to construct screenshot requests. */
+ public static final class ScreenshotBuilder {
+ private static final int NONE = 0;
+ private static final int SCALE = 1;
+ private static final int ASPECT = 2;
+ private static final int FULL = 3;
+ private static final int RECYCLE = 4;
+
+ private final GeckoSession mSession;
+ private int mOffsetX;
+ private int mOffsetY;
+ private int mSrcWidth;
+ private int mSrcHeight;
+ private int mOutWidth;
+ private int mOutHeight;
+ private int mAspectPreservingWidth;
+ private float mScale;
+ private Bitmap mRecycle;
+ private int mSizeType;
+
+ /* package */ ScreenshotBuilder(final GeckoSession session) {
+ this.mSizeType = NONE;
+ this.mSession = session;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param x Left most pixel of the source region.
+ * @param y Top most pixel of the source region.
+ * @param width Width of the source region in screen pixels
+ * @param height Height of the source region in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(
+ final int x, final int y, final int width, final int height) {
+ mOffsetX = x;
+ mOffsetY = y;
+ mSrcWidth = width;
+ mSrcHeight = height;
+ return this;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param source Region of the screen to capture in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(final @NonNull Rect source) {
+ mOffsetX = source.left;
+ mOffsetY = source.top;
+ mSrcWidth = source.width();
+ mSrcHeight = source.height();
+ return this;
+ }
+
+ private void checkAndSetSizeType(final int sizeType) {
+ if (mSizeType != NONE) {
+ throw new IllegalStateException("Size has already been set.");
+ }
+ mSizeType = sizeType;
+ }
+
+ /**
+ * The width of the bitmap to create when taking the screenshot. The height will be calculated
+ * to match the aspect ratio of the source as closely as possible. The source screenshot will be
+ * scaled into the resulting Bitmap.
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder aspectPreservingSize(final int width) {
+ checkAndSetSizeType(ASPECT);
+ mAspectPreservingWidth = width;
+ return this;
+ }
+
+ /**
+ * The scale of the bitmap relative to the source. The height and width of the output bitmap
+ * will be within one pixel of this multiple of the source dimensions. The source screenshot
+ * will be scaled into the resulting Bitmap.
+ *
+ * @param scale of the result Bitmap relative to the source.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder scale(final float scale) {
+ checkAndSetSizeType(SCALE);
+ mScale = scale;
+ return this;
+ }
+
+ /**
+ * Size of the bitmap to create when taking the screenshot. The source screenshot will be scaled
+ * into the resulting Bitmap
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @param height of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder size(final int width, final int height) {
+ checkAndSetSizeType(FULL);
+ mOutWidth = width;
+ mOutHeight = height;
+ return this;
+ }
+
+ /**
+ * Instead of creating a new Bitmap for the result, the builder will use the passed Bitmap.
+ *
+ * @param bitmap The Bitmap to use in the result.
+ * @return The builder.
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder bitmap(final @Nullable Bitmap bitmap) {
+ checkAndSetSizeType(RECYCLE);
+ mRecycle = bitmap;
+ return this;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the requested portion of the web page currently being rendered
+ * using any parameters specified with the builder.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the requested portion of the visible web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capture() {
+ ThreadUtils.assertOnUiThread();
+ if (!mSession.isCompositorReady()) {
+ throw new IllegalStateException("Compositor must be ready before pixels can be captured");
+ }
+
+ final GeckoResult<Bitmap> result = new GeckoResult<>();
+ final Bitmap target;
+ final Rect rect = new Rect();
+
+ if (mSrcWidth == 0 || mSrcHeight == 0) {
+ // Source is unset or invalid, use defaults.
+ mSession.getSurfaceBounds(rect);
+ mSrcWidth = rect.width();
+ mSrcHeight = rect.height();
+ }
+
+ switch (mSizeType) {
+ case NONE:
+ mOutWidth = mSrcWidth;
+ mOutHeight = mSrcHeight;
+ break;
+ case SCALE:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = (int) (rect.width() * mScale);
+ mOutHeight = (int) (rect.height() * mScale);
+ break;
+ case ASPECT:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = mAspectPreservingWidth;
+ mOutHeight = (int) (rect.height() * (mAspectPreservingWidth / (double) rect.width()));
+ break;
+ case RECYCLE:
+ mOutWidth = mRecycle.getWidth();
+ mOutHeight = mRecycle.getHeight();
+ break;
+ // case FULL does not need to be handled, as width and height are already set.
+ }
+
+ if (mRecycle == null) {
+ try {
+ target = Bitmap.createBitmap(mOutWidth, mOutHeight, Bitmap.Config.ARGB_8888);
+ } catch (final Throwable e) {
+ if (e instanceof NullPointerException || e instanceof OutOfMemoryError) {
+ return GeckoResult.fromException(
+ new OutOfMemoryError("Not enough memory to allocate for bitmap"));
+ }
+ return GeckoResult.fromException(new Throwable("Failed to create bitmap", e));
+ }
+ } else {
+ target = mRecycle;
+ }
+
+ mSession.mCompositor.requestScreenPixels(
+ result, target, mOffsetX, mOffsetY, mSrcWidth, mSrcHeight, mOutWidth, mOutHeight);
+
+ return result;
+ }
+ }
+
+ /**
+ * Creates a new screenshot builder.
+ *
+ * @return The new {@link ScreenshotBuilder}
+ */
+ @UiThread
+ public @NonNull ScreenshotBuilder screenshot() {
+ return new ScreenshotBuilder(mSession);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
new file mode 100644
index 0000000000..2d24dcbe93
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
@@ -0,0 +1,2616 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.method.KeyListener;
+import android.text.method.TextKeyListener;
+import android.text.style.CharacterStyle;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.GeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState;
+
+/**
+ * GeckoEditable implements only some functions of Editable The field mText contains the actual
+ * underlying SpannableStringBuilder/Editable that contains our text.
+ */
+/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub
+ implements InvocationHandler, Editable, SessionTextInput.EditableClient {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoEditable";
+
+ // Filters to implement Editable's filtering functionality
+ private InputFilter[] mFilters;
+
+ /**
+ * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing
+ * objects around via JNI seems to confuse the GC into thinking we have a native GC root.
+ */
+ /* package */ final WeakReference<GeckoSession> mSession;
+
+ private final AsyncText mText;
+ private final Editable mProxy;
+ private final ConcurrentLinkedQueue<Action> mActions;
+ private KeyCharacterMap mKeyMap;
+
+ // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables
+ // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to
+ // The two can be different when switching from one handler to another
+ private Handler mIcRunHandler;
+ private Handler mIcPostHandler;
+
+ // Parent process child used as a default for key events.
+ /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread.
+ // Parent or content process child that has the focus.
+ /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread.
+ /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread.
+ /* package */ SessionTextInput.EditableListener mListener;
+
+ /* package */ boolean mInBatchMode; // Used by IC thread
+ /* package */ boolean mNeedSync; // Used by IC thread
+ // Gecko side needs an updated composition from Java;
+ private boolean mNeedUpdateComposition; // Used by IC thread
+ private boolean mSuppressKeyUp; // Used by IC thread
+
+ @IMEState
+ private int mIMEState = // Used by IC thread.
+ SessionTextInput.EditableListener.IME_STATE_DISABLED;
+
+ private String mIMETypeHint = ""; // Used by IC/UI thread.
+ private String mIMEModeHint = ""; // Used by IC thread.
+ private String mIMEActionHint = ""; // Used by IC thread.
+ private String mIMEAutocapitalize = ""; // Used by IC thread.
+ @IMEContextFlags private int mIMEFlags; // Used by IC thread.
+
+ private boolean mIgnoreSelectionChange; // Used by Gecko thread
+ // Combined offsets from the previous batch of onTextChange calls; valid
+ // between the onTextChange calls and the next onSelectionChange call.
+ private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread
+ private int mLastTextChangeOldEnd = -1; // Used by Gecko thread
+ private int mLastTextChangeNewEnd = -1; // Used by Gecko thread
+ private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread
+
+ // Prevent showSoftInput and hideSoftInput from being called multiple times in a row,
+ // including reentrant calls on some devices. Used by UI/IC thread.
+ /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger();
+
+ private static final int IME_RANGE_CARETPOSITION = 1;
+ private static final int IME_RANGE_RAWINPUT = 2;
+ private static final int IME_RANGE_SELECTEDRAWTEXT = 3;
+ private static final int IME_RANGE_CONVERTEDTEXT = 4;
+ private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5;
+
+ private static final int IME_RANGE_LINE_NONE = 0;
+ private static final int IME_RANGE_LINE_SOLID = 1;
+ private static final int IME_RANGE_LINE_DOTTED = 2;
+ private static final int IME_RANGE_LINE_DASHED = 3;
+ private static final int IME_RANGE_LINE_DOUBLE = 4;
+ private static final int IME_RANGE_LINE_WAVY = 5;
+
+ private static final int IME_RANGE_UNDERLINE = 1;
+ private static final int IME_RANGE_FORECOLOR = 2;
+ private static final int IME_RANGE_BACKCOLOR = 4;
+ private static final int IME_RANGE_LINECOLOR = 8;
+
+ private void onKeyEvent(
+ final IGeckoEditableChild child,
+ final KeyEvent event,
+ final int action,
+ final int savedMetaState,
+ final boolean isSynthesizedImeKey)
+ throws RemoteException {
+ // Use a separate action argument so we can override the key's original action,
+ // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate
+ // a new key event just to change its action field.
+ //
+ // Normally we expect event.getMetaState() to reflect the current meta-state; however,
+ // some software-generated key events may not have event.getMetaState() set, e.g. key
+ // events from Swype. Therefore, it's necessary to combine the key's meta-states
+ // with the meta-states that we keep separately in KeyListener
+ final int metaState = event.getMetaState() | savedMetaState;
+ final int unmodifiedMetaState =
+ metaState & ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK);
+
+ final int unicodeChar = event.getUnicodeChar(metaState);
+ final int unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState);
+ final int domPrintableKeyValue =
+ unicodeChar >= ' '
+ ? unicodeChar
+ : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0;
+
+ // If a modifier (e.g. meta key) caused a different character to be entered, we
+ // drop that modifier from the metastate for the generated keypress event.
+ final int keyPressMetaState =
+ (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar)
+ ? unmodifiedMetaState
+ : metaState;
+
+ // For synthesized keys, ignore modifier metastates from the synthesized event,
+ // because the synthesized modifier metastates don't reflect the actual state of
+ // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is
+ // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key
+ // is not actually pressed in this case.
+ final int keyUpDownMetaState =
+ isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState;
+
+ child.onKeyEvent(
+ action,
+ event.getKeyCode(),
+ event.getScanCode(),
+ keyUpDownMetaState,
+ keyPressMetaState,
+ event.getEventTime(),
+ domPrintableKeyValue,
+ event.getRepeatCount(),
+ event.getFlags(),
+ isSynthesizedImeKey,
+ event);
+ }
+
+ /**
+ * Class that encapsulates asynchronous text editing. There are two copies of the text, a current
+ * copy and a shadow copy. Both can be modified independently through the current*** and shadow***
+ * methods, respectively. The current copy can only be modified on the Gecko side and reflects the
+ * authoritative version of the text. The shadow copy can only be modified on the IC side and
+ * reflects what we think the current text is. Periodically, the shadow copy can be synced to the
+ * current copy through syncShadowText, so the shadow copy once again refers to the same text as
+ * the current copy.
+ */
+ private final class AsyncText {
+ // The current text is the update-to-date version of the text, and is only updated
+ // on the Gecko side.
+ private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder();
+ // Track changes on the current side for syncing purposes.
+ // Start of the changed range in current text since last sync.
+ private int mCurrentStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in current text since last sync.
+ private int mCurrentOldEnd;
+ // End of the changed range (after the change) in current text since last sync.
+ private int mCurrentNewEnd;
+ // Track selection changes separately.
+ private boolean mCurrentSelectionChanged;
+
+ // The shadow text is what we think the current text is on the Java side, and is
+ // periodically synced with the current text.
+ private final SpannableStringBuilder mShadowText = new SpannableStringBuilder();
+ // Track changes on the shadow side for syncing purposes.
+ // Start of the changed range in shadow text since last sync.
+ private int mShadowStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in shadow text since last sync.
+ private int mShadowOldEnd;
+ // End of the changed range (after the change) in shadow text since last sync.
+ private int mShadowNewEnd;
+
+ private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mCurrentStart = Math.min(mCurrentStart, start);
+ mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd);
+ mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd);
+ }
+
+ public synchronized void currentReplace(
+ final int start, final int end, final CharSequence newText) {
+ // On Gecko or binder thread.
+ mCurrentText.replace(start, end, newText);
+ addCurrentChangeLocked(start, end, start + newText.length());
+ }
+
+ public synchronized void currentSetSelection(final int start, final int end) {
+ // On Gecko or binder thread.
+ Selection.setSelection(mCurrentText, start, end);
+ mCurrentSelectionChanged = true;
+ }
+
+ public synchronized void currentSetSpan(
+ final Object obj, final int start, final int end, final int flags) {
+ // On Gecko or binder thread.
+ mCurrentText.setSpan(obj, start, end, flags);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ public synchronized void currentRemoveSpan(final Object obj) {
+ // On Gecko or binder thread.
+ if (obj == null) {
+ mCurrentText.clearSpans();
+ addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length());
+ return;
+ }
+ final int start = mCurrentText.getSpanStart(obj);
+ final int end = mCurrentText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mCurrentText.removeSpan(obj);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the current*** methods.
+ public Spanned getCurrentText() {
+ // On Gecko or binder thread.
+ return mCurrentText;
+ }
+
+ private void addShadowChange(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mShadowStart = Math.min(mShadowStart, start);
+ mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd);
+ mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd);
+ }
+
+ public void shadowReplace(final int start, final int end, final CharSequence newText) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.replace(start, end, newText);
+ addShadowChange(start, end, start + newText.length());
+ }
+
+ public void shadowSetSpan(final Object obj, final int start, final int end, final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.setSpan(obj, start, end, flags);
+ addShadowChange(start, end, end);
+ }
+
+ public void shadowRemoveSpan(final Object obj) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ if (obj == null) {
+ mShadowText.clearSpans();
+ addShadowChange(0, mShadowText.length(), mShadowText.length());
+ return;
+ }
+ final int start = mShadowText.getSpanStart(obj);
+ final int end = mShadowText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mShadowText.removeSpan(obj);
+ addShadowChange(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the shadow*** methods.
+ public Spanned getShadowText() {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ return mShadowText;
+ }
+
+ /**
+ * Check whether we are currently discarding the composition. It means that shadow text has
+ * composition, but current text has no composition. So syncShadowText will discard composition.
+ *
+ * @return true if discarding composition
+ */
+ private boolean isDiscardingComposition() {
+ if (!isComposing(mShadowText)) {
+ return false;
+ }
+
+ return !isComposing(mCurrentText);
+ }
+
+ public synchronized void syncShadowText(final SessionTextInput.EditableListener listener) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) {
+ // Still check selection changes.
+ if (!mCurrentSelectionChanged) {
+ return;
+ }
+ final int start = Selection.getSelectionStart(mCurrentText);
+ final int end = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, start, end);
+ mCurrentSelectionChanged = false;
+
+ if (listener != null) {
+ listener.onSelectionChange();
+ }
+ return;
+ }
+
+ if (isDiscardingComposition()) {
+ if (listener != null) {
+ listener.onDiscardComposition();
+ }
+ }
+
+ // Copy the portion of the current text that has changed over to the shadow
+ // text, with consideration for any concurrent changes in the shadow text.
+ final int start = Math.min(mShadowStart, mCurrentStart);
+ final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd);
+ final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd);
+
+ // Remove existing spans that may no longer be in the new text.
+ Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class);
+ for (final Object span : spans) {
+ mShadowText.removeSpan(span);
+ }
+
+ mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd);
+
+ // The replace() call may not have copied all affected spans, so we re-copy all the
+ // spans manually just in case. Expand bounds by 1 so we get all the spans.
+ spans =
+ mCurrentText.getSpans(
+ Math.max(start - 1, 0),
+ Math.min(currentEnd + 1, mCurrentText.length()),
+ Object.class);
+ for (final Object span : spans) {
+ if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) {
+ continue;
+ }
+ mShadowText.setSpan(
+ span,
+ mCurrentText.getSpanStart(span),
+ mCurrentText.getSpanEnd(span),
+ mCurrentText.getSpanFlags(span));
+ }
+
+ // SpannableStringBuilder has some internal logic to fix up selections, but we
+ // don't want that, so we always fix up the selection a second time.
+ final int selStart = Selection.getSelectionStart(mCurrentText);
+ final int selEnd = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, selStart, selEnd);
+
+ if (DEBUG && !checkEqualText(mShadowText, mCurrentText)) {
+ // Sanity check.
+ throw new IllegalStateException(
+ "Failed to sync: "
+ + mShadowStart
+ + '-'
+ + mShadowOldEnd
+ + '-'
+ + mShadowNewEnd
+ + '/'
+ + mCurrentStart
+ + '-'
+ + mCurrentOldEnd
+ + '-'
+ + mCurrentNewEnd);
+ }
+
+ if (listener != null) {
+ // Call onTextChange after selection fix-up but before we call
+ // onSelectionChange.
+ listener.onTextChange();
+
+ if (mCurrentSelectionChanged
+ || (mCurrentOldEnd != mCurrentNewEnd
+ && (selStart >= mCurrentStart || selEnd >= mCurrentStart))) {
+ listener.onSelectionChange();
+ }
+ }
+
+ // These values ensure the first change is properly added.
+ mCurrentStart = mShadowStart = Integer.MAX_VALUE;
+ mCurrentOldEnd = mShadowOldEnd = 0;
+ mCurrentNewEnd = mShadowNewEnd = 0;
+ mCurrentSelectionChanged = false;
+ }
+ }
+
+ private static boolean checkEqualText(final Spanned s1, final Spanned s2) {
+ if (!s1.toString().equals(s2.toString())) {
+ return false;
+ }
+
+ final Object[] o1s = s1.getSpans(0, s1.length(), Object.class);
+ final Object[] o2s = s2.getSpans(0, s2.length(), Object.class);
+
+ if (o1s.length != o2s.length) {
+ return false;
+ }
+
+ o1loop:
+ for (final Object o1 : o1s) {
+ for (final Object o2 : o2s) {
+ if (o1 != o2) {
+ continue;
+ }
+ if (s1.getSpanStart(o1) != s2.getSpanStart(o2)
+ || s1.getSpanEnd(o1) != s2.getSpanEnd(o2)
+ || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) {
+ return false;
+ }
+ continue o1loop;
+ }
+ // o1 not found in o2s.
+ return false;
+ }
+ return true;
+ }
+
+ /* An action that alters the Editable
+
+ Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko
+ thread, the action stays on top of mActions queue. After the Gecko event is processed and
+ replied, the action is removed from the queue
+ */
+ private static final class Action {
+ // For input events (keypress, etc.); use with onImeSynchronize
+ static final int TYPE_EVENT = 0;
+ // For Editable.replace() call; use with onImeReplaceText
+ static final int TYPE_REPLACE_TEXT = 1;
+ // For Editable.setSpan() call; use with onImeSynchronize
+ static final int TYPE_SET_SPAN = 2;
+ // For Editable.removeSpan() call; use with onImeSynchronize
+ static final int TYPE_REMOVE_SPAN = 3;
+ // For switching handler; use with onImeSynchronize
+ static final int TYPE_SET_HANDLER = 4;
+
+ final int mType;
+ int mStart;
+ int mEnd;
+ CharSequence mSequence;
+ Object mSpanObject;
+ int mSpanFlags;
+ Handler mHandler;
+
+ Action(final int type) {
+ mType = type;
+ }
+
+ static Action newReplaceText(final CharSequence text, final int start, final int end) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid replace text offsets");
+ }
+
+ final Action action = new Action(TYPE_REPLACE_TEXT);
+ action.mSequence = text;
+ action.mStart = start;
+ action.mEnd = end;
+ return action;
+ }
+
+ static Action newSetSpan(final Object object, final int start, final int end, final int flags) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid span offsets");
+ }
+ final Action action = new Action(TYPE_SET_SPAN);
+ action.mSpanObject = object;
+ action.mStart = start;
+ action.mEnd = end;
+ action.mSpanFlags = flags;
+ return action;
+ }
+
+ static Action newRemoveSpan(final Object object) {
+ final Action action = new Action(TYPE_REMOVE_SPAN);
+ action.mSpanObject = object;
+ return action;
+ }
+
+ static Action newSetHandler(final Handler handler) {
+ final Action action = new Action(TYPE_SET_HANDLER);
+ action.mHandler = handler;
+ return action;
+ }
+ }
+
+ private void icOfferAction(final Action action) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "offer: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ mText.shadowSetSpan(
+ action.mSpanObject, action.mStart,
+ action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject);
+ mText.shadowRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_REPLACE_TEXT:
+ mText.shadowReplace(action.mStart, action.mEnd, action.mSequence);
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+
+ // Always perform actions on the shadow text side above, so we still act as a
+ // valid Editable object, but don't send the actions to Gecko below if we haven't
+ // been focused or initialized, or we've been destroyed.
+ if (mFocusedChild == null || mListener == null) {
+ return;
+ }
+
+ mActions.offer(action);
+
+ try {
+ icPerformAction(action);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ // Undo the offer.
+ mActions.remove(action);
+ }
+ }
+
+ private void icPerformAction(final Action action) throws RemoteException {
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ mFocusedChild.onImeSynchronize();
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0
+ || action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END);
+
+ action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd);
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REMOVE_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0;
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REPLACE_TEXT:
+ // Always sync text after a replace action, so that if the Gecko
+ // text is not changed, we will revert the shadow text to before.
+ mNeedSync = true;
+
+ // Because we get composition styling here essentially for free,
+ // we don't need to check if we're in batch mode.
+ if (icMaybeSendComposition(action.mSequence, SEND_COMPOSITION_USE_ENTIRE_TEXT)) {
+ mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString());
+ break;
+ }
+
+ // Since we don't have a composition, we can try sending key events.
+ sendCharKeyEvents(action);
+
+ // onImeReplaceText will set the selection range. But we don't
+ // know whether event state manager is processing text and
+ // selection. So current shadow may not be synchronized with
+ // Gecko's text and selection. So we have to avoid unnecessary
+ // selection update.
+ final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText());
+ final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText());
+ int actionStart = action.mStart;
+ int actionEnd = action.mEnd;
+ // If action range is collapsed and selection of shadow text is
+ // collapsed, we may try to dispatch keypress on current caret
+ // position. Action range is previous range before dispatching
+ // keypress, and shadow range is new range after dispatching
+ // it.
+ if (action.mStart == action.mEnd
+ && selStartOnShadow == selEndOnShadow
+ && action.mStart == selStartOnShadow + action.mSequence.toString().length()) {
+ // Replacing range is same value as current shadow's selection.
+ // So it is unnecessary to update the selection on Gecko.
+ actionStart = -1;
+ actionEnd = -1;
+ }
+ mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString());
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+ }
+
+ private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) {
+ try {
+ if (mKeyMap == null) {
+ mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+ }
+ } catch (final Exception e) {
+ // KeyCharacterMap.UnavailableException is not found on Gingerbread;
+ // besides, it seems like HC and ICS will throw something other than
+ // KeyCharacterMap.UnavailableException; so use a generic Exception here
+ return null;
+ }
+ final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray());
+ if (keyEvents == null || keyEvents.length == 0) {
+ return null;
+ }
+ return keyEvents;
+ }
+
+ private void sendCharKeyEvents(final Action action) throws RemoteException {
+ if (action.mSequence.length() != 1
+ || (action.mSequence instanceof Spannable
+ && ((Spannable) action.mSequence).nextSpanTransition(-1, Integer.MAX_VALUE, null)
+ < Integer.MAX_VALUE)) {
+ // Spans are not preserved when we use key events,
+ // so we need the sequence to not have any spans
+ return;
+ }
+ final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence);
+ if (keyEvents == null) {
+ return;
+ }
+ for (final KeyEvent event : keyEvents) {
+ if (KeyEvent.isModifierKey(event.getKeyCode())) {
+ continue;
+ }
+ if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) {
+ continue;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "sending: " + event);
+ }
+ onKeyEvent(
+ mFocusedChild,
+ event,
+ event.getAction(),
+ /* metaState */ 0, /* isSynthesizedImeKey */
+ true);
+ }
+ }
+
+ public GeckoEditable(@NonNull final GeckoSession session) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mSession = new WeakReference<>(session);
+ mText = new AsyncText();
+ mActions = new ConcurrentLinkedQueue<Action>();
+
+ final Class<?>[] PROXY_INTERFACES = {Editable.class};
+ mProxy =
+ (Editable) Proxy.newProxyInstance(Editable.class.getClassLoader(), PROXY_INTERFACES, this);
+
+ mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler();
+ }
+
+ @Override // IGeckoEditableParent
+ public void setDefaultChild(final IGeckoEditableChild child) {
+ if (DEBUG) {
+ // On Gecko or binder thread.
+ Log.d(LOGTAG, "setDefaultEditableChild " + child);
+ }
+ mDefaultChild = child;
+ }
+
+ public void setListener(final SessionTextInput.EditableListener newListener) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ Log.d(LOGTAG, "setListener " + newListener);
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onViewChange (set listener)");
+ }
+
+ mListener = newListener;
+ }
+ });
+ }
+
+ private boolean onIcThread() {
+ return mIcRunHandler.getLooper() == Looper.myLooper();
+ }
+
+ private void assertOnIcThread() {
+ ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW);
+ }
+
+ private Object getField(final Object obj, final String field, final Object def) {
+ try {
+ return obj.getClass().getField(field).get(obj);
+ } catch (final Exception e) {
+ return def;
+ }
+ }
+
+ // Flags for icMaybeSendComposition
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SEND_COMPOSITION_USE_ENTIRE_TEXT,
+ SEND_COMPOSITION_NOTIFY_GECKO,
+ SEND_COMPOSITION_KEEP_CURRENT
+ })
+ public @interface CompositionFlags {}
+
+ // If text has composing spans, treat the entire text as a Gecko composition,
+ // instead of just the spanned part.
+ private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0;
+ // Notify Gecko of the new composition ranges;
+ // otherwise, the caller is responsible for notifying Gecko.
+ private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1;
+ // Keep the current composition when updating;
+ // composition is not updated if there is no current composition.
+ private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2;
+
+ /**
+ * Send composition ranges to Gecko if the text has composing spans.
+ *
+ * @param sequence Text with possible composing spans
+ * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition.
+ * @return Whether there was a composition
+ */
+ private boolean icMaybeSendComposition(
+ final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException {
+ final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0;
+ final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0;
+ final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0;
+ final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0;
+
+ if (!keepCurrent) {
+ // If keepCurrent is true, the composition may not actually be updated;
+ // so we may still need to update the composition in the future.
+ mNeedUpdateComposition = false;
+ }
+
+ int selStart = Selection.getSelectionStart(sequence);
+ int selEnd = Selection.getSelectionEnd(sequence);
+
+ if (sequence instanceof Spanned) {
+ final Spanned text = (Spanned) sequence;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ boolean found = false;
+ int composingStart = useEntireText ? 0 : Integer.MAX_VALUE;
+ int composingEnd = useEntireText ? text.length() : 0;
+
+ // Find existence and range of any composing spans (spans with the
+ // SPAN_COMPOSING flag set).
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) {
+ continue;
+ }
+ found = true;
+ if (useEntireText) {
+ break;
+ }
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+
+ if (useEntireText && (selStart < 0 || selEnd < 0)) {
+ selStart = composingEnd;
+ selEnd = composingEnd;
+ }
+
+ if (found) {
+ if (selStart < composingStart || selEnd > composingEnd) {
+ // GBoard will set caret position that is out of composing
+ // range. Unfortunately, Gecko doesn't support this caret
+ // position. So we shouldn't set composing range data now.
+ // But this is temporary composing range, then GBoard will
+ // set valid range soon.
+ if (DEBUG) {
+ final StringBuilder sb =
+ new StringBuilder("icSendComposition(): invalid caret position. ");
+ sb.append("composing = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd);
+ Log.d(LOGTAG, sb.toString());
+ }
+ } else {
+ icSendComposition(text, selStart, selEnd, composingStart, composingEnd);
+ if (notifyGecko) {
+ mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags);
+ }
+ return true;
+ }
+ }
+ }
+
+ if (notifyGecko) {
+ // Set the selection by using a composition without ranges.
+ final Spanned currentText = mText.getCurrentText();
+ if (Selection.getSelectionStart(currentText) != selStart
+ || Selection.getSelectionEnd(currentText) != selEnd) {
+ // Gecko's selection is different of requested selection, so
+ // we have to set selection of Gecko side.
+ // If selection is same, it is unnecessary to update it.
+ // This may be race with Gecko's updating selection via
+ // JavaScript or keyboard event. But we don't know whether
+ // Gecko is during updating selection.
+ mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "icSendComposition(): no composition");
+ }
+ return false;
+ }
+
+ private void icSendComposition(
+ final Spanned text,
+ final int selStart,
+ final int selEnd,
+ final int composingStart,
+ final int composingEnd)
+ throws RemoteException {
+ if (DEBUG) {
+ assertOnIcThread();
+ final StringBuilder sb = new StringBuilder("icSendComposition(");
+ sb.append("\"")
+ .append(text)
+ .append("\"")
+ .append(", range = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (selEnd >= composingStart && selEnd <= composingEnd) {
+ mFocusedChild.onImeAddCompositionRange(
+ selEnd - composingStart,
+ selEnd - composingStart,
+ IME_RANGE_CARETPOSITION,
+ 0,
+ 0,
+ false,
+ 0,
+ 0,
+ 0);
+ }
+
+ int rangeStart = composingStart;
+ final TextPaint tp = new TextPaint();
+ final TextPaint emptyTp = new TextPaint();
+ // set initial foreground color to 0, because we check for tp.getColor() == 0
+ // below to decide whether to pass a foreground color to Gecko
+ emptyTp.setColor(0);
+ do {
+ final int rangeType;
+ int rangeStyles = 0;
+ int rangeLineStyle = IME_RANGE_LINE_NONE;
+ boolean rangeBoldLine = false;
+ int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0;
+ int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class);
+
+ if (selStart > rangeStart && selStart < rangeEnd) {
+ rangeEnd = selStart;
+ } else if (selEnd > rangeStart && selEnd < rangeEnd) {
+ rangeEnd = selEnd;
+ }
+ final CharacterStyle[] styleSpans = text.getSpans(rangeStart, rangeEnd, CharacterStyle.class);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + rangeStart + "-" + rangeEnd);
+ }
+
+ if (styleSpans.length == 0) {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDRAWTEXT
+ : IME_RANGE_RAWINPUT;
+ } else {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDCONVERTEDTEXT
+ : IME_RANGE_CONVERTEDTEXT;
+ tp.set(emptyTp);
+ for (final CharacterStyle span : styleSpans) {
+ span.updateDrawState(tp);
+ }
+ int tpUnderlineColor = 0;
+ float tpUnderlineThickness = 0.0f;
+
+ // These TextPaint fields only exist on Android ICS+ and are not in the SDK.
+ tpUnderlineColor = (Integer) getField(tp, "underlineColor", 0);
+ tpUnderlineThickness = (Float) getField(tp, "underlineThickness", 0.0f);
+ if (tpUnderlineColor != 0) {
+ rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR;
+ rangeLineColor = tpUnderlineColor;
+ // Approximately translate underline thickness to what Gecko understands
+ if (tpUnderlineThickness <= 0.5f) {
+ rangeLineStyle = IME_RANGE_LINE_DOTTED;
+ } else {
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ if (tpUnderlineThickness >= 2.0f) {
+ rangeBoldLine = true;
+ }
+ }
+ } else if (tp.isUnderlineText()) {
+ rangeStyles |= IME_RANGE_UNDERLINE;
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ }
+ if (tp.getColor() != 0) {
+ rangeStyles |= IME_RANGE_FORECOLOR;
+ rangeForeColor = tp.getColor();
+ }
+ if (tp.bgColor != 0) {
+ rangeStyles |= IME_RANGE_BACKCOLOR;
+ rangeBackColor = tp.bgColor;
+ }
+ }
+ mFocusedChild.onImeAddCompositionRange(
+ rangeStart - composingStart,
+ rangeEnd - composingStart,
+ rangeType,
+ rangeStyles,
+ rangeLineStyle,
+ rangeBoldLine,
+ rangeForeColor,
+ rangeBackColor,
+ rangeLineColor);
+ rangeStart = rangeEnd;
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ " added "
+ + rangeType
+ + " : "
+ + Integer.toHexString(rangeStyles)
+ + " : "
+ + Integer.toHexString(rangeForeColor)
+ + " : "
+ + Integer.toHexString(rangeBackColor));
+ }
+ } while (rangeStart < composingEnd);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void sendKeyEvent(
+ final @Nullable View view, final int action, final @NonNull KeyEvent event) {
+ final Editable editable = mProxy;
+ final KeyListener keyListener = TextKeyListener.getInstance();
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+
+ // We only let TextKeyListener do UI things on the UI thread.
+ final View v = ThreadUtils.isOnUiThread() ? view : null;
+ final int keyCode = translatedEvent.getKeyCode();
+ final boolean handled;
+
+ if (shouldSkipKeyListener(keyCode, translatedEvent)) {
+ handled = false;
+ } else if (action == KeyEvent.ACTION_DOWN) {
+ setSuppressKeyUp(true);
+ handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent);
+ } else if (action == KeyEvent.ACTION_UP) {
+ handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent);
+ } else {
+ handled = keyListener.onKeyOther(v, editable, translatedEvent);
+ }
+
+ if (!handled) {
+ sendKeyEvent(translatedEvent, action, TextKeyListener.getMetaState(editable));
+ }
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ if (!handled) {
+ // Usually, the down key listener call above adjusts meta states for us.
+ // However, if the call didn't handle the event, we have to manually
+ // adjust meta states so the meta states remain consistent.
+ TextKeyListener.adjustMetaAfterKeypress(editable);
+ }
+ setSuppressKeyUp(false);
+ }
+ }
+
+ private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final int metaState) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")");
+ }
+ /*
+ We are actually sending two events to Gecko here,
+ 1. Event from the event parameter (key event)
+ 2. Sync event from the icOfferAction call
+ The first event is a normal event that does not reply back to us,
+ the second sync event will have a reply, during which we see that there is a pending
+ event-type action, and update the shadow text accordingly.
+ */
+ try {
+ if (mFocusedChild == null) {
+ if (mDefaultChild == null) {
+ Log.w(LOGTAG, "Discarding key event");
+ return;
+ }
+ // Not focused; send simple key event to chrome window.
+ onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ return;
+ }
+
+ // Most IMEs handle arrow key, then set caret position. But GBoard
+ // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right
+ // even if having IME composition.
+ // Since Gecko doesn't dispatch keypress during IME composition due to
+ // DOM UI events spec, we have to emulate arrow key's behaviour.
+ boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN;
+ if (isComposing(mText.getShadowText())
+ && action == KeyEvent.ACTION_DOWN
+ && event.hasNoModifiers()) {
+ final int selStart = Selection.getSelectionStart(mText.getShadowText());
+ final int selEnd = Selection.getSelectionEnd(mText.getShadowText());
+ if (selStart == selEnd) {
+ // If dispatching arrow left/right key into composition,
+ // we update IME caret.
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (getComposingStart(mText.getShadowText()) < selStart) {
+ Selection.setSelection(getEditable(), selStart - 1, selStart - 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selStart == 0) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (getComposingEnd(mText.getShadowText()) > selEnd) {
+ Selection.setSelection(getEditable(), selStart + 1, selStart + 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selEnd == mText.getShadowText().length()) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ }
+ }
+ }
+
+ // Focused; key event may go to chrome window or to content window.
+ if (mNeedUpdateComposition) {
+ icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO);
+ }
+
+ if (commitCompositionBeforeKeyEvent) {
+ mFocusedChild.onImeRequestCommit();
+ }
+ onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ icOfferAction(new Action(Action.TYPE_EVENT));
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) {
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ return true;
+ }
+
+ // Preserve enter and tab keys for the browser
+ if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_TAB) {
+ return true;
+ }
+ // BaseKeyListener returns false even if it handled these keys for us,
+ // so we skip the key listener entirely and handle these ourselves
+ if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+ return true;
+ }
+ return false;
+ }
+
+ private static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final KeyEvent event) {
+ // The cross and circle button mappings may be swapped in the different regions so
+ // determine if they are swapped so the proper key codes can be mapped to the keys
+ final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped();
+
+ int translatedKeyCode = keyCode;
+ // If a Sony Xperia, remap the cross and circle buttons to buttons
+ // A and B for the gamepad API
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BACK:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B);
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A);
+ break;
+
+ default:
+ return event;
+ }
+
+ return new KeyEvent(event.getAction(), translatedKeyCode);
+ }
+
+ private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611;
+
+ private static boolean isSonyXperiaGamepadKeyEvent(final KeyEvent event) {
+ return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID
+ && "Sony Ericsson".equals(Build.MANUFACTURER)
+ && ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL)));
+ }
+
+ private static boolean areSonyXperiaGamepadKeysSwapped() {
+ // The cross and circle buttons on Sony Xperia phones are swapped
+ // in different regions
+ // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/
+ final char DEFAULT_O_BUTTON_LABEL = 0x25CB;
+
+ boolean swapped = false;
+ final int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int i = 0; deviceIds != null && i < deviceIds.length; i++) {
+ final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]);
+ if (keyCharacterMap != null
+ && DEFAULT_O_BUTTON_LABEL
+ == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) {
+ swapped = true;
+ break;
+ }
+ }
+ return swapped;
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ if (isSonyXperiaGamepadKeyEvent(event)) {
+ return translateSonyXperiaGamepadKeys(keyCode, event);
+ }
+ return event;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Editable getEditable() {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "getEditable() called on non-IC thread");
+ }
+ return null;
+ }
+ if (mListener == null) {
+ // We haven't initialized or we've been destroyed.
+ return null;
+ }
+ return mProxy;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void setBatchMode(final boolean inBatchMode) {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "setBatchMode() called on non-IC thread");
+ }
+ return;
+ }
+
+ mInBatchMode = inBatchMode;
+
+ if (!inBatchMode && mFocusedChild != null) {
+ // We may not commit composition on Gecko even if Java side has
+ // no composition. So we have to sync composition state with Gecko
+ // when batch edit is done.
+ //
+ // i.e. Although finishComposingText removes composing span, we
+ // don't commit current composition yet.
+ final Editable editable = getEditable();
+ if (editable != null && !isComposing(editable)) {
+ try {
+ mFocusedChild.onImeRequestCommit();
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+ // Committing composition doesn't change text, so we can sync shadow text.
+ }
+
+ if (!inBatchMode && mNeedSync) {
+ icSyncShadowText();
+ }
+ }
+
+ /* package */ void icSyncShadowText() {
+ if (mListener == null) {
+ // Not yet attached or already destroyed.
+ return;
+ }
+
+ if (mInBatchMode || !mActions.isEmpty()) {
+ mNeedSync = true;
+ return;
+ }
+
+ mNeedSync = false;
+ mText.syncShadowText(mListener);
+ }
+
+ private void setSuppressKeyUp(final boolean suppress) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ // Suppress key up event generated as a result of
+ // translating characters to key events
+ mSuppressKeyUp = suppress;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Handler setInputConnectionHandler(final Handler handler) {
+ if (handler == mIcRunHandler) {
+ return mIcRunHandler;
+ }
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // There are three threads at this point: Gecko thread, old IC thread, and new IC
+ // thread, and we want to safely switch from old IC thread to new IC thread.
+ // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that
+ // the Gecko thread is stopped at a known point. At the same time, the old IC
+ // thread blocks on the action; this ensures that the old IC thread is stopped at
+ // a known point. Finally, inside the Gecko thread, we post a Runnable to the old
+ // IC thread; this Runnable switches from old IC thread to new IC thread. We
+ // switch IC thread on the old IC thread to ensure any pending Runnables on the
+ // old IC thread are processed before we switch over. Inside the Gecko thread, we
+ // also post a Runnable to the new IC thread; this Runnable blocks until the
+ // switch is complete; this ensures that the new IC thread won't accept
+ // InputConnection calls until after the switch.
+
+ handler.post(
+ new Runnable() { // Make the new IC thread wait.
+ @Override
+ public void run() {
+ synchronized (handler) {
+ while (mIcRunHandler != handler) {
+ try {
+ handler.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+ }
+ });
+
+ icOfferAction(Action.newSetHandler(handler));
+ return handler;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void postToInputConnection(final Runnable runnable) {
+ mIcPostHandler.post(runnable);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void requestCursorUpdates(@CursorMonitorMode final int requestMode) {
+ try {
+ if (mFocusedChild != null) {
+ mFocusedChild.onImeRequestCursorUpdates(requestMode);
+ }
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) {
+ if (mFocusedChild == null) {
+ return;
+ }
+
+ try {
+ mFocusedChild.onImeInsertImage(data, mimeType);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call to insert image failed", e);
+ }
+ }
+
+ private void geckoSetIcHandler(final Handler newHandler) {
+ // On Gecko or binder thread.
+ mIcPostHandler.post(
+ new Runnable() { // posting to old IC thread
+ @Override
+ public void run() {
+ synchronized (newHandler) {
+ mIcRunHandler = newHandler;
+ newHandler.notify();
+ }
+ }
+ });
+
+ // At this point, all future Runnables should be posted to the new IC thread, but
+ // we don't switch mIcRunHandler yet because there may be pending Runnables on the
+ // old IC thread still waiting to run.
+ mIcPostHandler = newHandler;
+ }
+
+ private void geckoActionReply(final Action action) {
+ // On Gecko or binder thread.
+ if (action == null) {
+ Log.w(LOGTAG, "Mismatched reply");
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+ switch (action.mType) {
+ case Action.TYPE_REPLACE_TEXT:
+ {
+ final Spanned currentText = mText.getCurrentText();
+ final int actionNewEnd = action.mStart + action.mSequence.length();
+ if (mLastTextChangeStart > mLastTextChangeNewEnd
+ || mLastTextChangeNewEnd > currentText.length()
+ || action.mStart < mLastTextChangeStart
+ || actionNewEnd > mLastTextChangeNewEnd) {
+ // Replace-text action doesn't match our text change.
+ break;
+ }
+
+ int indexInText =
+ TextUtils.indexOf(
+ currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd);
+ if (indexInText < 0 && action.mStart != mLastTextChangeStart) {
+ final String changedText =
+ TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd);
+ indexInText = changedText.lastIndexOf(action.mSequence.toString());
+ if (indexInText >= 0) {
+ indexInText += mLastTextChangeStart;
+ }
+ }
+ if (indexInText < 0) {
+ // Replace-text action doesn't match our current text.
+ break;
+ }
+
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // Replace-text action matches our current text; copy the new spans to the
+ // current text.
+ mText.currentReplace(
+ indexInText, indexInText + action.mSequence.length(), action.mSequence);
+ // Make sure selection is preserved.
+ mText.currentSetSelection(selStart, selEnd);
+
+ // The text change is caused by the replace-text event. If the text change
+ // replaced the previous selection, we need to rely on Gecko for an updated
+ // selection, so don't ignore selection change. However, if the text change
+ // did not replace the previous selection, we can ignore the Gecko selection
+ // in favor of the Java selection.
+ mIgnoreSelectionChange = !mLastTextChangeReplacedSelection;
+ break;
+ }
+
+ case Action.TYPE_SET_SPAN:
+ final int len = mText.getCurrentText().length();
+ if (action.mStart > len
+ || action.mEnd > len
+ || !TextUtils.substring(mText.getCurrentText(), action.mStart, action.mEnd)
+ .equals(action.mSequence)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "discarding stale set span call");
+ }
+ break;
+ }
+ if ((action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END)
+ && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart
+ || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) {
+ // Use the Java selection if, between text-change notification and replace-text
+ // processing, we specifically set the selection to outside the replaced range.
+ mLastTextChangeReplacedSelection = false;
+ }
+ mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ mText.currentRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_SET_HANDLER:
+ geckoSetIcHandler(action.mHandler);
+ break;
+ }
+ }
+
+ private synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) {
+ // Verify that we're getting an IME notification from the currently focused child.
+ if (mFocusedToken == token || (mFocusedToken == null && allowNull)) {
+ return true;
+ }
+ Log.w(LOGTAG, "Invalid token");
+ return false;
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply()
+ if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ Log.d(
+ LOGTAG,
+ "notifyIME("
+ + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type)
+ + ")");
+ }
+ }
+
+ final IBinder token = child.asBinder();
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) {
+ synchronized (this) {
+ if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) {
+ // Focused child already exists and is alive.
+ Log.w(LOGTAG, "Already focused");
+ return;
+ }
+ mFocusedToken = token;
+ return;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) {
+ // Always from parent process.
+ ThreadUtils.assertOnGeckoThread();
+ } else if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) {
+ synchronized (this) {
+ onTextChange(token, "", 0, Integer.MAX_VALUE, false);
+ mActions.clear();
+ mFocusedToken = null;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ geckoActionReply(mActions.poll());
+ if (!mActions.isEmpty()) {
+ // Only post to IC thread below when the queue is empty.
+ return;
+ }
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIME(child, type);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIME(
+ final IGeckoEditableChild child, @IMENotificationType final int type) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ if (mNeedSync) {
+ icSyncShadowText();
+ }
+ return;
+ }
+
+ switch (type) {
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS:
+ if (mFocusedChild != null) {
+ // Already focused, so blur first.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false);
+ }
+
+ mFocusedChild = child;
+ mNeedSync = false;
+ mText.syncShadowText(/* listener */ null);
+
+ // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it
+ // comes _after_ notifyIME. In that case, the state is disabled here, and
+ // notifyIMEContext is responsible for calling restartInput.
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN;
+ } else {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR:
+ if (mFocusedChild != null) {
+ mFocusedChild = null;
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB:
+ toggleSoftInput(/* force */ true, mIMEState);
+ return; // Don't notify listener.
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ {
+ // Gecko already committed its composition. However, Android keyboards
+ // have trouble dealing with us removing the composition manually on the
+ // Java side. Therefore, we keep the composition intact on the Java side.
+ // The text content should still be in-sync on both sides.
+ //
+ // Nevertheless, if we somehow lost the composition, we must force the
+ // keyboard to reset.
+ if (isComposing(mText.getShadowText())) {
+ // Still have composition; no need to reset.
+ return; // Don't notify listener.
+ }
+ // No longer have composition; perform reset.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ return; // Don't notify listener.
+ }
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN:
+ case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT:
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ default:
+ throw new IllegalArgumentException("Invalid notifyIME type: " + type);
+ }
+
+ if (mListener != null) {
+ mListener.notifyIME(type);
+ }
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIMEContext(
+ final IBinder token,
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("notifyIMEContext(");
+ sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state))
+ .append(", type=\"")
+ .append(typeHint)
+ .append("\", inputmode=\"")
+ .append(modeHint)
+ .append("\", autocapitalize=\"")
+ .append(autocapitalize)
+ .append("\", flags=0x")
+ .append(Integer.toHexString(flags))
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Regular notifyIMEContext calls all come from the parent process (with the default child),
+ // so always allow calls from there. We can get additional notifyIMEContext calls during
+ // a session transfer; calls in those cases can come from child processes, and we must
+ // perform a token check in that situation.
+ if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIMEContext(
+ @IMEState final int originalState,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // For some input type we will use a widget to display the ui, for those we must not
+ // display the ime. We can display a widget for date and time types and, if the sdk version
+ // is 11 or greater, for datetime/month/week as well.
+ final int state;
+ if ((typeHint != null
+ && (typeHint.equalsIgnoreCase("date")
+ || typeHint.equalsIgnoreCase("time")
+ || typeHint.equalsIgnoreCase("month")
+ || typeHint.equalsIgnoreCase("week")
+ || typeHint.equalsIgnoreCase("datetime-local")))
+ || (modeHint != null && modeHint.equals("none"))) {
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ } else {
+ state = originalState;
+ }
+
+ final int oldState = mIMEState;
+ mIMEState = state;
+ mIMETypeHint = (typeHint == null) ? "" : typeHint;
+ mIMEModeHint = (modeHint == null) ? "" : modeHint;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+ mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize;
+ mIMEFlags = flags;
+
+ if (mListener != null) {
+ mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags);
+ }
+
+ if (mFocusedChild == null) {
+ // We have no focus.
+ return;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) {
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("icNotifyIMEContext: ");
+ sb.append("focus isn't changed. oldState=")
+ .append(oldState)
+ .append(", newState=")
+ .append(state);
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD)
+ && state == SessionTextInput.EditableListener.IME_STATE_DISABLED)
+ || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED
+ && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) {
+ // Even if focus isn't changed, software keyboard state is changed.
+ // We have to show or dismiss it.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ true);
+ return;
+ }
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR
+ // will dismiss it.
+ // So ignore to control software keyboard at this time.
+ return;
+ }
+
+ // We changed state while focused. If the old state is unknown, it means this
+ // notifyIMEContext call came _after_ the notifyIME call, so we need to call
+ // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change
+ // counts as a content change.
+ if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ }
+ }
+
+ private void icRestartInput(
+ @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')');
+ }
+
+ final GeckoSession session = mSession.get();
+ if (session != null) {
+ session.getTextInput().getDelegate().restartInput(session, reason);
+ }
+
+ if (!toggleSoftInput) {
+ return;
+ }
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ int state = mIMEState;
+ if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR
+ && mFocusedChild == null) {
+ // On blur, notifyIMEContext() is called after notifyIME(). Therefore,
+ // mIMEState is not up-to-date here and we need to override it.
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ }
+ toggleSoftInput(/* force */ false, state);
+ }
+ });
+ }
+ });
+ }
+
+ public void onCreateInputConnection(final EditorInfo outAttrs) {
+ final int state = mIMEState;
+ final String typeHint = mIMETypeHint;
+ final String modeHint = mIMEModeHint;
+ final String actionHint = mIMEActionHint;
+ final String autocapitalize = mIMEAutocapitalize;
+ final int flags = mIMEFlags;
+
+ // Some keyboards require us to fill out outAttrs even if we return null.
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ outAttrs.actionLabel = null;
+
+ if (modeHint.equals("none")) {
+ // inputmode=none hides VKB at force.
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED);
+ return;
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ false, state);
+ return;
+ }
+
+ // We give priority to typeHint so that content authors can't annoy
+ // users by doing dumb things like opening the numeric keyboard for
+ // an email form field.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
+ if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD
+ || "password".equalsIgnoreCase(typeHint)) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
+ } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (typeHint.equalsIgnoreCase("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (typeHint.equalsIgnoreCase("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) {
+ outAttrs.inputType =
+ InputType.TYPE_CLASS_NUMBER
+ | InputType.TYPE_NUMBER_VARIATION_NORMAL
+ | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // We look at modeHint
+ if (modeHint.equals("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (modeHint.equals("url")) {
+ outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (modeHint.equals("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (modeHint.equals("numeric")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL;
+ } else if (modeHint.equals("decimal")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap
+ outAttrs.inputType |=
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE;
+ }
+ }
+
+ if (autocapitalize.equals("characters")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS;
+ } else if (autocapitalize.equals("none")) {
+ // not set anymore.
+ } else if (autocapitalize.equals("sentences")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ } else if (autocapitalize.equals("words")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS;
+ } else if (modeHint.length() == 0
+ && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0
+ && !typeHint.equalsIgnoreCase("text")) {
+ // auto-capitalized mode is the default for types other than text (bug 871884)
+ // except to password, url and email.
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ }
+
+ if (actionHint.equals("enter")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ } else if (actionHint.equals("go")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_GO;
+ } else if (actionHint.equals("done")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
+ } else if (actionHint.equals("next") || actionHint.equals("maybenext")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT;
+ } else if (actionHint.equals("previous")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS;
+ } else if (actionHint.equals("search") || typeHint.equals("search")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH;
+ } else if (actionHint.equals("send")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND;
+ } else if (actionHint.length() > 0) {
+ if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\"");
+ outAttrs.actionLabel = actionHint;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) {
+ outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) {
+ // contenteditable allows image insertion.
+ outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"};
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final Spanned currentText = mText.getCurrentText();
+ outAttrs.initialSelStart = Selection.getSelectionStart(currentText);
+ outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText);
+ outAttrs.setInitialSurroundingText(currentText);
+ }
+
+ toggleSoftInput(/* force */ false, state);
+ }
+
+ /* package */ void toggleSoftInput(final boolean force, final int state) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "toggleSoftInput");
+ }
+ // Can be called from UI or IC thread.
+ final int flags = mIMEFlags;
+
+ // There are three paths that toggleSoftInput() can be called:
+ // 1) through calling restartInput(), which then indirectly calls
+ // onCreateInputConnection() and then toggleSoftInput().
+ // 2) through calling toggleSoftInput() directly from restartInput().
+ // This path is the fallback in case 1) does not happen.
+ // 3) through a system-generated onCreateInputConnection() call when the activity
+ // is restored from background, which then calls toggleSoftInput().
+ // mSoftInputReentrancyGuard is needed to ensure that between the different paths,
+ // the soft input is only toggled exactly once.
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet();
+ final boolean isReentrant = reentrancyGuard > 1;
+
+ // When using Find In Page, we can still receive notifyIMEContext calls due to the
+ // selection changing when highlighting. However in this case we don't want to
+ // show/hide the keyboard because the find box has the focus and is taking input from
+ // the keyboard.
+ final GeckoSession session = mSession.get();
+
+ if (session == null) {
+ return;
+ }
+
+ final View view = session.getTextInput().getView();
+ final boolean isFocused = (view == null) || view.hasFocus();
+
+ final boolean isUserAction =
+ ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0);
+
+ if (!force && (isReentrant || !isFocused || !isUserAction)) {
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "toggleSoftInput: no-op, reentrant="
+ + isReentrant
+ + ", focused="
+ + isFocused
+ + ", user="
+ + isUserAction);
+ }
+ return;
+ }
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ session.getTextInput().getDelegate().hideSoftInput(session);
+ return;
+ }
+ {
+ final GeckoBundle bundle = new GeckoBundle();
+ // This bit is subtle. We want to force-zoom to the input
+ // if we're _not_ force-showing the virtual keyboard.
+ //
+ // We only force-show the virtual keyboard as a result of
+ // something that _doesn't_ switch the focus, and we don't
+ // want to move the view out of the focused editor unless
+ // we _actually_ show toggle the keyboard.
+ bundle.putBoolean("force", !force);
+ session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle);
+ }
+ session.getTextInput().getDelegate().showSoftInput(session);
+ } finally {
+ mSoftInputReentrancyGuard.decrementAndGet();
+ }
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void onSelectionChange(
+ final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onSelectionChange(");
+ sb.append(start)
+ .append(", ")
+ .append(end)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (mIgnoreSelectionChange) {
+ mIgnoreSelectionChange = false;
+ } else {
+ mText.currentSetSelection(start, end);
+ }
+
+ // We receive selection change notification after receiving replies for pending
+ // events, so we can reset text change bounds at this point.
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ if (causedOnlyByComposition) {
+ // It is unnecessary to sync shadow text since this change is by composition from Java
+ // side.
+ return;
+ }
+
+ // It is ready to synchronize Java text with Gecko text when no more input events is
+ // dispatched.
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icSyncShadowText();
+ }
+ });
+ }
+
+ private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) {
+ return oldEnd - start == newText.length()
+ && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start);
+ }
+
+ @Override // IGeckoEditableParent
+ public void onTextChange(
+ final IBinder token,
+ final CharSequence text,
+ final int start,
+ final int unboundedOldEnd,
+ final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onTextChange(");
+ debugAppend(sb, text)
+ .append(", ")
+ .append(start)
+ .append(", ")
+ .append(unboundedOldEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (unboundedOldEnd >= Integer.MAX_VALUE / 2) {
+ // Integer.MAX_VALUE / 2 is a magic number to synchronize all.
+ // (See GeckoEditableSupport::FlushIMEText.)
+ // Previous text transactions are unnecessary now, so we have to ignore it.
+ mActions.clear();
+ }
+
+ final int currentLength = mText.getCurrentText().length();
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ final int newEnd = start + text.length();
+
+ if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) {
+ // | oldEnd > currentLength | signals entire text is cleared (e.g. for
+ // newly-focused editors). Simply replace the text in that case; replace in
+ // two steps to properly clear composing spans that span the whole range.
+ mText.currentReplace(0, currentLength, "");
+ mText.currentReplace(0, 0, text);
+
+ // Don't ignore the next selection change because we are re-syncing with Gecko
+ mIgnoreSelectionChange = false;
+
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ } else if (!geckoIsSameText(start, oldEnd, text)) {
+ final Spanned currentText = mText.getCurrentText();
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // True if the selection was in the middle of the replaced text; in that case
+ // we don't know where to place the selection after replacement, and must rely
+ // on the Gecko selection.
+ mLastTextChangeReplacedSelection |=
+ (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd);
+
+ // Gecko side initiated the text change. Replace in two steps to properly
+ // clear composing spans that span the whole range.
+ mText.currentReplace(start, oldEnd, "");
+ mText.currentReplace(start, start, text);
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+
+ } else {
+ // Nothing to do because the text is the same. This could happen when
+ // the composition is updated for example, in which case we want to keep the
+ // Java selection.
+ final Action action = mActions.peek();
+ mIgnoreSelectionChange =
+ mIgnoreSelectionChange
+ || (action != null
+ && (action.mType == Action.TYPE_REPLACE_TEXT
+ || action.mType == Action.TYPE_SET_SPAN
+ || action.mType == Action.TYPE_REMOVE_SPAN));
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+ }
+
+ // onTextChange is always followed by onSelectionChange, so we let
+ // onSelectionChange schedule a shadow text sync.
+ }
+
+ @Override // IGeckoEditableParent
+ public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onDefaultKeyEvent(");
+ sb.append("action=")
+ .append(event.getAction())
+ .append(", ")
+ .append("keyCode=")
+ .append(event.getKeyCode())
+ .append(", ")
+ .append("metaState=")
+ .append(event.getMetaState())
+ .append(", ")
+ .append("time=")
+ .append(event.getEventTime())
+ .append(", ")
+ .append("repeatCount=")
+ .append(event.getRepeatCount())
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Allow default key processing even if we're not focused.
+ if (!binderCheckToken(token, /* allowNull */ true)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onDefaultKeyEvent(event);
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void updateCompositionRects(
+ final IBinder token, final RectF[] rects, final RectF caretRect) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")");
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.updateCompositionRects(rects, caretRect);
+ }
+ });
+ }
+
+ // InvocationHandler interface
+
+ static String getConstantName(final Class<?> cls, final String prefix, final Object value) {
+ for (final Field fld : cls.getDeclaredFields()) {
+ try {
+ if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) {
+ return fld.getName();
+ }
+ } catch (final IllegalAccessException e) {
+ }
+ }
+ return String.valueOf(value);
+ }
+
+ private static String getPrintableChar(final char chr) {
+ if (chr >= 0x20 && chr <= 0x7e) {
+ return String.valueOf(chr);
+ } else if (chr == '\n') {
+ return "\u21b2";
+ }
+ return String.format("\\u%04x", (int) chr);
+ }
+
+ static StringBuilder debugAppend(final StringBuilder sb, final Object obj) {
+ if (obj == null) {
+ sb.append("null");
+ } else if (obj instanceof GeckoEditable) {
+ sb.append("GeckoEditable");
+ } else if (obj instanceof GeckoEditableChild) {
+ sb.append("GeckoEditableChild");
+ } else if (Proxy.isProxyClass(obj.getClass())) {
+ debugAppend(sb, Proxy.getInvocationHandler(obj));
+ } else if (obj instanceof Character) {
+ sb.append('\'').append(getPrintableChar((Character) obj)).append('\'');
+ } else if (obj instanceof CharSequence) {
+ final String str = obj.toString();
+ sb.append('"');
+ for (int i = 0; i < str.length(); i++) {
+ final char chr = str.charAt(i);
+ if (chr >= 0x20 && chr <= 0x7e) {
+ sb.append(chr);
+ } else {
+ sb.append(getPrintableChar(chr));
+ }
+ }
+ sb.append('"');
+ } else if (obj.getClass().isArray()) {
+ sb.append(obj.getClass().getComponentType().getSimpleName())
+ .append('[')
+ .append(Array.getLength(obj))
+ .append(']');
+ } else {
+ sb.append(obj);
+ }
+ return sb;
+ }
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final Object target;
+ final Class<?> methodInterface = method.getDeclaringClass();
+ if (DEBUG) {
+ // Editable methods should all be called from the IC thread
+ assertOnIcThread();
+ }
+ if (methodInterface == Editable.class
+ || methodInterface == Appendable.class
+ || methodInterface == Spannable.class) {
+ // Method alters the Editable; route calls to our implementation
+ target = this;
+ } else {
+ target = mText.getShadowText();
+ }
+
+ final Object ret = method.invoke(target, args);
+ if (DEBUG) {
+ final StringBuilder log = new StringBuilder(method.getName());
+ log.append("(");
+ if (args != null) {
+ for (final Object arg : args) {
+ debugAppend(log, arg).append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ if (method.getReturnType().equals(Void.TYPE)) {
+ log.append(")");
+ } else {
+ debugAppend(log.append(") = "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ }
+ return ret;
+ }
+
+ // Spannable interface
+
+ @Override
+ public void removeSpan(final Object what) {
+ if (what == null) {
+ return;
+ }
+
+ if (what == Selection.SELECTION_START || what == Selection.SELECTION_END) {
+ Log.w(LOGTAG, "selection removed with removeSpan()");
+ }
+
+ icOfferAction(Action.newRemoveSpan(what));
+ }
+
+ @Override
+ public void setSpan(final Object what, final int start, final int end, final int flags) {
+ icOfferAction(Action.newSetSpan(what, start, end, flags));
+ }
+
+ // Appendable interface
+
+ @Override
+ public Editable append(final CharSequence text) {
+ return replace(mProxy.length(), mProxy.length(), text, 0, text.length());
+ }
+
+ @Override
+ public Editable append(final CharSequence text, final int start, final int end) {
+ return replace(mProxy.length(), mProxy.length(), text, start, end);
+ }
+
+ @Override
+ public Editable append(final char text) {
+ return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1);
+ }
+
+ // Editable interface
+
+ @Override
+ public InputFilter[] getFilters() {
+ return mFilters;
+ }
+
+ @Override
+ public void setFilters(final InputFilter[] filters) {
+ mFilters = filters;
+ }
+
+ @Override
+ public void clearSpans() {
+ /* XXX this clears the selection spans too,
+ but there is no way to clear the corresponding selection in Gecko */
+ Log.w(LOGTAG, "selection cleared with clearSpans()");
+ icOfferAction(Action.newRemoveSpan(/* what */ null));
+ }
+
+ @Override
+ public Editable replace(
+ final int st, final int en, final CharSequence source, final int start, final int end) {
+ CharSequence text = source;
+ if (start < 0 || start > end || end > text.length()) {
+ Log.e(
+ LOGTAG,
+ "invalid replace offsets: " + start + " to " + end + ", length: " + text.length());
+ throw new IllegalArgumentException("invalid replace offsets");
+ }
+ if (start != 0 || end != text.length()) {
+ text = text.subSequence(start, end);
+ }
+ if (mFilters != null) {
+ // Filter text before sending the request to Gecko
+ for (int i = 0; i < mFilters.length; ++i) {
+ final CharSequence cs = mFilters[i].filter(text, 0, text.length(), mProxy, st, en);
+ if (cs != null) {
+ text = cs;
+ }
+ }
+ }
+ if (text == source) {
+ // Always create a copy
+ text = new SpannableString(source);
+ }
+ icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en)));
+ return mProxy;
+ }
+
+ @Override
+ public void clear() {
+ replace(0, mProxy.length(), "", 0, 0);
+ }
+
+ @Override
+ public Editable delete(final int st, final int en) {
+ return replace(st, en, "", 0, 0);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text, final int start, final int end) {
+ return replace(where, where, text, start, end);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text) {
+ return replace(where, where, text, 0, text.length());
+ }
+
+ @Override
+ public Editable replace(final int st, final int en, final CharSequence text) {
+ return replace(st, en, text, 0, text.length());
+ }
+
+ /* GetChars interface */
+
+ @Override
+ public void getChars(final int start, final int end, final char[] dest, final int destoff) {
+ /* overridden Editable interface methods in GeckoEditable must not be called directly
+ outside of GeckoEditable. Instead, the call must go through mProxy, which ensures
+ that Java is properly synchronized with Gecko */
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* Spanned interface */
+
+ @Override
+ public int getSpanEnd(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanFlags(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanStart(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public <T> T[] getSpans(final int start, final int end, final Class<T> type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration
+ public int nextSpanTransition(final int start, final int limit, final Class type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* CharSequence interface */
+
+ @Override
+ public char charAt(final int index) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int length() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public CharSequence subSequence(final int start, final int end) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public String toString() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ public boolean onKeyPreIme(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ public boolean onKeyDown(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event);
+ }
+
+ public boolean onKeyUp(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_UP, keyCode, event);
+ }
+
+ public boolean onKeyMultiple(
+ final @Nullable View view,
+ final int keyCode,
+ final int repeatCount,
+ final @NonNull KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters()
+ final String str = event.getCharacters();
+ for (int i = 0; i < str.length(); i++) {
+ final KeyEvent charEvent = getCharKeyEvent(str.charAt(i));
+ if (!processKey(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent)
+ || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ for (int i = 0; i < repeatCount; i++) {
+ if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event)
+ || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public boolean onKeyLongPress(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ /** Get a key that represents a given character. */
+ private static KeyEvent getCharKeyEvent(final char c) {
+ final long time = SystemClock.uptimeMillis();
+ return new KeyEvent(
+ time, time, KeyEvent.ACTION_MULTIPLE, KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) {
+ @Override
+ public int getUnicodeChar() {
+ return c;
+ }
+
+ @Override
+ public int getUnicodeChar(final int metaState) {
+ return c;
+ }
+ };
+ }
+
+ private boolean processKey(
+ final @Nullable View view,
+ final int action,
+ final int keyCode,
+ final @NonNull KeyEvent event) {
+ if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) {
+ return false;
+ }
+
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendKeyEvent(view, action, event);
+ }
+ });
+ return true;
+ }
+
+ private static boolean shouldProcessKey(final int keyCode, final KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_MENU:
+ case KeyEvent.KEYCODE_BACK:
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ case KeyEvent.KEYCODE_SEARCH:
+ // ignore HEADSETHOOK to allow hold-for-voice-search to work
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isComposing(final Spanned text) {
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int getComposingStart(final Spanned text) {
+ int composingStart = Integer.MAX_VALUE;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ }
+ }
+
+ return composingStart;
+ }
+
+ private static int getComposingEnd(final Spanned text) {
+ int composingEnd = -1;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+ }
+
+ return composingEnd;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
new file mode 100644
index 0000000000..ec53d2803a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
@@ -0,0 +1,172 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Settings;
+import android.util.Log;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * A class that automatically adjusts font size settings for web content in Gecko in accordance with
+ * the device's OS font scale setting.
+ *
+ * @see android.provider.Settings.System#FONT_SCALE
+ */
+/* package */ final class GeckoFontScaleListener extends ContentObserver {
+ private static final String LOGTAG = "GeckoFontScaleListener";
+
+ private static final float DEFAULT_FONT_SCALE = 1.0f;
+
+ // We're referencing the *application* context, so this is in fact okay.
+ @SuppressLint("StaticFieldLeak")
+ private static final GeckoFontScaleListener sInstance = new GeckoFontScaleListener();
+
+ private Context mApplicationContext;
+ private GeckoRuntimeSettings mSettings;
+
+ private boolean mAttached;
+ private boolean mEnabled;
+ private boolean mRunning;
+
+ private float mPrevGeckoFontScale;
+
+ public static GeckoFontScaleListener getInstance() {
+ return sInstance;
+ }
+
+ private GeckoFontScaleListener() {
+ // Ensure the ContentObserver callback runs on the UI thread.
+ super(ThreadUtils.getUiHandler());
+ }
+
+ /**
+ * Prepare the GeckoFontScaleListener for usage. If it has been previously enabled, it will now
+ * start actively working.
+ */
+ public void attachToContext(final Context context, final GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ Log.w(LOGTAG, "Already attached!");
+ return;
+ }
+
+ mAttached = true;
+ mSettings = settings;
+ mApplicationContext = context.getApplicationContext();
+ onEnabledChange();
+ }
+
+ /**
+ * Detaches the context and also stops the GeckoFontScaleListener if it was previously enabled.
+ * This will also restore the previously used font size settings.
+ */
+ public void detachFromContext() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!mAttached) {
+ Log.w(LOGTAG, "Already detached!");
+ return;
+ }
+
+ stop();
+ mApplicationContext = null;
+ mSettings = null;
+ mAttached = false;
+ }
+
+ /**
+ * Controls whether the GeckoFontScaleListener should automatically adjust font sizes for web
+ * content in Gecko. When disabling, this will restore the previously used font size settings.
+ *
+ * <p>This method can be called at any time, but the GeckoFontScaleListener won't start actively
+ * adjusting font sizes until it has been attached to a context.
+ *
+ * @param enabled True if automatic font size setting should be enabled.
+ */
+ public void setEnabled(final boolean enabled) {
+ ThreadUtils.assertOnUiThread();
+ mEnabled = enabled;
+ onEnabledChange();
+ }
+
+ /**
+ * Get whether the GeckoFontScaleListener is currently enabled.
+ *
+ * @return True if the GeckoFontScaleListener is currently enabled.
+ */
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ private void onEnabledChange() {
+ if (!mAttached) {
+ return;
+ }
+
+ if (mEnabled) {
+ start();
+ } else {
+ stop();
+ }
+ }
+
+ private void start() {
+ if (mRunning) {
+ return;
+ }
+
+ mPrevGeckoFontScale = mSettings.getFontSizeFactor();
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ final Uri fontSizeSetting = Settings.System.getUriFor(Settings.System.FONT_SCALE);
+ contentResolver.registerContentObserver(fontSizeSetting, false, this);
+ onSystemFontScaleChange(contentResolver, false);
+
+ mRunning = true;
+ }
+
+ private void stop() {
+ if (!mRunning) {
+ return;
+ }
+
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ contentResolver.unregisterContentObserver(this);
+ onSystemFontScaleChange(contentResolver, /*stopping*/ true);
+
+ mRunning = false;
+ }
+
+ private void onSystemFontScaleChange(
+ final ContentResolver contentResolver, final boolean stopping) {
+ float fontScale;
+
+ if (!stopping) { // Either we were enabled, or else the system font scale changed.
+ fontScale =
+ Settings.System.getFloat(contentResolver, Settings.System.FONT_SCALE, DEFAULT_FONT_SCALE);
+ // Older Android versions don't sanitize the FONT_SCALE value. See Bug 1656078.
+ if (fontScale < 0) {
+ fontScale = DEFAULT_FONT_SCALE;
+ }
+ } else { // We were turned off.
+ fontScale = mPrevGeckoFontScale;
+ }
+
+ mSettings.setFontSizeFactorInternal(fontScale);
+ }
+
+ @UiThread // See constructor.
+ @Override
+ public void onChange(final boolean selfChange) {
+ onSystemFontScaleChange(mApplicationContext.getContentResolver(), false);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
new file mode 100644
index 0000000000..2d2f2d8dd3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
@@ -0,0 +1,829 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableString;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputContentInfo;
+import androidx.annotation.NonNull;
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import org.mozilla.gecko.Clipboard;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/* package */ final class GeckoInputConnection extends BaseInputConnection
+ implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener {
+
+ private static final boolean DEBUG = false;
+ protected static final String LOGTAG = "GeckoInputConnection";
+
+ private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection";
+ private static final String CUSTOM_HANDLER_TEST_CLASS =
+ "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput";
+
+ private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480;
+
+ private static Handler sBackgroundHandler;
+
+ // Managed only by notifyIMEContext; see comments in notifyIMEContext
+ @IMEState private int mIMEState;
+ private String mIMEActionHint = "";
+ private int mLastSelectionStart;
+ private int mLastSelectionEnd;
+
+ private String mCurrentInputMethod = "";
+
+ private final GeckoSession mSession;
+ private final View mView;
+ private final SessionTextInput.EditableClient mEditableClient;
+ protected int mBatchEditCount;
+ private ExtractedTextRequest mUpdateRequest;
+ private final InputConnection mKeyInputConnection;
+ private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder;
+
+ public static SessionTextInput.InputConnectionClient create(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ SessionTextInput.InputConnectionClient ic =
+ new GeckoInputConnection(session, targetView, editable);
+ if (DEBUG) {
+ ic = wrapForDebug(ic);
+ }
+ return ic;
+ }
+
+ private static SessionTextInput.InputConnectionClient wrapForDebug(
+ final SessionTextInput.InputConnectionClient ic) {
+ final InvocationHandler handler =
+ new InvocationHandler() {
+ private final StringBuilder mCallLevel = new StringBuilder();
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final StringBuilder log = new StringBuilder(mCallLevel);
+ log.append("> ").append(method.getName()).append("(");
+ if (args != null) {
+ for (int i = 0; i < args.length; i++) {
+ final Object arg = args[i];
+ // translate argument values to constant names
+ if ("notifyIME".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg));
+ } else if ("notifyIMEContext".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "IME_STATE_", arg));
+ } else {
+ GeckoEditable.debugAppend(log, arg);
+ }
+ log.append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ log.append(")");
+ Log.d(LOGTAG, log.toString());
+
+ mCallLevel.append(' ');
+ Object ret = method.invoke(ic, args);
+ if (ret == ic) {
+ ret = proxy;
+ }
+ mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1));
+
+ log.setLength(mCallLevel.length());
+ log.append("< ").append(method.getName());
+ if (!method.getReturnType().equals(Void.TYPE)) {
+ GeckoEditable.debugAppend(log.append(": "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ return ret;
+ }
+ };
+
+ return (SessionTextInput.InputConnectionClient)
+ Proxy.newProxyInstance(
+ GeckoInputConnection.class.getClassLoader(),
+ new Class<?>[] {
+ InputConnection.class,
+ SessionTextInput.InputConnectionClient.class,
+ SessionTextInput.EditableListener.class
+ },
+ handler);
+ }
+
+ protected GeckoInputConnection(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ super(targetView, true);
+ mSession = session;
+ mView = targetView;
+ mEditableClient = editable;
+ mIMEState = IME_STATE_DISABLED;
+ // InputConnection that sends keys for plugins, which don't have full editors
+ mKeyInputConnection = new BaseInputConnection(targetView, false);
+ }
+
+ @Override
+ public synchronized boolean beginBatchEdit() {
+ mBatchEditCount++;
+ if (mBatchEditCount == 1) {
+ mEditableClient.setBatchMode(true);
+ }
+ return true;
+ }
+
+ @Override
+ public synchronized boolean endBatchEdit() {
+ if (mBatchEditCount <= 0) {
+ Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!");
+ return true;
+ }
+
+ mBatchEditCount--;
+ if (mBatchEditCount != 0) {
+ return true;
+ }
+
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ return true;
+ }
+
+ @Override
+ public Editable getEditable() {
+ return mEditableClient.getEditable();
+ }
+
+ @Override
+ public boolean performContextMenuAction(final int id) {
+ final View view = getView();
+ final Editable editable = getEditable();
+ if (view == null || editable == null) {
+ return false;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ switch (id) {
+ case android.R.id.selectAll:
+ setSelection(0, editable.length());
+ break;
+ case android.R.id.cut:
+ // If selection is empty, we'll select everything
+ if (selStart == selEnd) {
+ // Fill the clipboard
+ Clipboard.setText(view.getContext(), editable);
+ editable.clear();
+ } else {
+ Clipboard.setText(
+ view.getContext(),
+ editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd)));
+ editable.delete(selStart, selEnd);
+ }
+ break;
+ case android.R.id.paste:
+ final String text = Clipboard.getText(view.getContext());
+ if (text != null) {
+ commitText(text, 1);
+ }
+ break;
+ case android.R.id.copy:
+ // Copy the current selection or the empty string if nothing is selected.
+ final String copiedText =
+ selStart == selEnd
+ ? ""
+ : editable
+ .toString()
+ .substring(Math.min(selStart, selEnd), Math.max(selStart, selEnd));
+ Clipboard.setText(view.getContext(), copiedText);
+ break;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean performEditorAction(final int editorAction) {
+ if (editorAction == EditorInfo.IME_ACTION_PREVIOUS && !mIMEActionHint.equals("previous")) {
+ // This action is [Previous] key on FireTV's keyboard.
+ // [Previous] closes software keyboard, and don't generate any keyboard event.
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().hideSoftInput(mSession);
+ }
+ });
+ return true;
+ }
+ return super.performEditorAction(editorAction);
+ }
+
+ @Override
+ public ExtractedText getExtractedText(final ExtractedTextRequest req, final int flags) {
+ if (req == null) return null;
+
+ if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req;
+
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return null;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ final ExtractedText extract = new ExtractedText();
+ extract.flags = 0;
+ extract.partialStartOffset = -1;
+ extract.partialEndOffset = -1;
+ extract.selectionStart = selStart;
+ extract.selectionEnd = selEnd;
+ extract.startOffset = 0;
+ if ((req.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extract.text = new SpannableString(editable);
+ } else {
+ extract.text = editable.toString();
+ }
+ return extract;
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public View getView() {
+ return mView;
+ }
+
+ @NonNull
+ /* package */ GeckoSession.TextInputDelegate getInputDelegate() {
+ return mSession.getTextInput().getDelegate();
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onTextChange() {
+ final Editable editable = getEditable();
+ if (mUpdateRequest == null || editable == null) {
+ return;
+ }
+
+ final ExtractedTextRequest request = mUpdateRequest;
+ final ExtractedText extractedText = new ExtractedText();
+ extractedText.flags = 0;
+ // Update the entire Editable range
+ extractedText.partialStartOffset = -1;
+ extractedText.partialEndOffset = -1;
+ extractedText.selectionStart = Selection.getSelectionStart(editable);
+ extractedText.selectionEnd = Selection.getSelectionEnd(editable);
+ extractedText.startOffset = 0;
+ if ((request.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extractedText.text = new SpannableString(editable);
+ } else {
+ extractedText.text = editable.toString();
+ }
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateExtractedText(mSession, request, extractedText);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onSelectionChange() {
+
+ final Editable editable = getEditable();
+ if (editable != null) {
+ mLastSelectionStart = Selection.getSelectionStart(editable);
+ mLastSelectionEnd = Selection.getSelectionEnd(editable);
+ notifySelectionChange(mLastSelectionStart, mLastSelectionEnd);
+ }
+ }
+
+ private void notifySelectionChange(final int start, final int end) {
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return;
+ }
+
+ final int compositionStart = getComposingSpanStart(editable);
+ final int compositionEnd = getComposingSpanEnd(editable);
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .updateSelection(mSession, start, end, compositionStart, compositionEnd);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDiscardComposition() {
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. But ATOK series do nothing. So we have to
+ // restart input method to remove composition as workaround.
+ if (!InputMethods.needsRestartInput(InputMethods.getCurrentInputMethod(view.getContext()))) {
+ return;
+ }
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .restartInput(
+ mSession, GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE);
+ }
+ });
+ }
+
+ @TargetApi(21)
+ @Override // SessionTextInput.EditableListener
+ public void updateCompositionRects(final RectF[] rects, final RectF caretRect) {
+ if (!(Build.VERSION.SDK_INT >= 21)) {
+ return;
+ }
+
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ final Editable content = getEditable();
+ if (content == null) {
+ return;
+ }
+
+ final int composingStart = getComposingSpanStart(content);
+ final int composingEnd = getComposingSpanEnd(content);
+ if (composingStart < 0 || composingEnd < 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "No composition for updates");
+ }
+ return;
+ }
+
+ final CharSequence composition = content.subSequence(composingStart, composingEnd);
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ updateCompositionRectsOnUi(view, rects, caretRect, composition);
+ }
+ });
+ }
+
+ @TargetApi(21)
+ /* package */ void updateCompositionRectsOnUi(
+ final View view, final RectF[] rects, final RectF caretRect, final CharSequence composition) {
+ if (mCursorAnchorInfoBuilder == null) {
+ mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
+ }
+ mCursorAnchorInfoBuilder.reset();
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenOffsetMatrix(matrix);
+ mCursorAnchorInfoBuilder.setMatrix(matrix);
+
+ for (int i = 0; i < rects.length; i++) {
+ mCursorAnchorInfoBuilder.addCharacterBounds(
+ i,
+ rects[i].left,
+ rects[i].top,
+ rects[i].right,
+ rects[i].bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ mCursorAnchorInfoBuilder.setComposingText(0, composition);
+
+ if (!caretRect.isEmpty()) {
+ // Gecko doesn't provide baseline information of caret.
+ mCursorAnchorInfoBuilder.setInsertionMarkerLocation(
+ caretRect.left,
+ caretRect.top,
+ caretRect.bottom,
+ caretRect.bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build();
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateCursorAnchorInfo(mSession, info);
+ }
+ });
+ }
+
+ @Override
+ public boolean requestCursorUpdates(final int cursorUpdateMode) {
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.ONE_SHOT);
+ }
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.START_MONITOR);
+ } else {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.END_MONITOR);
+ }
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDefaultKeyEvent(final KeyEvent event) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoInputConnection.this.performDefaultKeyAction(event);
+ }
+ });
+ }
+
+ private static synchronized Handler getBackgroundHandler() {
+ if (sBackgroundHandler != null) {
+ return sBackgroundHandler;
+ }
+ // Don't use GeckoBackgroundThread because Gecko thread may block waiting on
+ // GeckoBackgroundThread. If we were to use GeckoBackgroundThread, due to IME,
+ // GeckoBackgroundThread may end up also block waiting on Gecko thread and a
+ // deadlock occurs
+ final Thread backgroundThread =
+ new Thread(
+ new Runnable() {
+ @Override
+ public void run() {
+ Looper.prepare();
+ synchronized (GeckoInputConnection.class) {
+ sBackgroundHandler = new Handler();
+ GeckoInputConnection.class.notify();
+ }
+ Looper.loop();
+ // We should never be exiting the thread loop.
+ throw new IllegalThreadStateException("unreachable code");
+ }
+ },
+ LOGTAG);
+ backgroundThread.setDaemon(true);
+ backgroundThread.start();
+ while (sBackgroundHandler == null) {
+ try {
+ // wait for new thread to set sBackgroundHandler
+ GeckoInputConnection.class.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ return sBackgroundHandler;
+ }
+
+ private synchronized boolean canReturnCustomHandler() {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return false;
+ }
+ for (final StackTraceElement frame : Thread.currentThread().getStackTrace()) {
+ // We only return our custom Handler to InputMethodManager's InputConnection
+ // proxy. For all other purposes, we return the regular Handler.
+ // InputMethodManager retrieves the Handler for its InputConnection proxy
+ // inside its method startInputInner(), so we check for that here. This is
+ // valid from Android 2.2 to at least Android 4.2. If this situation ever
+ // changes, we gracefully fall back to using the regular Handler.
+ if ("startInputInner".equals(frame.getMethodName())
+ && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) {
+ // Only return our own Handler to InputMethodManager and only prior to 24.
+ return Build.VERSION.SDK_INT < 24;
+ }
+ if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName())
+ && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) {
+ // InputConnection tests should also run on the custom handler
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isPhysicalKeyboardPresent() {
+ final View v = getView();
+ if (v == null) {
+ return false;
+ }
+ final Configuration config = v.getContext().getResources().getConfiguration();
+ return config.keyboard != Configuration.KEYBOARD_NOKEYS;
+ }
+
+ @Override // InputConnection
+ public Handler getHandler() {
+ final Handler handler;
+ if (isPhysicalKeyboardPresent()) {
+ handler = ThreadUtils.getUiHandler();
+ } else {
+ handler = getBackgroundHandler();
+ }
+ return mEditableClient.setInputConnectionHandler(handler);
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public Handler getHandler(final Handler defHandler) {
+ if (!canReturnCustomHandler()) {
+ return defHandler;
+ }
+
+ return getHandler();
+ }
+
+ @Override // InputConnection
+ public void closeConnection() {
+ if (mBatchEditCount != 0) {
+ // GBoard may call this into batch edit mode then it doesn't call endBatchEdit.
+ // Since we are recycle GeckoInputConnection, we have to reset
+ // batch count even if IME/keyboard bug.
+ if (DEBUG) {
+ Log.d(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ }
+ mBatchEditCount = 0;
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ }
+ super.closeConnection();
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return null;
+ }
+
+ final Context context = getView().getContext();
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ if (Math.min(metrics.widthPixels, metrics.heightPixels) > INLINE_IME_MIN_DISPLAY_SIZE) {
+ // prevent showing full-screen keyboard only when the screen is tall enough
+ // to show some reasonable amount of the page (see bug 752709)
+ outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN;
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "mapped IME states to: inputType = "
+ + Integer.toHexString(outAttrs.inputType)
+ + ", imeOptions = "
+ + Integer.toHexString(outAttrs.imeOptions));
+ }
+
+ final String prevInputMethod = mCurrentInputMethod;
+ mCurrentInputMethod = InputMethods.getCurrentInputMethod(context);
+ if (DEBUG) {
+ Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod);
+ }
+
+ outAttrs.initialSelStart = mLastSelectionStart;
+ outAttrs.initialSelEnd = mLastSelectionEnd;
+ return this;
+ }
+
+ private boolean replaceComposingSpanWithSelection() {
+ final Editable content = getEditable();
+ if (content == null) {
+ return false;
+ }
+ final int a = getComposingSpanStart(content);
+ final int b = getComposingSpanEnd(content);
+ if (a != -1 && b != -1) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "removing composition at " + a + "-" + b);
+ }
+ removeComposingSpans(content);
+ Selection.setSelection(content, a, b);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean commitText(final CharSequence text, final int newCursorPosition) {
+ if (InputMethods.shouldCommitCharAsKey(mCurrentInputMethod)
+ && text.length() == 1
+ && newCursorPosition > 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "committing \"" + text + "\" as key");
+ }
+ // mKeyInputConnection is a BaseInputConnection that commits text as keys;
+ // but we first need to replace any composing span with a selection,
+ // so that the new key events will generate characters to replace
+ // text from the old composing span
+ return replaceComposingSpanWithSelection()
+ && mKeyInputConnection.commitText(text, newCursorPosition);
+ }
+ return super.commitText(text, newCursorPosition);
+ }
+
+ @Override
+ public boolean setSelection(final int start, final int end) {
+ if (start < 0 || end < 0) {
+ // Some keyboards (e.g. Samsung) can call setSelection with
+ // negative offsets. In that case we ignore the call, similar to how
+ // BaseInputConnection.setSelection ignores offsets that go past the length.
+ return true;
+ }
+ return super.setSelection(start, end);
+ }
+
+ @Override
+ public boolean sendKeyEvent(final @NonNull KeyEvent event) {
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+ mEditableClient.sendKeyEvent(getView(), event.getAction(), translatedEvent);
+ return false; // seems to always return false
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ENTER:
+ if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0
+ && mIMEActionHint.equals("maybenext")) {
+ // XXX It is not good to dispatch tab key for web compatibility.
+ // See https://github.com/w3c/uievents/issues/253 and bug 1600540.
+ return new KeyEvent(
+ event.getDownTime(),
+ event.getEventTime(),
+ event.getAction(),
+ KeyEvent.KEYCODE_TAB,
+ 0);
+ }
+ break;
+ }
+ return event;
+ }
+
+ // Called by OnDefaultKeyEvent handler, up from Gecko
+ /* package */ void performDefaultKeyAction(final KeyEvent event) {
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_MUTE:
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ case KeyEvent.KEYCODE_MEDIA_PLAY:
+ case KeyEvent.KEYCODE_MEDIA_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_STOP:
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ case KeyEvent.KEYCODE_MEDIA_REWIND:
+ case KeyEvent.KEYCODE_MEDIA_RECORD:
+ case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ case KeyEvent.KEYCODE_MEDIA_CLOSE:
+ case KeyEvent.KEYCODE_MEDIA_EJECT:
+ case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK:
+ // Forward media keypresses to the registered handler so headset controls work
+ // Does the same thing as Chromium
+ // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445
+ // These are all the keys dispatchMediaKeyEvent supports.
+ if (Build.VERSION.SDK_INT >= 19) {
+ // dispatchMediaKeyEvent is only available on Android 4.4+
+ final Context viewContext = getView().getContext();
+ final AudioManager am =
+ (AudioManager) viewContext.getSystemService(Context.AUDIO_SERVICE);
+ am.dispatchMediaKeyEvent(event);
+ }
+ break;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N_MR1)
+ @Override
+ public boolean commitContent(
+ final InputContentInfo inputContentInfo, final int flags, final Bundle opts) {
+ final boolean requestPermission =
+ ((flags & InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0);
+ if (requestPermission) {
+ try {
+ inputContentInfo.requestPermission();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "InputContentInfo.requestPermission() failed.", e);
+ return false;
+ }
+ }
+
+ try (final InputStream inputStream =
+ getView()
+ .getContext()
+ .getContentResolver()
+ .openInputStream(inputContentInfo.getContentUri());
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ final byte[] data = new byte[4096];
+ int readed;
+ while ((readed = inputStream.read(data)) != -1) {
+ outputStream.write(data, 0, readed);
+ }
+ mEditableClient.insertImage(
+ outputStream.toByteArray(), inputContentInfo.getDescription().getMimeType(0));
+ } catch (final FileNotFoundException e) {
+ Log.e(LOGTAG, "Cannot open provider URI.", e);
+ return false;
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot read/write provider URI.", e);
+ return false;
+ } finally {
+ if (requestPermission) {
+ inputContentInfo.releasePermission();
+ }
+ }
+
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void notifyIME(final @IMENotificationType int type) {
+ switch (type) {
+ case NOTIFY_IME_OF_FOCUS:
+ // Showing/hiding vkb is done in notifyIMEContext
+ if (mBatchEditCount != 0) {
+ Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ mBatchEditCount = 0;
+ }
+ break;
+
+ case NOTIFY_IME_OF_BLUR:
+ break;
+
+ case NOTIFY_IME_OF_TOKEN:
+ case NOTIFY_IME_OPEN_VKB:
+ case NOTIFY_IME_REPLY_EVENT:
+ case NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ case NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ default:
+ if (DEBUG) {
+ throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type);
+ }
+ break;
+ }
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public synchronized void notifyIMEContext(
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ @IMEContextFlags final int flags) {
+ // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext,
+ // and not reset anywhere else. Usually, notifyIMEContext is called right after a
+ // focus or blur, so resetting mIMEState during the focus or blur seems harmless.
+ // However, this behavior is not guaranteed. Gecko may call notifyIMEContext
+ // independent of focus change; that is, a focus change may not be accompanied by
+ // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not
+ // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318)
+ /* When IME is 'disabled', IME processing is disabled.
+ In addition, the IME UI is hidden */
+ mIMEState = state;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+
+ // These fields are reset here and will be updated when restartInput is called below
+ mUpdateRequest = null;
+ mCurrentInputMethod = "";
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
new file mode 100644
index 0000000000..2fe7c8ead0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
@@ -0,0 +1,205 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/**
+ * This class provides an {@link InputStream} wrapper for a Gecko nsIChannel (or really,
+ * nsIRequest).
+ */
+@WrapForJNI
+@AnyThread
+/* package */ class GeckoInputStream extends InputStream {
+ private static final String LOGTAG = "GeckoInputStream";
+
+ private LinkedList<ByteBuffer> mBuffers = new LinkedList<>();
+ private boolean mEOF;
+ private boolean mClosed;
+ private boolean mHaveError;
+ private long mReadTimeout;
+ private boolean mResumed;
+ private Support mSupport;
+
+ /**
+ * This is only called via JNI. The support instance provides callbacks for the native
+ * counterpart.
+ *
+ * @param support An instance of {@link Support}, used for native callbacks.
+ */
+ /* package */ GeckoInputStream(final @Nullable Support support) {
+ mSupport = support;
+ }
+
+ public void setReadTimeoutMillis(final long millis) {
+ mReadTimeout = millis;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ super.close();
+ mClosed = true;
+
+ if (mSupport != null) {
+ mSupport.close();
+ mSupport = null;
+ }
+ }
+
+ @Override
+ public synchronized int available() throws IOException {
+ if (mClosed) {
+ return 0;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ return buf != null ? buf.remaining() : 0;
+ }
+
+ private void ensureNotClosed() throws IOException {
+ if (mClosed) {
+ throw new IOException("Stream is closed");
+ }
+ }
+
+ @Override
+ public synchronized int read() throws IOException {
+ ensureNotClosed();
+
+ final int expect = Integer.SIZE / 8;
+ final byte[] bytes = new byte[expect];
+
+ int count = 0;
+ while (count < expect) {
+ final long bytesRead = read(bytes, count, expect - count);
+ if (bytesRead < 0) {
+ return -1;
+ }
+
+ count += bytesRead;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ return buffer.getInt();
+ }
+
+ @Override
+ public int read(final @NonNull byte[] b) throws IOException {
+ return read(b, 0, b.length);
+ }
+
+ @Override
+ public synchronized int read(final @NonNull byte[] dest, final int offset, final int length)
+ throws IOException {
+ ensureNotClosed();
+
+ final long startTime = System.currentTimeMillis();
+ while (!mEOF && mBuffers.size() == 0) {
+ if (mReadTimeout > 0 && (System.currentTimeMillis() - startTime) >= mReadTimeout) {
+ throw new IOException("Timed out");
+ }
+
+ // The underlying channel is suspended, so resume that before
+ // waiting for a buffer.
+ if (!mResumed) {
+ if (mSupport != null) {
+ mSupport.resume();
+ }
+ mResumed = true;
+ }
+
+ try {
+ wait(mReadTimeout);
+ } catch (final InterruptedException e) {
+ }
+ }
+
+ if (mEOF && mBuffers.size() == 0) {
+ if (mHaveError) {
+ throw new IOException("Unknown error");
+ }
+
+ // We have no data and we're not expecting more.
+ return -1;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ final int readCount = Math.min(length, buf.remaining());
+ buf.get(dest, offset, readCount);
+
+ if (buf.remaining() == 0) {
+ // We're done with this buffer, advance the queue.
+ mBuffers.removeFirst();
+ }
+
+ return readCount;
+ }
+
+ /** Called by native code to indicate that no more data will be sent via {@link #appendBuffer}. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendEof() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ notifyAll();
+ }
+
+ /** Called by native code to indicate that there was an error while reading the stream. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendError() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ mHaveError = true;
+ notifyAll();
+ }
+
+ /**
+ * Called by native code to provide data for this stream.
+ *
+ * @param buf the bytes
+ * @throws IOException
+ */
+ @WrapForJNI(exceptionMode = "nsresult", calledFrom = "gecko")
+ /* package */ synchronized void appendBuffer(final byte[] buf) throws IOException {
+
+ if (mClosed) {
+ throw new IllegalStateException("Stream is closed");
+ }
+
+ if (mEOF) {
+ throw new IllegalStateException("EOF, no more data expected");
+ }
+
+ mBuffers.add(ByteBuffer.wrap(buf));
+ notifyAll();
+ }
+
+ @WrapForJNI
+ private static class Support extends JNIObject {
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void resume();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void close();
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java
new file mode 100644
index 0000000000..c991913b75
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java
@@ -0,0 +1,1072 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.SystemClock;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.SimpleArrayMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.TimeoutException;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.IXPCOMEventTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+
+/**
+ * GeckoResult is a class that represents an asynchronous result. The result is initially pending,
+ * and at a later time, the result may be completed with {@link #complete a value} or {@link
+ * #completeExceptionally an exception} depending on the outcome of the asynchronous operation. For
+ * example,
+ *
+ * <pre>
+ * public GeckoResult&lt;Integer&gt; divide(final int dividend, final int divisor) {
+ * final GeckoResult&lt;Integer&gt; result = new GeckoResult&lt;&gt;();
+ * (new Thread(() -&gt; {
+ * if (divisor != 0) {
+ * result.complete(dividend / divisor);
+ * } else {
+ * result.completeExceptionally(new ArithmeticException("Dividing by zero"));
+ * }
+ * })).start();
+ * return result;
+ * }</pre>
+ *
+ * <p>To retrieve the completed value or exception, use one of the {@link #then} methods to register
+ * listeners on the result. Listeners are run on the thread where the GeckoResult is created if a
+ * {@link Looper} is present. For example, to retrieve a completed value,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // value == 21
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * });</pre>
+ *
+ * <p>And to retrieve a completed exception,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>{@link #then} calls may be chained to complete multiple asynchonous operations in sequence.
+ * This example takes an integer, converts it to a String, and appends it to another String,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * return GeckoResult.fromValue(value.toString());
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final String value) {
+ * return GeckoResult.fromValue("42 / 2 = " + value);
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "42 / 2 = 21"
+ * return null;
+ * }
+ * });</pre>
+ *
+ * <p>Chaining works with exception listeners as well. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * return "foo";
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "foo"
+ * }
+ * });</pre>
+ *
+ * <p>A completed value/exception will propagate down the chain even if an intermediate step does
+ * not have a value/exception listener. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>However, any propagated value will be coerced to null. For example,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == null
+ * }
+ * });</pre>
+ *
+ * <p>If a GeckoResult is created on a thread without a {@link Looper}, {@link
+ * #then(OnValueListener, OnExceptionListener)} is unusable (and will throw {@link
+ * IllegalThreadStateException}). In this scenario, the value is only available via {@link
+ * #poll(long)}. Alternatively, you may also chain the GeckoResult to one with a {@link Handler} via
+ * {@link #withHandler(Handler)}. You may then use {@link #then(OnValueListener,
+ * OnExceptionListener)} on the returned GeckoResult normally.
+ *
+ * <p>Any exception thrown by a listener are automatically used to complete the result. At the end
+ * of every chain, there is an implicit exception listener that rethrows any uncaught and unhandled
+ * exception as {@link UncaughtException}. The following example will cause {@link
+ * UncaughtException} to be thrown because {@code BazException} is uncaught and unhandled at the end
+ * of the chain,
+ *
+ * <pre>
+ * GeckoResult.fromValue(42).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) throws FooException {
+ * throw new FooException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Exception {
+ * // exception instanceof FooException
+ * throw new BarException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Throwable {
+ * // exception instanceof BarException
+ * return new BazException();
+ * }
+ * });</pre>
+ *
+ * @param <T> The type of the value delivered via the GeckoResult.
+ */
+@AnyThread
+public class GeckoResult<T> {
+ private static final String LOGTAG = "GeckoResult";
+
+ private interface Dispatcher {
+ void dispatch(Runnable r);
+ }
+
+ private static class HandlerDispatcher implements Dispatcher {
+ HandlerDispatcher(final Handler h) {
+ mHandler = h;
+ }
+
+ public void dispatch(final Runnable r) {
+ mHandler.post(r);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof HandlerDispatcher)) {
+ return false;
+ }
+ return mHandler.equals(((HandlerDispatcher) other).mHandler);
+ }
+
+ @Override
+ public int hashCode() {
+ return mHandler.hashCode();
+ }
+
+ Handler mHandler;
+ }
+
+ private static class XPCOMEventTargetDispatcher implements Dispatcher {
+ private IXPCOMEventTarget mEventTarget;
+
+ public XPCOMEventTargetDispatcher(final IXPCOMEventTarget eventTarget) {
+ mEventTarget = eventTarget;
+ }
+
+ @Override
+ public void dispatch(final Runnable r) {
+ mEventTarget.execute(r);
+ }
+ }
+
+ private static class DirectDispatcher implements Dispatcher {
+ public void dispatch(final Runnable r) {
+ r.run();
+ }
+
+ static DirectDispatcher sInstance = new DirectDispatcher();
+
+ private DirectDispatcher() {}
+ }
+
+ public static final class UncaughtException extends RuntimeException {
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public UncaughtException(final Throwable cause) {
+ super(cause);
+ }
+ }
+
+ /** Interface used to delegate cancellation operations for a {@link GeckoResult}. */
+ @AnyThread
+ public interface CancellationDelegate {
+
+ /**
+ * This method should attempt to cancel the in-progress operation for the result to which this
+ * instance was attached. See {@link GeckoResult#cancel()} for more details.
+ *
+ * @return A {@link GeckoResult} resolving to "true" if cancellation was successful, "false"
+ * otherwise.
+ */
+ default @NonNull GeckoResult<Boolean> cancel() {
+ return GeckoResult.fromValue(false);
+ }
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#DENY}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> deny() {
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#ALLOW}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> allow() {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ // The default dispatcher for listeners on this GeckoResult. Other dispatchers can be specified
+ // when the listener is registered.
+ private final Dispatcher mDispatcher;
+ private boolean mComplete;
+ private T mValue;
+ private Throwable mError;
+ private boolean mIsUncaughtError;
+ private SimpleArrayMap<Dispatcher, ArrayList<Runnable>> mListeners = new SimpleArrayMap<>();
+
+ private GeckoResult<?> mParent;
+ private CancellationDelegate mCancellationDelegate;
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ */
+ @WrapForJNI
+ public GeckoResult() {
+ if (ThreadUtils.isOnUiThread()) {
+ mDispatcher = new HandlerDispatcher(ThreadUtils.getUiHandler());
+ } else if (Looper.myLooper() != null) {
+ mDispatcher = new HandlerDispatcher(new Handler());
+ } else if (XPCOMEventTarget.launcherThread().isOnCurrentThread()) {
+ mDispatcher = new XPCOMEventTargetDispatcher(XPCOMEventTarget.launcherThread());
+ } else {
+ mDispatcher = null;
+ }
+ }
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ *
+ * @param handler This {@link Handler} will be used for dispatching listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)}.
+ */
+ public GeckoResult(final Handler handler) {
+ mDispatcher = new HandlerDispatcher(handler);
+ }
+
+ /**
+ * This constructs a result that is chained to the specified result.
+ *
+ * @param from The {@link GeckoResult} to copy.
+ */
+ public GeckoResult(final GeckoResult<T> from) {
+ this();
+ completeFrom(from);
+ }
+
+ /**
+ * Construct a result that is completed with the specified value.
+ *
+ * @param value The value used to complete the newly created result.
+ * @param <U> Type for the result.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <U> GeckoResult<U> fromValue(@Nullable final U value) {
+ final GeckoResult<U> result = new GeckoResult<>();
+ result.complete(value);
+ return result;
+ }
+
+ /**
+ * Construct a result that is completed with the specified {@link Throwable}. May not be null.
+ *
+ * @param error The exception used to complete the newly created result.
+ * @param <T> Type for the result if the result had been completed without exception.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <T> GeckoResult<T> fromException(@NonNull final Throwable error) {
+ final GeckoResult<T> result = new GeckoResult<>();
+ result.completeExceptionally(error);
+ return result;
+ }
+
+ @Override
+ public synchronized int hashCode() {
+ return Arrays.hashCode(new Object[] {mComplete, mValue, mError});
+ }
+
+ // This can go away once we can rely on java.util.Objects.equals() (API 19)
+ private static boolean objectEquals(final Object a, final Object b) {
+ return a == b || (a != null && a.equals(b));
+ }
+
+ @Override
+ public synchronized boolean equals(final Object other) {
+ if (other instanceof GeckoResult<?>) {
+ final GeckoResult<?> result = (GeckoResult<?>) other;
+ return result.mComplete == mComplete
+ && objectEquals(result.mError, mError)
+ && objectEquals(result.mValue, mValue);
+ }
+
+ return false;
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(@NonNull final OnValueListener<T, U> valueListener) {
+ return then(valueListener, null);
+ }
+
+ /**
+ * Convenience method for {@link #map(OnValueMapper, OnExceptionMapper)}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(@Nullable final OnValueMapper<T, U> valueMapper) {
+ return map(valueMapper, null);
+ }
+
+ /**
+ * Transform the value and error of this {@link GeckoResult}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionMapper An instance of {@link OnExceptionMapper}, called when the {@link
+ * GeckoResult} is completed with an exception.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(
+ @Nullable final OnValueMapper<T, U> valueMapper,
+ @Nullable final OnExceptionMapper exceptionMapper) {
+ final OnValueListener<T, U> valueListener =
+ valueMapper != null ? value -> GeckoResult.fromValue(valueMapper.onValue(value)) : null;
+ final OnExceptionListener<U> exceptionListener =
+ exceptionMapper != null
+ ? error -> GeckoResult.fromException(exceptionMapper.onException(error))
+ : null;
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Exception}.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> exceptionally(
+ @NonNull final OnExceptionListener<U> exceptionListener) {
+ return then(null, exceptionListener);
+ }
+
+ /**
+ * Replacement for {@link java.util.function.Consumer} for devices with minApi &lt; 24.
+ *
+ * @param <T> the type of the input for this consumer.
+ */
+ // TODO: Remove this when we move to min API 24
+ public interface Consumer<T> {
+ /**
+ * Run this consumer for the given input.
+ *
+ * @param t the input value.
+ */
+ @AnyThread
+ void accept(@Nullable T t);
+ }
+
+ /**
+ * Convenience method for {@link #accept(Consumer, Consumer)}.
+ *
+ * @param valueListener An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(@Nullable final Consumer<T> valueListener) {
+ return accept(valueListener, null);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueConsumer An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionConsumer An instance of {@link Consumer}, called when the {@link GeckoResult}
+ * is completed with an {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ final OnValueListener<T, Void> valueListener =
+ valueConsumer == null
+ ? null
+ : value -> {
+ valueConsumer.accept(value);
+ return null;
+ };
+
+ final OnExceptionListener<Void> exceptionListener =
+ exceptionConsumer == null
+ ? null
+ : value -> {
+ exceptionConsumer.accept(value);
+ return null;
+ };
+
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed regardless of success
+ * status. Listeners will be invoked on the {@link Looper} returned from {@link #getLooper()}. If
+ * null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param finallyRunnable An instance of {@link Runnable}, called when the {@link GeckoResult} is
+ * completed with a value or a {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> finally_(@NonNull final Runnable finallyRunnable) {
+ final OnValueListener<T, Void> valueListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ final OnExceptionListener<Void> exceptionListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ return then(valueListener, exceptionListener);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(@Nullable final Consumer<T> valueConsumer) {
+ return getOrAccept(valueConsumer, null);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ if (haveValue() && valueConsumer != null) {
+ valueConsumer.accept(mValue);
+ return GeckoResult.fromValue(null);
+ }
+
+ if (haveError() && exceptionConsumer != null) {
+ exceptionConsumer.accept(mError);
+ return GeckoResult.fromValue(null);
+ }
+
+ return accept(valueConsumer, exceptionConsumer);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Throwable}.
+ * @param <U> Type of the new result that is returned by the listeners.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (mDispatcher == null) {
+ throw new IllegalThreadStateException("Must have a Handler");
+ }
+
+ return thenInternal(mDispatcher, valueListener, exceptionListener);
+ }
+
+ private @NonNull <U> GeckoResult<U> thenInternal(
+ @NonNull final Dispatcher dispatcher,
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (valueListener == null && exceptionListener == null) {
+ throw new IllegalArgumentException("At least one listener should be non-null");
+ }
+
+ final GeckoResult<U> result = new GeckoResult<U>();
+ result.mParent = this;
+ thenInternal(
+ dispatcher,
+ () -> {
+ try {
+ if (haveValue()) {
+ result.completeFrom(valueListener != null ? valueListener.onValue(mValue) : null);
+ } else if (!haveError()) {
+ // Listener called without completion?
+ throw new AssertionError();
+ } else if (exceptionListener != null) {
+ result.completeFrom(exceptionListener.onException(mError));
+ } else {
+ result.mIsUncaughtError = mIsUncaughtError;
+ result.completeExceptionally(mError);
+ }
+ } catch (final Throwable e) {
+ if (!result.mComplete) {
+ result.mIsUncaughtError = true;
+ result.completeExceptionally(e);
+ } else if (e instanceof RuntimeException) {
+ // This should only be UncaughtException, but we rethrow all RuntimeExceptions
+ // to avoid squelching logic errors in GeckoResult itself.
+ throw (RuntimeException) e;
+ }
+ }
+ });
+ return result;
+ }
+
+ private synchronized void thenInternal(
+ @NonNull final Dispatcher dispatcher, @NonNull final Runnable listener) {
+ if (mComplete) {
+ dispatcher.dispatch(listener);
+ } else {
+ if (!mListeners.containsKey(dispatcher)) {
+ mListeners.put(dispatcher, new ArrayList<>(1));
+ }
+ mListeners.get(dispatcher).add(listener);
+ }
+ }
+
+ @WrapForJNI
+ private void nativeThen(
+ @NonNull final GeckoCallback accept, @NonNull final GeckoCallback reject) {
+ // NB: We could use the lambda syntax here, but given all the layers
+ // of abstraction it's helpful to see the types written explicitly.
+ thenInternal(
+ DirectDispatcher.sInstance,
+ new OnValueListener<T, Void>() {
+ @Override
+ public GeckoResult<Void> onValue(final T value) {
+ accept.call(value);
+ return null;
+ }
+ },
+ new OnExceptionListener<Void>() {
+ @Override
+ public GeckoResult<Void> onException(final Throwable exception) {
+ reject.call(exception);
+ return null;
+ }
+ });
+ }
+
+ /**
+ * @return Get the {@link Looper} that will be used to schedule listeners registered via {@link
+ * #then(OnValueListener, OnExceptionListener)}.
+ */
+ public @Nullable Looper getLooper() {
+ if (mDispatcher == null || !(mDispatcher instanceof HandlerDispatcher)) {
+ return null;
+ }
+
+ return ((HandlerDispatcher) mDispatcher).mHandler.getLooper();
+ }
+
+ /**
+ * Returns a new GeckoResult that will be completed by this instance. Listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)} will be run on the specified {@link
+ * Handler}.
+ *
+ * @param handler A {@link Handler} where listeners will be run. May be null.
+ * @return A new GeckoResult.
+ */
+ public @NonNull GeckoResult<T> withHandler(final @Nullable Handler handler) {
+ final GeckoResult<T> result = new GeckoResult<>(handler);
+ result.completeFrom(this);
+ return result;
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @SuppressWarnings("varargs")
+ @SafeVarargs
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @NonNull GeckoResult<V>... pending) {
+ return allOf(Arrays.asList(pending));
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @Nullable List<GeckoResult<V>> pending) {
+ if (pending == null) {
+ return GeckoResult.fromValue(null);
+ }
+
+ return new AllOfResult<>(pending);
+ }
+
+ private static class AllOfResult<V> extends GeckoResult<List<V>> {
+ private boolean mFailed = false;
+ private int mResultCount = 0;
+ private final List<V> mAccumulator;
+ private final List<GeckoResult<V>> mPending;
+
+ public AllOfResult(final @NonNull List<GeckoResult<V>> pending) {
+ // Initialize the list with nulls so we can fill it in the same order as the input list
+ mAccumulator = new ArrayList<>(Collections.nCopies(pending.size(), null));
+ mPending = pending;
+
+ // If the input list is empty, there's nothing to do
+ if (pending.size() == 0) {
+ complete(mAccumulator);
+ return;
+ }
+
+ // We use iterators so we can access the index and preserve the list order
+ final ListIterator<GeckoResult<V>> it = pending.listIterator();
+ while (it.hasNext()) {
+ final int index = it.nextIndex();
+ it.next().accept(value -> onResult(value, index), this::onError);
+ }
+ }
+
+ private void onResult(final V value, final int index) {
+ if (mFailed) {
+ // Some other element in the list already failed, nothing to do here
+ return;
+ }
+
+ mResultCount++;
+ mAccumulator.set(index, value);
+
+ if (mResultCount == mPending.size()) {
+ complete(mAccumulator);
+ }
+ }
+
+ private void onError(final Throwable error) {
+ mFailed = true;
+ completeExceptionally(error);
+ }
+ }
+
+ private void dispatchLocked() {
+ if (!mComplete) {
+ throw new IllegalStateException("Cannot dispatch unless result is complete");
+ }
+
+ if (mListeners.isEmpty()) {
+ if (mIsUncaughtError) {
+ // We have no listeners to forward the uncaught exception to;
+ // rethrow the exception to make it visible.
+ throw new UncaughtException(mError);
+ }
+ return;
+ }
+
+ if (mDispatcher == null) {
+ throw new AssertionError("Shouldn't have listeners with null dispatcher");
+ }
+
+ for (int i = 0; i < mListeners.size(); ++i) {
+ final Dispatcher dispatcher = mListeners.keyAt(i);
+ final ArrayList<Runnable> jobs = mListeners.valueAt(i);
+ dispatcher.dispatch(
+ () -> {
+ for (final Runnable job : jobs) {
+ job.run();
+ }
+ });
+ }
+ mListeners.clear();
+ }
+
+ /**
+ * Completes this result based on another result.
+ *
+ * @param other The result that this result should mirror
+ */
+ public void completeFrom(final @Nullable GeckoResult<T> other) {
+ if (other == null) {
+ complete(null);
+ return;
+ }
+
+ this.mCancellationDelegate = other.mCancellationDelegate;
+ other.thenInternal(
+ DirectDispatcher.sInstance,
+ () -> {
+ if (other.haveValue()) {
+ complete(other.mValue);
+ } else {
+ mIsUncaughtError = other.mIsUncaughtError;
+ completeExceptionally(other.mError);
+ }
+ });
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>You must not call this method if the current thread has a {@link Looper} due to the
+ * possibility of a deadlock. If this occurs, {@link IllegalStateException} is thrown.
+ *
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws IllegalThreadStateException if this method is called on a thread that has a {@link
+ * Looper}.
+ */
+ public synchronized @Nullable T poll() throws Throwable {
+ if (Looper.myLooper() != null) {
+ throw new IllegalThreadStateException("Cannot poll indefinitely from thread with Looper");
+ }
+
+ return poll(Long.MAX_VALUE);
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>Caution is advised if the caller is on a thread with a {@link Looper}, as it's possible to
+ * effectively deadlock in cases when the work is being completed on the calling thread. It's
+ * preferable to use {@link #then(OnValueListener, OnExceptionListener)} in such circumstances,
+ * but if you must use this method consider a small timeout value.
+ *
+ * @param timeoutMillis Number of milliseconds to wait for the result to complete.
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws TimeoutException if we wait more than timeoutMillis before the result is completed.
+ */
+ public synchronized @Nullable T poll(final long timeoutMillis) throws Throwable {
+ final long start = SystemClock.uptimeMillis();
+ long remaining = timeoutMillis;
+ while (!mComplete && remaining > 0) {
+ try {
+ wait(remaining);
+ } catch (final InterruptedException e) {
+ }
+
+ remaining = timeoutMillis - (SystemClock.uptimeMillis() - start);
+ }
+
+ if (!mComplete) {
+ throw new TimeoutException();
+ }
+
+ if (haveError()) {
+ throw mError;
+ }
+
+ return mValue;
+ }
+
+ /**
+ * Complete the result with the specified value. IllegalStateException is thrown if the result is
+ * already complete.
+ *
+ * @param value The value used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void complete(final @Nullable T value) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ mValue = value;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * Complete the result with the specified {@link Throwable}. IllegalStateException is thrown if
+ * the result is already complete.
+ *
+ * @param exception The {@link Throwable} used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void completeExceptionally(@NonNull final Throwable exception) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ if (exception == null) {
+ throw new IllegalArgumentException("Throwable must not be null");
+ }
+
+ mError = exception;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * An interface used to deliver values to listeners of a {@link GeckoResult}
+ *
+ * @param <T> Type of the value delivered via {@link #onValue(Object)}
+ * @param <U> Type of the value for the result returned from {@link #onValue(Object)}
+ */
+ public interface OnValueListener<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<U> onValue(@Nullable T value) throws Throwable;
+ }
+
+ /**
+ * An interface used to map {@link GeckoResult} values.
+ *
+ * @param <T> Type of the value delivered via {@link #onValue}
+ * @param <U> Type of the new value returned by {@link #onValue}
+ */
+ public interface OnValueMapper<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Value used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ U onValue(@Nullable T value) throws Throwable;
+ }
+
+ /** An interface used to map {@link GeckoResult} exceptions. */
+ public interface OnExceptionMapper {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Exception used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ Throwable onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ /**
+ * An interface used to deliver exceptions to listeners of a {@link GeckoResult}
+ *
+ * @param <V> Type of the vale for the result returned from {@link #onException(Throwable)}
+ */
+ public interface OnExceptionListener<V> {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<V> onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ @WrapForJNI
+ private static class GeckoCallback extends JNIObject {
+ private native void call(Object arg);
+
+ @Override
+ protected native void disposeNative();
+ }
+
+ private boolean haveValue() {
+ return mComplete && mError == null;
+ }
+
+ private boolean haveError() {
+ return mComplete && mError != null;
+ }
+
+ /**
+ * Attempts to cancel the operation associated with this result.
+ *
+ * <p>If this result has a {@link CancellationDelegate} attached via {@link
+ * #setCancellationDelegate(CancellationDelegate)}, the return value will be the result of calling
+ * {@link CancellationDelegate#cancel()} on that instance. Otherwise, if this result is chained to
+ * another result (via return value from {@link OnValueListener}), we will walk up the chain until
+ * a CancellationDelegate is found and run it. If no CancellationDelegate is found, a result
+ * resolving to "false" will be returned.
+ *
+ * <p>If this result is already complete, the returned result will always resolve to false.
+ *
+ * <p>If the returned result resolves to true, this result will be completed with a {@link
+ * CancellationException}.
+ *
+ * @return A GeckoResult resolving to a boolean indicating success or failure of the cancellation
+ * attempt.
+ */
+ public synchronized @NonNull GeckoResult<Boolean> cancel() {
+ if (haveValue() || haveError()) {
+ return GeckoResult.fromValue(false);
+ }
+
+ if (mCancellationDelegate != null) {
+ return mCancellationDelegate
+ .cancel()
+ .then(
+ value -> {
+ if (value) {
+ try {
+ this.completeExceptionally(new CancellationException());
+ } catch (final IllegalStateException e) {
+ // Can't really do anything about this.
+ }
+ }
+ return GeckoResult.fromValue(value);
+ });
+ }
+
+ if (mParent != null) {
+ return mParent.cancel();
+ }
+
+ return GeckoResult.fromValue(false);
+ }
+
+ /**
+ * Sets the instance of {@link CancellationDelegate} that will be invoked by {@link #cancel()}.
+ *
+ * @param delegate an instance of CancellationDelegate.
+ */
+ public void setCancellationDelegate(final @Nullable CancellationDelegate delegate) {
+ mCancellationDelegate = delegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
new file mode 100644
index 0000000000..aa5f01da59
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
@@ -0,0 +1,1050 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.app.ActivityManager;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Process;
+import android.provider.Settings;
+import android.text.format.DateFormat;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+import androidx.lifecycle.ProcessLifecycleOwner;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.List;
+import java.util.Map;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.GeckoScreenChangeListener;
+import org.mozilla.gecko.GeckoScreenOrientation;
+import org.mozilla.gecko.GeckoScreenOrientation.ScreenOrientation;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.process.MemoryController;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.DebugConfig;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public final class GeckoRuntime implements Parcelable {
+ private static final String LOGTAG = "GeckoRuntime";
+ private static final boolean DEBUG = false;
+
+ private static final String CONFIG_FILE_PATH_TEMPLATE =
+ "/data/local/tmp/%s-geckoview-config.yaml";
+
+ /**
+ * Intent action sent to the crash handler when a crash is encountered.
+ *
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ */
+ public static final String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a String with the
+ * path to a Breakpad minidump file containing information about the crash. Several crash
+ * reporters are able to ingest this in a crash report, including <a
+ * href="https://sentry.io">Sentry</a> and Mozilla's <a
+ * href="https://wiki.mozilla.org/Socorro">Socorro</a>. <br>
+ * <br>
+ * Be aware, the minidump can contain personally identifiable information. Ensure you are obeying
+ * all applicable laws and policies before sending this to a remote server.
+ *
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ */
+ public static final String EXTRA_MINIDUMP_PATH = "minidumpPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a string with the
+ * path to a file containing extra metadata about the crash. The file contains key-value pairs in
+ * the form
+ *
+ * <pre>Key=Value</pre>
+ *
+ * Be aware, it may contain sensitive data such as the URI that was loaded at the time of the
+ * crash.
+ */
+ public static final String EXTRA_EXTRAS_PATH = "extrasPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String matching
+ * one of the `CRASHED_PROCESS_TYPE_*` constants, describing what type of process the crash
+ * occurred in.
+ *
+ * @see GeckoSession.ContentDelegate#onCrash(GeckoSession)
+ */
+ public static final String EXTRA_CRASH_PROCESS_TYPE = "processType";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating the main application process was
+ * affected by the crash, which is therefore fatal.
+ */
+ public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a foreground child process, such as a
+ * content process, crashed. The application may be able to recover from this crash, but it was
+ * likely noticable to the user.
+ */
+ public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a background child process crashed. This
+ * should have been recovered from automatically, and will have had minimal impact to the user, if
+ * any.
+ */
+ public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD";
+
+ private final MemoryController mMemoryController = new MemoryController();
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(
+ value = {
+ CRASHED_PROCESS_TYPE_MAIN,
+ CRASHED_PROCESS_TYPE_FOREGROUND_CHILD,
+ CRASHED_PROCESS_TYPE_BACKGROUND_CHILD
+ })
+ public @interface CrashedProcessType {}
+
+ private final class LifecycleListener implements LifecycleObserver {
+ private boolean mPaused = false;
+
+ public LifecycleListener() {}
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ void onCreate() {
+ Log.d(LOGTAG, "Lifecycle: onCreate");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_START)
+ void onStart() {
+ Log.d(LOGTAG, "Lifecycle: onStart");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ void onResume() {
+ Log.d(LOGTAG, "Lifecycle: onResume");
+ if (mPaused) {
+ // Do not trigger the first onResume event because it breaks nsAppShell::sPauseCount counter
+ // thresholds.
+ GeckoThread.onResume();
+ }
+ mPaused = false;
+ // Can resume location services, checks if was in use before going to background
+ GeckoAppShell.resumeLocation();
+ // Monitor network status and send change notifications to Gecko
+ // while active.
+ GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext());
+
+ // Set settings that may have changed between last app opening
+ GeckoAppShell.setIs24HourFormat(
+ DateFormat.is24HourFormat(GeckoAppShell.getApplicationContext()));
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ void onPause() {
+ Log.d(LOGTAG, "Lifecycle: onPause");
+ mPaused = true;
+ // Pause listening for locations when in background
+ GeckoAppShell.pauseLocation();
+ // Stop monitoring network status while inactive.
+ GeckoNetworkManager.getInstance().stop();
+ GeckoThread.onPause();
+ }
+ }
+
+ private static GeckoRuntime sDefaultRuntime;
+
+ /**
+ * Get the default runtime for the given context. This will create and initialize the runtime with
+ * the default settings.
+ *
+ * <p>Note: Only use this for session-less apps. For regular apps, use create() instead.
+ *
+ * @param context An application context for the default runtime.
+ * @return The (static) default runtime for the context.
+ */
+ @UiThread
+ public static synchronized @NonNull GeckoRuntime getDefault(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "getDefault");
+ }
+ if (sDefaultRuntime == null) {
+ sDefaultRuntime = new GeckoRuntime();
+ sDefaultRuntime.attachTo(context);
+ sDefaultRuntime.init(context, new GeckoRuntimeSettings());
+ }
+
+ return sDefaultRuntime;
+ }
+
+ private static GeckoRuntime sRuntime;
+ private GeckoRuntimeSettings mSettings;
+ private Delegate mDelegate;
+ private ServiceWorkerDelegate mServiceWorkerDelegate;
+ private WebNotificationDelegate mNotificationDelegate;
+ private ActivityDelegate mActivityDelegate;
+ private OrientationController mOrientationController;
+ private StorageController mStorageController;
+ private final WebExtensionController mWebExtensionController;
+ private WebPushController mPushController;
+ private final ContentBlockingController mContentBlockingController;
+ private final Autocomplete.StorageProxy mAutocompleteStorageProxy;
+ private final ProfilerController mProfilerController;
+ private final GeckoScreenChangeListener mScreenChangeListener;
+
+ private GeckoRuntime() {
+ mWebExtensionController = new WebExtensionController(this);
+ mContentBlockingController = new ContentBlockingController();
+ mAutocompleteStorageProxy = new Autocomplete.StorageProxy();
+ mProfilerController = new ProfilerController();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ mScreenChangeListener = new GeckoScreenChangeListener();
+ } else {
+ mScreenChangeListener = null;
+ }
+
+ if (sRuntime != null) {
+ throw new IllegalStateException("Only one GeckoRuntime instance is allowed");
+ }
+ sRuntime = this;
+ }
+
+ @WrapForJNI
+ @UiThread
+ /* package */ @Nullable
+ static GeckoRuntime getInstance() {
+ return sRuntime;
+ }
+
+ /**
+ * Called by mozilla::dom::ClientOpenWindow to retrieve the window id to use for a
+ * ServiceWorkerClients.openWindow() request.
+ *
+ * @param url validated Url being requested to be opened in a new window.
+ * @return SessionID to use for the request.
+ */
+ @SuppressLint("WrongThread") // for .isOpen() which is called on the UI thread
+ @WrapForJNI(calledFrom = "gecko")
+ private static @NonNull GeckoResult<String> serviceWorkerOpenWindow(final @NonNull String url) {
+ if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) {
+ final GeckoResult<String> result = new GeckoResult<>();
+ // perform the onOpenWindow call in the UI thread
+ ThreadUtils.runOnUiThread(
+ () -> {
+ sRuntime
+ .mServiceWorkerDelegate
+ .onOpenWindow(url)
+ .accept(
+ session -> {
+ if (session != null) {
+ if (!session.isOpen()) {
+ session.open(sRuntime);
+ }
+ session.loadUri(url);
+ result.complete(session.getId());
+ } else {
+ result.complete(null);
+ }
+ });
+ });
+ return result;
+ } else {
+ return GeckoResult.fromException(
+ new java.lang.RuntimeException("No available Service Worker delegate."));
+ }
+ }
+
+ /**
+ * Attach the runtime to the given context.
+ *
+ * @param context The new context to attach to.
+ */
+ @UiThread
+ public void attachTo(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "attachTo " + context.getApplicationContext());
+ }
+ final Context appContext = context.getApplicationContext();
+ if (!appContext.equals(GeckoAppShell.getApplicationContext())) {
+ GeckoAppShell.setApplicationContext(appContext);
+ }
+ }
+
+ private final BundleEventListener mEventListener =
+ new BundleEventListener() {
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ final Class<?> crashHandler = GeckoRuntime.this.getSettings().mCrashHandler;
+
+ if ("Gecko:Exited".equals(event) && mDelegate != null) {
+ mDelegate.onShutdown();
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mEventListener, "Gecko:Exited");
+ } else if ("GeckoView:Test:NewTab".equals(event)) {
+ final String url = message.getString("url", "about:blank");
+ serviceWorkerOpenWindow(url)
+ .then(
+ (GeckoResult.OnValueListener<String, Void>)
+ value -> {
+ callback.sendSuccess(value);
+ return null;
+ })
+ .exceptionally(
+ (GeckoResult.OnExceptionListener<Void>)
+ error -> {
+ callback.sendError(error + " Could not open tab.");
+ return null;
+ });
+ } else if ("GeckoView:ChildCrashReport".equals(event) && crashHandler != null) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent i = new Intent(ACTION_CRASHED, null, context, crashHandler);
+ i.putExtra(EXTRA_MINIDUMP_PATH, message.getString(EXTRA_MINIDUMP_PATH));
+ i.putExtra(EXTRA_EXTRAS_PATH, message.getString(EXTRA_EXTRAS_PATH));
+ i.putExtra(EXTRA_CRASH_PROCESS_TYPE, message.getString(EXTRA_CRASH_PROCESS_TYPE));
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(i);
+ } else {
+ context.startService(i);
+ }
+ }
+ }
+ };
+
+ private static String getProcessName(final Context context) {
+ final ActivityManager manager =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ final List<ActivityManager.RunningAppProcessInfo> infos = manager.getRunningAppProcesses();
+ if (infos == null) {
+ return null;
+ }
+ for (final ActivityManager.RunningAppProcessInfo info : infos) {
+ if (info.pid == Process.myPid()) {
+ return info.processName;
+ }
+ }
+
+ return null;
+ }
+
+ /* package */ boolean init(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "init");
+ }
+ int flags = GeckoThread.FLAG_PRELOAD_CHILD;
+
+ if (settings.getPauseForDebuggerEnabled()) {
+ flags |= GeckoThread.FLAG_DEBUGGING;
+ }
+
+ final Class<?> crashHandler = settings.getCrashHandler();
+ if (crashHandler != null) {
+ try {
+ final ServiceInfo info =
+ context.getPackageManager().getServiceInfo(new ComponentName(context, crashHandler), 0);
+ if (info.processName.equals(getProcessName(context))) {
+ throw new IllegalArgumentException(
+ "Crash handler service must run in a separate process");
+ }
+
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "GeckoView:ChildCrashReport");
+
+ flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new IllegalArgumentException("Crash handler must be registered as a service");
+ }
+ }
+
+ GeckoAppShell.useMaxScreenDepth(settings.getUseMaxScreenDepth());
+ GeckoAppShell.setDisplayDensityOverride(settings.getDisplayDensityOverride());
+ GeckoAppShell.setDisplayDpiOverride(settings.getDisplayDpiOverride());
+ GeckoAppShell.setScreenSizeOverride(settings.getScreenSizeOverride());
+ GeckoAppShell.setCrashHandlerService(settings.getCrashHandler());
+ GeckoFontScaleListener.getInstance().attachToContext(context, settings);
+
+ Bundle extras = settings.getExtras();
+ String[] args = settings.getArguments();
+ Map<String, Object> prefs = settings.getPrefsMap();
+
+ // Older versions have problems with SnakeYaml
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ String configFilePath = settings.getConfigFilePath();
+ if (configFilePath == null) {
+ // Default to /data/local/tmp/$PACKAGE-geckoview-config.yaml if android:debuggable="true"
+ // or if this application is the current Android "debug_app", and to not read configuration
+ // from a file otherwise.
+ if (isApplicationDebuggable(context) || isApplicationCurrentDebugApp(context)) {
+ configFilePath =
+ String.format(CONFIG_FILE_PATH_TEMPLATE, context.getApplicationInfo().packageName);
+ }
+ }
+
+ if (configFilePath != null && !configFilePath.isEmpty()) {
+ try {
+ final DebugConfig debugConfig = DebugConfig.fromFile(new File(configFilePath));
+ Log.i(LOGTAG, "Adding debug configuration from: " + configFilePath);
+ prefs = debugConfig.mergeIntoPrefs(prefs);
+ args = debugConfig.mergeIntoArgs(args);
+ extras = debugConfig.mergeIntoExtras(extras);
+ } catch (final DebugConfig.ConfigException e) {
+ Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e);
+ } catch (final FileNotFoundException e) {
+ }
+ }
+ }
+
+ final GeckoThread.InitInfo info =
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .extras(extras)
+ .flags(flags)
+ .prefs(prefs)
+ .outFilePath(extras != null ? extras.getString("out_file") : null)
+ .build();
+
+ if (info.xpcshell
+ && !"org.mozilla.geckoview.test_runner"
+ .equals(context.getApplicationContext().getPackageName())) {
+ throw new IllegalArgumentException("Only the test app can run -xpcshell.");
+ }
+
+ if (info.xpcshell) {
+ // Xpcshell tests need multi-e10s to work properly
+ settings.setProcessCount(BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_COUNT);
+ }
+
+ if (!GeckoThread.init(info)) {
+ Log.w(LOGTAG, "init failed (could not initiate GeckoThread)");
+ return false;
+ }
+
+ if (!GeckoThread.launch()) {
+ Log.w(LOGTAG, "init failed (GeckoThread already launched)");
+ return false;
+ }
+
+ mSettings = settings;
+
+ // Bug 1453062 -- the EventDispatcher should really live here (or in GeckoThread)
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "Gecko:Exited", "GeckoView:Test:NewTab");
+
+ // Attach and commit settings.
+ mSettings.attachTo(this);
+
+ // Initialize the system ClipboardManager by accessing it on the main thread.
+ GeckoAppShell.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE);
+
+ // Add process lifecycle listener to react to backgrounding events.
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleListener());
+
+ // Add Display Manager listener to listen screen orientation change.
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.enable();
+ }
+
+ mProfilerController.addMarker(
+ "GeckoView Initialization START", mProfilerController.getProfilerTime());
+ return true;
+ }
+
+ private boolean isApplicationDebuggable(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+ return (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
+ }
+
+ private boolean isApplicationCurrentDebugApp(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+
+ final String currentDebugApp;
+ if (Build.VERSION.SDK_INT >= 17) {
+ currentDebugApp =
+ Settings.Global.getString(context.getContentResolver(), Settings.Global.DEBUG_APP);
+ } else {
+ currentDebugApp =
+ Settings.System.getString(context.getContentResolver(), Settings.System.DEBUG_APP);
+ }
+ return applicationInfo.packageName.equals(currentDebugApp);
+ }
+
+ /* package */ void setDefaultPrefs(final GeckoBundle prefs) {
+ EventDispatcher.getInstance().dispatch("GeckoView:SetDefaultPrefs", prefs);
+ }
+
+ /**
+ * Create a new runtime with default settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ return create(context, new GeckoRuntimeSettings());
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoRuntime.
+ *
+ * @return an instance of {@link WebExtensionController}.
+ */
+ @UiThread
+ public @NonNull WebExtensionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Returns the ContentBlockingController for this GeckoRuntime.
+ *
+ * @return An instance of {@link ContentBlockingController}.
+ */
+ @UiThread
+ public @NonNull ContentBlockingController getContentBlockingController() {
+ return mContentBlockingController;
+ }
+
+ /**
+ * Returns a ProfilerController for this GeckoRuntime.
+ *
+ * @return an instance of {@link ProfilerController}.
+ */
+ @UiThread
+ public @NonNull ProfilerController getProfilerController() {
+ return mProfilerController;
+ }
+
+ /**
+ * Create a new runtime with the given settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @param settings The settings for the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "create " + context);
+ }
+
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.attachTo(context);
+
+ if (!runtime.init(context, settings)) {
+ throw new IllegalStateException("Failed to initialize GeckoRuntime");
+ }
+
+ context.registerComponentCallbacks(runtime.mMemoryController);
+
+ return runtime;
+ }
+
+ /** Shutdown the runtime. This will invalidate all attached sessions. */
+ @AnyThread
+ public void shutdown() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "shutdown");
+ }
+
+ GeckoSystemStateListener.getInstance().shutdown();
+
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.disable();
+ }
+
+ GeckoThread.forceQuit();
+ }
+
+ public interface Delegate {
+ /**
+ * This is called when the runtime shuts down. Any GeckoSession instances that were opened with
+ * this instance are now considered closed.
+ */
+ @UiThread
+ void onShutdown();
+ }
+
+ /**
+ * Set a delegate for receiving callbacks relevant to to this GeckoRuntime.
+ *
+ * @param delegate an implementation of {@link GeckoRuntime.Delegate}.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Returns the current delegate, if any.
+ *
+ * @return an instance of {@link GeckoRuntime.Delegate} or null if no delegate has been set.
+ */
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ /**
+ * Set the {@link Autocomplete.StorageDelegate} instance on this runtime. This delegate is
+ * required for handling autocomplete storage requests.
+ *
+ * @param delegate The {@link Autocomplete.StorageDelegate} handling autocomplete storage
+ * requests.
+ */
+ @UiThread
+ public void setAutocompleteStorageDelegate(
+ final @Nullable Autocomplete.StorageDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mAutocompleteStorageProxy.setDelegate(delegate);
+ }
+
+ /**
+ * Get the {@link Autocomplete.StorageDelegate} instance set on this runtime.
+ *
+ * @return The {@link Autocomplete.StorageDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable Autocomplete.StorageDelegate getAutocompleteStorageDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mAutocompleteStorageProxy.getDelegate();
+ }
+
+ @UiThread
+ public interface ServiceWorkerDelegate {
+
+ /**
+ * This is called when a service worker tries to open a new window using client.openWindow() The
+ * GeckoView application should provide an open {@link GeckoSession} to open the url.
+ *
+ * @param url Url which the Service Worker wishes to open in a new window.
+ * @return New or existing open {@link GeckoSession} in which to open the requested url.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow">openWindow()</a>
+ */
+ @UiThread
+ @NonNull
+ GeckoResult<GeckoSession> onOpenWindow(@NonNull String url);
+ }
+
+ /**
+ * Sets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @param serviceWorkerDelegate An instance of {@link ServiceWorkerDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ */
+ @UiThread
+ public void setServiceWorkerDelegate(
+ final @Nullable ServiceWorkerDelegate serviceWorkerDelegate) {
+ mServiceWorkerDelegate = serviceWorkerDelegate;
+ }
+
+ /**
+ * Gets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @return the {@link ServiceWorkerDelegate} instance set by {@link #setServiceWorkerDelegate}
+ */
+ @UiThread
+ @Nullable
+ public ServiceWorkerDelegate getServiceWorkerDelegate() {
+ return mServiceWorkerDelegate;
+ }
+
+ /**
+ * Sets the delegate to be used for handling Web Notifications.
+ *
+ * @param delegate An instance of {@link WebNotificationDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web
+ * Notifications</a>
+ */
+ @UiThread
+ public void setWebNotificationDelegate(final @Nullable WebNotificationDelegate delegate) {
+ mNotificationDelegate = delegate;
+ }
+
+ @WrapForJNI
+ /* package */ boolean usesDarkTheme() {
+ switch (getSettings().getPreferredColorScheme()) {
+ case GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM:
+ return GeckoSystemStateListener.getInstance().isNightMode();
+ case GeckoRuntimeSettings.COLOR_SCHEME_DARK:
+ return true;
+ case GeckoRuntimeSettings.COLOR_SCHEME_LIGHT:
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Returns the current WebNotificationDelegate, if any
+ *
+ * @return an instance of WebNotificationDelegate or null if no delegate has been set
+ */
+ @WrapForJNI
+ @UiThread
+ public @Nullable WebNotificationDelegate getWebNotificationDelegate() {
+ return mNotificationDelegate;
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private void notifyOnShow(final WebNotification notification) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ if (mNotificationDelegate != null) {
+ mNotificationDelegate.onShowNotification(notification);
+ }
+ });
+ }
+
+ @WrapForJNI
+ @AnyThread
+ private void notifyOnClose(final WebNotification notification) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ if (mNotificationDelegate != null) {
+ mNotificationDelegate.onCloseNotification(notification);
+ }
+ });
+ }
+
+ /**
+ * This is used to allow GeckoRuntime to start activities via the embedding application (and
+ * {@link android.app.Activity}). Currently this is used to invoke the Google Play FIDO Activity
+ * in order to integrate with the Web Authentication API.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">Web
+ * Authentication API</a>
+ */
+ public interface ActivityDelegate {
+ /**
+ * Sometimes GeckoView needs the application to perform a {@link
+ * android.app.Activity#startActivityForResult(Intent, int)} on its behalf. Implementations of
+ * this method should call that based on the information in the passed {@link PendingIntent},
+ * collect the result, and resolve the returned {@link GeckoResult} with that data. If the
+ * Activity does not return {@link android.app.Activity#RESULT_OK}, the {@link GeckoResult} must
+ * be completed with an exception of your choosing.
+ *
+ * @param intent The {@link PendingIntent} to launch
+ * @return A {@link GeckoResult} that is eventually resolved with the Activity result.
+ */
+ @UiThread
+ @Nullable
+ GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent);
+ }
+
+ /**
+ * Set the {@link ActivityDelegate} instance on this runtime. This delegate is used to provide
+ * GeckoView support for launching external activities and receiving results from those
+ * activities.
+ *
+ * @param delegate The {@link ActivityDelegate} handling intent launching requests.
+ */
+ @UiThread
+ public void setActivityDelegate(final @Nullable ActivityDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mActivityDelegate = delegate;
+ }
+
+ /**
+ * Get the {@link ActivityDelegate} instance set on this runtime, if any,
+ *
+ * @return The {@link ActivityDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable ActivityDelegate getActivityDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mActivityDelegate;
+ }
+
+ @AnyThread
+ /* package */ GeckoResult<Intent> startActivityForResult(final @NonNull PendingIntent intent) {
+ if (!ThreadUtils.isOnUiThread()) {
+ // Delegates expect to be called on the UI thread.
+ final GeckoResult<Intent> result = new GeckoResult<>();
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<Intent> delegateResult = startActivityForResult(intent);
+ if (delegateResult != null) {
+ delegateResult.accept(
+ val -> result.complete(val), e -> result.completeExceptionally(e));
+ } else {
+ result.completeExceptionally(new IllegalStateException("No result"));
+ }
+ });
+
+ return result;
+ }
+
+ if (mActivityDelegate == null) {
+ return GeckoResult.fromException(new IllegalStateException("No delegate attached"));
+ }
+
+ @SuppressLint("WrongThread")
+ GeckoResult<Intent> result = mActivityDelegate.onStartActivityForResult(intent);
+ if (result == null) {
+ result = GeckoResult.fromException(new IllegalStateException("No result"));
+ }
+
+ return result;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoRuntimeSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Notify Gecko that the screen orientation has changed. */
+ @UiThread
+ public void orientationChanged() {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update();
+ }
+
+ /**
+ * Notify Gecko that the device configuration has changed.
+ *
+ * @param newConfig The new Configuration object, {@link android.content.res.Configuration}.
+ */
+ @UiThread
+ public void configurationChanged(final @NonNull Configuration newConfig) {
+ ThreadUtils.assertOnUiThread();
+ GeckoSystemStateListener.getInstance().updateNightMode(newConfig.uiMode);
+ }
+
+ /**
+ * Notify Gecko that the screen orientation has changed.
+ *
+ * @param newOrientation The new screen orientation, as retrieved e.g. from the current {@link
+ * android.content.res.Configuration}.
+ */
+ @UiThread
+ public void orientationChanged(final int newOrientation) {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update(newOrientation);
+ }
+
+ /**
+ * Get the orientation controller for this runtime. The orientation controller can be used to
+ * manage changes to and locking of the screen orientation.
+ *
+ * @return The {@link OrientationController} for this instance.
+ */
+ @UiThread
+ public @NonNull OrientationController getOrientationController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOrientationController == null) {
+ mOrientationController = new OrientationController();
+ }
+ return mOrientationController;
+ }
+
+ /**
+ * Converts GeckoScreenOrientation to ActivityInfo orientation
+ *
+ * @return A {@link ActivityInfo} orientation.
+ */
+ @AnyThread
+ private int toAndroidOrientation(final int geckoOrientation) {
+ if (geckoOrientation == ScreenOrientation.PORTRAIT_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.DEFAULT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.ANY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+ }
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ }
+
+ /**
+ * Lock screen orientation using OrientationController's onOrientationLock.
+ *
+ * @return A {@link GeckoResult} that resolves an orientation lock.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private @NonNull GeckoResult<Boolean> lockScreenOrientation(final int aOrientation) {
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate == null) {
+ // Delegate is not set
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ final GeckoResult<AllowOrDeny> response =
+ delegate.onOrientationLock(toAndroidOrientation(aOrientation));
+ if (response == null) {
+ // Delegate is default. So lock orientation is not implemented
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ res.completeFrom(response.map(v -> v == AllowOrDeny.ALLOW));
+ });
+ return res;
+ }
+
+ /** Unlock screen orientation using OrientationController's onOrientationUnlock. */
+ @WrapForJNI(calledFrom = "gecko")
+ private void unlockScreenOrientation() {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate != null) {
+ delegate.onOrientationUnlock();
+ }
+ });
+ }
+
+ /**
+ * Get the storage controller for this runtime. The storage controller can be used to manage
+ * persistent storage data accumulated by {@link GeckoSession}.
+ *
+ * @return The {@link StorageController} for this instance.
+ */
+ @UiThread
+ public @NonNull StorageController getStorageController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mStorageController == null) {
+ mStorageController = new StorageController();
+ }
+ return mStorageController;
+ }
+
+ /**
+ * Get the Web Push controller for this runtime. The Web Push controller can be used to allow
+ * content to use the Web Push API.
+ *
+ * @return The {@link WebPushController} for this instance.
+ */
+ @UiThread
+ public @NonNull WebPushController getWebPushController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mPushController == null) {
+ mPushController = new WebPushController();
+ }
+
+ return mPushController;
+ }
+
+ /**
+ * Appends notes to crash report.
+ *
+ * @param notes The application notes to append to the crash report.
+ */
+ @AnyThread
+ public void appendAppNotesToCrashReport(@NonNull final String notes) {
+ final String notesWithNewLine = notes + "\n";
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoAppShell.nativeAppendAppNotesToCrashReport(notesWithNewLine);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ GeckoAppShell.class,
+ "nativeAppendAppNotesToCrashReport",
+ String.class,
+ notesWithNewLine);
+ }
+ // This function already adds a newline
+ GeckoAppShell.appendAppNotesToCrashReport(notes);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeParcelable(mSettings, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mSettings = source.readParcelable(getClass().getClassLoader());
+ }
+
+ public static final Parcelable.Creator<GeckoRuntime> CREATOR =
+ new Parcelable.Creator<GeckoRuntime>() {
+ @Override
+ @AnyThread
+ public GeckoRuntime createFromParcel(final Parcel in) {
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.readFromParcel(in);
+ return runtime;
+ }
+
+ @Override
+ @AnyThread
+ public GeckoRuntime[] newArray(final int size) {
+ return new GeckoRuntime[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
new file mode 100644
index 0000000000..85bfa6dd8f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
@@ -0,0 +1,1305 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import static android.os.Build.VERSION;
+
+import android.app.Service;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.os.LocaleList;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.util.GeckoBundle;
+
+@AnyThread
+public final class GeckoRuntimeSettings extends RuntimeSettings {
+ private static final String LOGTAG = "GeckoRuntimeSettings";
+
+ /** Settings builder used to construct the settings object. */
+ @AnyThread
+ public static final class Builder extends RuntimeSettings.Builder<GeckoRuntimeSettings> {
+ @Override
+ protected @NonNull GeckoRuntimeSettings newSettings(
+ final @Nullable GeckoRuntimeSettings settings) {
+ return new GeckoRuntimeSettings(settings);
+ }
+
+ /**
+ * Set the custom Gecko process arguments.
+ *
+ * @param args The Gecko process arguments.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder arguments(final @NonNull String[] args) {
+ if (args == null) {
+ throw new IllegalArgumentException("Arguments must not be null");
+ }
+ getSettings().mArgs = args;
+ return this;
+ }
+
+ /**
+ * Set the custom Gecko intent extras.
+ *
+ * @param extras The Gecko intent extras.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extras(final @NonNull Bundle extras) {
+ if (extras == null) {
+ throw new IllegalArgumentException("Extras must not be null");
+ }
+ getSettings().mExtras = extras;
+ return this;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>, on
+ * older devices this will be silently ignored.
+ *
+ * @param configFilePath Configuration file path to read from, or <code>null</code> to use
+ * default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml</code>.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder configFilePath(final @Nullable String configFilePath) {
+ getSettings().mConfigFilePath = configFilePath;
+ return this;
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder javaScriptEnabled(final boolean flag) {
+ getSettings().mJavaScript.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder remoteDebuggingEnabled(final boolean enabled) {
+ getSettings().mRemoteDebugging.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder webFontsEnabled(final boolean flag) {
+ getSettings().mWebFonts.set(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Set whether there should be a pause during startup. This is useful if you need to wait for a
+ * debugger to attach.
+ *
+ * @param enabled A flag determining whether there will be a pause early in startup. Defaults to
+ * false.
+ * @return This Builder.
+ */
+ public @NonNull Builder pauseForDebugger(final boolean enabled) {
+ getSettings().mDebugPause = enabled;
+ return this;
+ }
+ /**
+ * Set whether the to report the full bit depth of the device.
+ *
+ * <p>By default, 24 bits are reported for high memory devices and 16 bits for low memory
+ * devices. If set to true, the device's maximum bit depth is reported. On most modern devices
+ * this will be 32 bit screen depth.
+ *
+ * @param enable A flag determining whether maximum screen depth should be used.
+ * @return This Builder.
+ */
+ public @NonNull Builder useMaxScreenDepth(final boolean enable) {
+ getSettings().mUseMaxScreenDepth = enable;
+ return this;
+ }
+
+ /**
+ * Set whether web manifest support is enabled.
+ *
+ * <p>This controls if Gecko actually downloads, or "obtains", web manifests and processes them.
+ * Without setting this pref, trying to obtain a manifest throws.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder webManifest(final boolean enabled) {
+ getSettings().mWebManifest.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use
+ * of the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return The builder instance.
+ */
+ public @NonNull Builder consoleOutput(final boolean enabled) {
+ getSettings().mConsoleOutput.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return The builder instance.
+ */
+ public @NonNull Builder automaticFontSizeAdjustment(final boolean enabled) {
+ getSettings().setAutomaticFontSizeAdjustment(enabled);
+ return this;
+ }
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0
+ * disables both this feature and {@link Builder#fontInflation font inflation}.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontSizeFactor(final float fontSizeFactor) {
+ getSettings().setFontSizeFactor(fontSizeFactor);
+ return this;
+ }
+
+ /**
+ * Enable the Enterprise Roots feature.
+ *
+ * <p>When Enabled, GeckoView will fetch the third-party root certificates added to the Android
+ * OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return The builder instance
+ */
+ public @NonNull Builder enterpriseRootsEnabled(final boolean enabled) {
+ getSettings().setEnterpriseRootsEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font inflation for non mobile-friendly pages should be enabled. The
+ * default value of this setting is <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link Builder#fontSizeFactor font
+ * size factor} currently in use.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontInflation(final boolean enabled) {
+ getSettings().setFontInflationEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set the display density override.
+ *
+ * @param density The display density value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDensityOverride(final float density) {
+ getSettings().mDisplayDensityOverride = density;
+ return this;
+ }
+
+ /**
+ * Set the display DPI override.
+ *
+ * @param dpi The display DPI value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDpiOverride(final int dpi) {
+ getSettings().mDisplayDpiOverride = dpi;
+ return this;
+ }
+
+ /**
+ * Set the screen size override.
+ *
+ * @param width The screen width value to use for overriding the system default.
+ * @param height The screen height value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder screenSizeOverride(final int width, final int height) {
+ getSettings().mScreenWidthOverride = width;
+ getSettings().mScreenHeightOverride = height;
+ return this;
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is
+ * provided via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder loginAutofillEnabled(final boolean enabled) {
+ getSettings().setLoginAutofillEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * When set, the specified {@link android.app.Service} will be started by an {@link
+ * android.content.Intent} with action {@link GeckoRuntime#ACTION_CRASHED} when a crash is
+ * encountered. Crash details can be found in the Intent extras, such as {@link
+ * GeckoRuntime#EXTRA_MINIDUMP_PATH}. <br>
+ * <br>
+ * The crash handler Service must be declared to run in a different process from the {@link
+ * GeckoRuntime}. Additionally, the handler will be run as a foreground service, so the normal
+ * rules about activating a foreground service apply. <br>
+ * <br>
+ * In practice, you have one of three options once the crash handler is started:
+ *
+ * <ul>
+ * <li>Call {@link android.app.Service#startForeground(int, android.app.Notification)}. You
+ * can then take as much time as necessary to report the crash.
+ * <li>Start an activity. Unless you also call {@link android.app.Service#startForeground(int,
+ * android.app.Notification)} this should be in a different process from the crash
+ * handler, since Android will kill the crash handler process as part of the background
+ * execution limitations.
+ * <li>Schedule work via {@link android.app.job.JobScheduler}. This will allow you to do
+ * substantial work in the background without execution limits.
+ * </ul>
+ *
+ * <br>
+ * You can use {@link CrashReporter} to send the report to Mozilla, which provides Mozilla with
+ * data needed to fix the crash. Be aware that the minidump may contain personally identifiable
+ * information (PII). Consult Mozilla's <a href="https://www.mozilla.org/en-US/privacy/">privacy
+ * policy</a> for information on how this data will be handled.
+ *
+ * @param handler The class for the crash handler Service.
+ * @return This builder instance.
+ * @see <a href="https://developer.android.com/about/versions/oreo/background">Android
+ * Background Execution Limits</a>
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ public @NonNull Builder crashHandler(final @Nullable Class<? extends Service> handler) {
+ getSettings().mCrashHandler = handler;
+ return this;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales List of locale codes in Gecko format ("en" or "en-US").
+ * @return The builder instance.
+ */
+ public @NonNull Builder locales(final @Nullable String[] requestedLocales) {
+ getSettings().mRequestedLocales = requestedLocales;
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Builder contentBlocking(final @NonNull ContentBlocking.Settings cb) {
+ getSettings().mContentBlocking = cb;
+ return this;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder preferredColorScheme(final @ColorScheme int scheme) {
+ getSettings().setPreferredColorScheme(scheme);
+ return this;
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder inputAutoZoomEnabled(final boolean flag) {
+ getSettings().mInputAutoZoom.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether double tap zooming should be enabled.
+ *
+ * @param flag True if double tap zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder doubleTapZoomingEnabled(final boolean flag) {
+ getSettings().mDoubleTapZooming.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder glMsaaLevel(final int level) {
+ getSettings().mGlMsaaLevel.set(level);
+ return this;
+ }
+
+ /**
+ * Add a {@link RuntimeTelemetry.Delegate} instance to this GeckoRuntime. This delegate can be
+ * used by the app to receive streaming telemetry data from GeckoView.
+ *
+ * @param delegate the delegate that will handle telemetry
+ * @return The builder instance.
+ */
+ public @NonNull Builder telemetryDelegate(final @NonNull RuntimeTelemetry.Delegate delegate) {
+ getSettings().mTelemetryProxy = new RuntimeTelemetry.Proxy(delegate);
+ getSettings().mTelemetryEnabled.set(true);
+ return this;
+ }
+
+ /**
+ * Enables GeckoView and Gecko Logging. Logging is on by default. Does not control all logging
+ * in Gecko. Logging done in Java code must be stripped out at build time.
+ *
+ * @param enable True if logging is enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder debugLogging(final boolean enable) {
+ getSettings().mDevToolsConsoleToLogcat.set(enable);
+ getSettings().mConsoleServiceToLogcat.set(enable);
+ getSettings().mGeckoViewLogLevel.set(enable ? "Debug" : "Fatal");
+ return this;
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to
+ * break in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder aboutConfigEnabled(final boolean flag) {
+ getSettings().mAboutConfig.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder forceUserScalableEnabled(final boolean flag) {
+ getSettings().mForceUserScalable.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder allowInsecureConnections(final @HttpsOnlyMode int level) {
+ getSettings().setAllowInsecureConnections(level);
+ return this;
+ }
+ }
+
+ private GeckoRuntime mRuntime;
+ /* package */ String[] mArgs;
+ /* package */ Bundle mExtras;
+ /* package */ String mConfigFilePath;
+
+ /* package */ ContentBlocking.Settings mContentBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull ContentBlocking.Settings getContentBlocking() {
+ return mContentBlocking;
+ }
+
+ /* package */ final Pref<Boolean> mWebManifest = new Pref<Boolean>("dom.manifest.enabled", true);
+ /* package */ final Pref<Boolean> mJavaScript = new Pref<Boolean>("javascript.enabled", true);
+ /* package */ final Pref<Boolean> mRemoteDebugging =
+ new Pref<Boolean>("devtools.debugger.remote-enabled", false);
+ /* package */ final Pref<Integer> mWebFonts =
+ new Pref<Integer>("browser.display.use_document_fonts", 1);
+ /* package */ final Pref<Boolean> mConsoleOutput =
+ new Pref<Boolean>("geckoview.console.enabled", false);
+ /* package */ final Pref<Integer> mFontSizeFactor = new Pref<>("font.size.systemFontScale", 100);
+ /* package */ final Pref<Boolean> mEnterpriseRootsEnabled =
+ new Pref<>("security.enterprise_roots.enabled", false);
+ /* package */ final Pref<Integer> mFontInflationMinTwips =
+ new Pref<>("font.size.inflation.minTwips", 0);
+ /* package */ final Pref<Boolean> mInputAutoZoom = new Pref<>("formhelper.autozoom", true);
+ /* package */ final Pref<Boolean> mDoubleTapZooming =
+ new Pref<>("apz.allow_double_tap_zooming", true);
+ /* package */ final Pref<Integer> mGlMsaaLevel = new Pref<>("webgl.msaa-samples", 4);
+ /* package */ final Pref<Boolean> mTelemetryEnabled =
+ new Pref<>("toolkit.telemetry.geckoview.streaming", false);
+ /* package */ final Pref<String> mGeckoViewLogLevel = new Pref<>("geckoview.logging", "Debug");
+ /* package */ final Pref<Boolean> mConsoleServiceToLogcat =
+ new Pref<>("consoleservice.logcat", true);
+ /* package */ final Pref<Boolean> mDevToolsConsoleToLogcat =
+ new Pref<>("devtools.console.stdout.chrome", true);
+ /* package */ final Pref<Boolean> mAboutConfig = new Pref<>("general.aboutConfig.enable", false);
+ /* package */ final Pref<Boolean> mForceUserScalable =
+ new Pref<>("browser.ui.zoom.force-user-scalable", false);
+ /* package */ final Pref<Boolean> mAutofillLogins =
+ new Pref<Boolean>("signon.autofillForms", true);
+ /* package */ final Pref<Boolean> mHttpsOnly =
+ new Pref<Boolean>("dom.security.https_only_mode", false);
+ /* package */ final Pref<Boolean> mHttpsOnlyPrivateMode =
+ new Pref<Boolean>("dom.security.https_only_mode_pbm", false);
+ /* package */ final Pref<Integer> mProcessCount = new Pref<>("dom.ipc.processCount", 2);
+
+ /* package */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM;
+
+ /* package */ boolean mForceEnableAccessibility;
+ /* package */ boolean mDebugPause;
+ /* package */ boolean mUseMaxScreenDepth;
+ /* package */ float mDisplayDensityOverride = -1.0f;
+ /* package */ int mDisplayDpiOverride;
+ /* package */ int mScreenWidthOverride;
+ /* package */ int mScreenHeightOverride;
+ /* package */ Class<? extends Service> mCrashHandler;
+ /* package */ String[] mRequestedLocales;
+ /* package */ RuntimeTelemetry.Proxy mTelemetryProxy;
+
+ /**
+ * Attach and commit the settings to the given runtime.
+ *
+ * @param runtime The runtime to attach to.
+ */
+ /* package */ void attachTo(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ commit();
+
+ if (mTelemetryProxy != null) {
+ mTelemetryProxy.attach();
+ }
+ }
+
+ @Override // RuntimeSettings
+ public @Nullable GeckoRuntime getRuntime() {
+ return mRuntime;
+ }
+
+ /* package */ GeckoRuntimeSettings() {
+ this(null);
+ }
+
+ /* package */ GeckoRuntimeSettings(final @Nullable GeckoRuntimeSettings settings) {
+ super(/* parent */ null);
+
+ if (settings == null) {
+ mArgs = new String[0];
+ mExtras = new Bundle();
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, null /* settings */);
+ return;
+ }
+
+ updateSettings(settings);
+ }
+
+ private void updateSettings(final @NonNull GeckoRuntimeSettings settings) {
+ updatePrefs(settings);
+
+ mArgs = settings.getArguments().clone();
+ mExtras = new Bundle(settings.getExtras());
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, settings.mContentBlocking);
+
+ mForceEnableAccessibility = settings.mForceEnableAccessibility;
+ mDebugPause = settings.mDebugPause;
+ mUseMaxScreenDepth = settings.mUseMaxScreenDepth;
+ mDisplayDensityOverride = settings.mDisplayDensityOverride;
+ mDisplayDpiOverride = settings.mDisplayDpiOverride;
+ mScreenWidthOverride = settings.mScreenWidthOverride;
+ mScreenHeightOverride = settings.mScreenHeightOverride;
+ mCrashHandler = settings.mCrashHandler;
+ mRequestedLocales = settings.mRequestedLocales;
+ mConfigFilePath = settings.mConfigFilePath;
+ mTelemetryProxy = settings.mTelemetryProxy;
+ }
+
+ /* package */ void commit() {
+ commitLocales();
+ commitResetPrefs();
+ }
+
+ /**
+ * Get the custom Gecko process arguments.
+ *
+ * @return The Gecko process arguments.
+ */
+ public @NonNull String[] getArguments() {
+ return mArgs;
+ }
+
+ /**
+ * Get the custom Gecko intent extras.
+ *
+ * @return The Gecko intent extras.
+ */
+ public @NonNull Bundle getExtras() {
+ return mExtras;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>.
+ *
+ * @return Path to configuration file from which GeckoView will read configuration options, or
+ * <code>null</code> for default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml
+ * </code>.
+ */
+ public @Nullable String getConfigFilePath() {
+ return mConfigFilePath;
+ }
+
+ /**
+ * Get whether JavaScript support is enabled.
+ *
+ * @return Whether JavaScript support is enabled.
+ */
+ public boolean getJavaScriptEnabled() {
+ return mJavaScript.get();
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setJavaScriptEnabled(final boolean flag) {
+ mJavaScript.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get whether remote debugging support is enabled.
+ *
+ * @return True if remote debugging support is enabled.
+ */
+ public boolean getRemoteDebuggingEnabled() {
+ return mRemoteDebugging.get();
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setRemoteDebuggingEnabled(final boolean enabled) {
+ mRemoteDebugging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether web fonts support is enabled.
+ *
+ * @return Whether web fonts support is enabled.
+ */
+ public boolean getWebFontsEnabled() {
+ return mWebFonts.get() != 0 ? true : false;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebFontsEnabled(final boolean flag) {
+ mWebFonts.commit(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Gets whether the pause-for-debugger is enabled or not.
+ *
+ * @return True if the pause is enabled.
+ */
+ public boolean getPauseForDebuggerEnabled() {
+ return mDebugPause;
+ }
+
+ /**
+ * Gets whether accessibility is force enabled or not.
+ *
+ * @return true if accessibility is force enabled.
+ */
+ public boolean getForceEnableAccessibility() {
+ return mForceEnableAccessibility;
+ }
+
+ /**
+ * Sets whether accessibility is force enabled or not.
+ *
+ * <p>Useful when testing accessibility.
+ *
+ * @param value whether accessibility is force enabled or not
+ * @return this GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceEnableAccessibility(final boolean value) {
+ mForceEnableAccessibility = value;
+ SessionAccessibility.setForceEnabled(value);
+ return this;
+ }
+
+ /**
+ * Gets whether the compositor should use the maximum screen depth when rendering.
+ *
+ * @return True if the maximum screen depth should be used.
+ */
+ public boolean getUseMaxScreenDepth() {
+ return mUseMaxScreenDepth;
+ }
+
+ /**
+ * Gets the display density override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Float getDisplayDensityOverride() {
+ if (mDisplayDensityOverride > 0.0f) {
+ return mDisplayDensityOverride;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the display DPI override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Integer getDisplayDpiOverride() {
+ if (mDisplayDpiOverride > 0) {
+ return mDisplayDpiOverride;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable Class<? extends Service> getCrashHandler() {
+ return mCrashHandler;
+ }
+
+ /**
+ * Gets the screen size override value.
+ *
+ * @return Returns a Rect containing the dimensions to use for the window size. Will return null
+ * if not set.
+ */
+ public @Nullable Rect getScreenSizeOverride() {
+ if ((mScreenWidthOverride > 0) && (mScreenHeightOverride > 0)) {
+ return new Rect(0, 0, mScreenWidthOverride, mScreenHeightOverride);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the list of requested locales.
+ *
+ * @return A list of locale codes in Gecko format ("en" or "en-US").
+ */
+ public @Nullable String[] getLocales() {
+ return mRequestedLocales;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales An ordered list of locales in Gecko format ("en-US").
+ */
+ public void setLocales(final @Nullable String[] requestedLocales) {
+ mRequestedLocales = requestedLocales;
+ commitLocales();
+ }
+
+ private void commitLocales() {
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("requestedLocales", mRequestedLocales);
+ data.putString("acceptLanguages", computeAcceptLanguages());
+ EventDispatcher.getInstance().dispatch("GeckoView:SetLocale", data);
+ }
+
+ private String computeAcceptLanguages() {
+ final LinkedHashMap<String, String> locales = new LinkedHashMap<>();
+
+ // Explicitly-set app prefs come first:
+ if (mRequestedLocales != null) {
+ for (final String locale : mRequestedLocales) {
+ locales.put(locale.toLowerCase(Locale.ROOT), locale);
+ }
+ }
+ // OS prefs come second:
+ for (final String locale : getDefaultLocales()) {
+ final String localeLowerCase = locale.toLowerCase(Locale.ROOT);
+ if (!locales.containsKey(localeLowerCase)) {
+ locales.put(localeLowerCase, locale);
+ }
+ }
+
+ return TextUtils.join(",", locales.values());
+ }
+
+ private static String[] getDefaultLocales() {
+ if (VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ final String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ final String[] locales = new String[1];
+ final Locale locale = Locale.getDefault();
+ if (VERSION.SDK_INT >= 21) {
+ locales[0] = locale.toLanguageTag();
+ return locales;
+ }
+
+ locales[0] = getLanguageTag(locale);
+ return locales;
+ }
+
+ private static String getLanguageTag(final Locale locale) {
+ final StringBuilder out = new StringBuilder(locale.getLanguage());
+ final String country = locale.getCountry();
+ final String variant = locale.getVariant();
+ if (!TextUtils.isEmpty(country)) {
+ out.append('-').append(country);
+ }
+ if (!TextUtils.isEmpty(variant)) {
+ out.append('-').append(variant);
+ }
+ // e.g. "en", "en-US", or "en-US-POSIX".
+ return out.toString();
+ }
+
+ /**
+ * Sets whether Web Manifest processing support is enabled.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebManifestEnabled(final boolean enabled) {
+ mWebManifest.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not Web Manifest processing support is enabled.
+ *
+ * @return True if web manifest processing support is enabled.
+ */
+ public boolean getWebManifestEnabled() {
+ return mWebManifest.get();
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use of
+ * the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setConsoleOutputEnabled(final boolean enabled) {
+ mConsoleOutput.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not web console messages are sent to logcat.
+ *
+ * @return True if console output is enabled.
+ */
+ public boolean getConsoleOutputEnabled() {
+ return mConsoleOutput.get();
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting. Enabling this will prevent modification of the
+ * {@link GeckoRuntimeSettings#setFontSizeFactor font size factor}. Disabling this setting will
+ * restore the previously used value for the {@link GeckoRuntimeSettings#getFontSizeFactor font
+ * size factor}.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAutomaticFontSizeAdjustment(final boolean enabled) {
+ GeckoFontScaleListener.getInstance().setEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not the font sizes for web content are automatically adjusted to match the
+ * device's system font scale setting.
+ *
+ * @return True if font sizes are automatically adjusted.
+ */
+ public boolean getAutomaticFontSizeAdjustment() {
+ return GeckoFontScaleListener.getInstance().getEnabled();
+ }
+
+ private static final int FONT_INFLATION_BASE_VALUE = 120;
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * <p>This setting cannot be modified while {@link
+ * GeckoRuntimeSettings#setAutomaticFontSizeAdjustment automatic font size adjustment} is enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 disables
+ * both this feature and {@link GeckoRuntimeSettings#setFontInflationEnabled font inflation}.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontSizeFactor(final float fontSizeFactor) {
+ if (getAutomaticFontSizeAdjustment()) {
+ throw new IllegalStateException("Not allowed when automatic font size adjustment is enabled");
+ }
+ return setFontSizeFactorInternal(fontSizeFactor);
+ }
+
+ /*
+ * Enable the Enteprise Roots feature.
+ *
+ * When Enabled, GeckoView will fetch the third-party root certificates added to the
+ * Android OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return This GeckoRuntimeSettings instance
+ */
+ public @NonNull GeckoRuntimeSettings setEnterpriseRootsEnabled(final boolean enabled) {
+ mEnterpriseRootsEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Gets whether the Enteprise Roots feature is enabled or not.
+ *
+ * @return true if the feature is enabled, false otherwise.
+ */
+ public boolean getEnterpriseRootsEnabled() {
+ return mEnterpriseRootsEnabled.get();
+ }
+
+ private static final float DEFAULT_FONT_SIZE_FACTOR = 1f;
+
+ private float sanitizeFontSizeFactor(final float fontSizeFactor) {
+ if (fontSizeFactor < 0) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new IllegalArgumentException("fontSizeFactor cannot be < 0");
+ } else {
+ Log.e(LOGTAG, "fontSizeFactor cannot be < 0");
+ return DEFAULT_FONT_SIZE_FACTOR;
+ }
+ }
+
+ return fontSizeFactor;
+ }
+
+ /* package */ @NonNull
+ GeckoRuntimeSettings setFontSizeFactorInternal(final float fontSizeFactor) {
+ final int fontSizePercentage = Math.round(sanitizeFontSizeFactor(fontSizeFactor) * 100);
+ mFontSizeFactor.commit(fontSizePercentage);
+ if (getFontInflationEnabled()) {
+ final int scaledFontInflation = Math.round(FONT_INFLATION_BASE_VALUE * fontSizeFactor);
+ mFontInflationMinTwips.commit(scaledFontInflation);
+ }
+ return this;
+ }
+
+ /**
+ * Gets the currently applied font size factor.
+ *
+ * @return The currently applied font size factor.
+ */
+ public float getFontSizeFactor() {
+ return mFontSizeFactor.get() / 100f;
+ }
+
+ /**
+ * Set whether or not font inflation for non mobile-friendly pages should be enabled. The default
+ * value of this setting is <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link
+ * GeckoRuntimeSettings#setFontSizeFactor font size factor} currently in use.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontInflationEnabled(final boolean enabled) {
+ final int minTwips = enabled ? Math.round(FONT_INFLATION_BASE_VALUE * getFontSizeFactor()) : 0;
+ mFontInflationMinTwips.commit(minTwips);
+ return this;
+ }
+
+ /**
+ * Get whether or not font inflation for non mobile-friendly pages is currently enabled.
+ *
+ * @return True if font inflation is enabled.
+ */
+ public boolean getFontInflationEnabled() {
+ return mFontInflationMinTwips.get() > 0;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({COLOR_SCHEME_LIGHT, COLOR_SCHEME_DARK, COLOR_SCHEME_SYSTEM})
+ public @interface ColorScheme {}
+
+ /** A light theme for web content is preferred. */
+ public static final int COLOR_SCHEME_LIGHT = 0;
+ /** A dark theme for web content is preferred. */
+ public static final int COLOR_SCHEME_DARK = 1;
+ /** The preferred color scheme will be based on system settings. */
+ public static final int COLOR_SCHEME_SYSTEM = -1;
+
+ /**
+ * Gets the preferred color scheme override for web content.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ */
+ public @ColorScheme int getPreferredColorScheme() {
+ return mPreferredColorScheme;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setPreferredColorScheme(final @ColorScheme int scheme) {
+ if (mPreferredColorScheme != scheme) {
+ mPreferredColorScheme = scheme;
+ GeckoSystemStateListener.onDeviceChanged();
+ }
+ return this;
+ }
+
+ /**
+ * Gets whether auto-zoom to editable fields is enabled.
+ *
+ * @return True if auto-zoom is enabled, false otherwise.
+ */
+ public boolean getInputAutoZoomEnabled() {
+ return mInputAutoZoom.get();
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setInputAutoZoomEnabled(final boolean flag) {
+ mInputAutoZoom.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether double-tap zooming is enabled.
+ *
+ * @return True if double-tap zooming is enabled, false otherwise.
+ */
+ public boolean getDoubleTapZoomingEnabled() {
+ return mDoubleTapZooming.get();
+ }
+
+ /**
+ * Sets whether double tap zooming is enabled.
+ *
+ * @param flag true if double tap zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setDoubleTapZoomingEnabled(final boolean flag) {
+ mDoubleTapZooming.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets the current WebGL MSAA level.
+ *
+ * @return number of MSAA samples, 0 if MSAA is disabled.
+ */
+ public int getGlMsaaLevel() {
+ return mGlMsaaLevel.get();
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setGlMsaaLevel(final int level) {
+ mGlMsaaLevel.commit(level);
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable RuntimeTelemetry.Delegate getTelemetryDelegate() {
+ return mTelemetryProxy.getDelegate();
+ }
+
+ /**
+ * Gets whether about:config is enabled or not.
+ *
+ * @return True if about:config is enabled, false otherwise.
+ */
+ public boolean getAboutConfigEnabled() {
+ return mAboutConfig.get();
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to break
+ * in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAboutConfigEnabled(final boolean flag) {
+ mAboutConfig.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether or not force user scalable zooming should be enabled or not.
+ *
+ * @return True if force user scalable zooming should be enabled, false otherwise.
+ */
+ public boolean getForceUserScalableEnabled() {
+ return mForceUserScalable.get();
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceUserScalableEnabled(final boolean flag) {
+ mForceUserScalable.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get whether login form autofill is enabled.
+ *
+ * @return True if login autofill is enabled.
+ */
+ public boolean getLoginAutofillEnabled() {
+ return mAutofillLogins.get();
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is provided
+ * via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull GeckoRuntimeSettings setLoginAutofillEnabled(final boolean enabled) {
+ mAutofillLogins.commit(enabled);
+ return this;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ALLOW_ALL, HTTPS_ONLY_PRIVATE, HTTPS_ONLY})
+ public @interface HttpsOnlyMode {}
+
+ /** Allow all insecure connections */
+ public static final int ALLOW_ALL = 0;
+ /** Allow insecure connections in normal browsing, but only HTTPS in private browsing. */
+ public static final int HTTPS_ONLY_PRIVATE = 1;
+ /** Only allow HTTPS connections. */
+ public static final int HTTPS_ONLY = 2;
+
+ /**
+ * Get whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ */
+ public @HttpsOnlyMode int getAllowInsecureConnections() {
+ final boolean httpsOnly = mHttpsOnly.get();
+ final boolean httpsOnlyPrivate = mHttpsOnlyPrivateMode.get();
+ if (httpsOnly) {
+ return HTTPS_ONLY;
+ } else if (httpsOnlyPrivate) {
+ return HTTPS_ONLY_PRIVATE;
+ }
+ return ALLOW_ALL;
+ }
+
+ /**
+ * Set whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAllowInsecureConnections(final @HttpsOnlyMode int level) {
+ switch (level) {
+ case ALLOW_ALL:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ case HTTPS_ONLY_PRIVATE:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(true);
+ break;
+ case HTTPS_ONLY:
+ mHttpsOnly.commit(true);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid setting for setAllowInsecureConnections");
+ }
+ return this;
+ }
+
+ // For internal use only
+ /* protected */ @NonNull
+ GeckoRuntimeSettings setProcessCount(final int processCount) {
+ mProcessCount.commit(processCount);
+ return this;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+
+ out.writeStringArray(mArgs);
+ mExtras.writeToParcel(out, flags);
+ ParcelableUtils.writeBoolean(out, mForceEnableAccessibility);
+ ParcelableUtils.writeBoolean(out, mDebugPause);
+ ParcelableUtils.writeBoolean(out, mUseMaxScreenDepth);
+ out.writeFloat(mDisplayDensityOverride);
+ out.writeInt(mDisplayDpiOverride);
+ out.writeInt(mScreenWidthOverride);
+ out.writeInt(mScreenHeightOverride);
+ out.writeString(mCrashHandler != null ? mCrashHandler.getName() : null);
+ out.writeStringArray(mRequestedLocales);
+ out.writeString(mConfigFilePath);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ super.readFromParcel(source);
+
+ mArgs = source.createStringArray();
+ mExtras.readFromParcel(source);
+ mForceEnableAccessibility = ParcelableUtils.readBoolean(source);
+ mDebugPause = ParcelableUtils.readBoolean(source);
+ mUseMaxScreenDepth = ParcelableUtils.readBoolean(source);
+ mDisplayDensityOverride = source.readFloat();
+ mDisplayDpiOverride = source.readInt();
+ mScreenWidthOverride = source.readInt();
+ mScreenHeightOverride = source.readInt();
+
+ final String crashHandlerName = source.readString();
+ if (crashHandlerName != null) {
+ try {
+ @SuppressWarnings("unchecked")
+ final Class<? extends Service> handler =
+ (Class<? extends Service>) Class.forName(crashHandlerName);
+
+ mCrashHandler = handler;
+ } catch (final ClassNotFoundException e) {
+ }
+ }
+
+ mRequestedLocales = source.createStringArray();
+ mConfigFilePath = source.readString();
+ }
+
+ public static final Parcelable.Creator<GeckoRuntimeSettings> CREATOR =
+ new Parcelable.Creator<GeckoRuntimeSettings>() {
+ @Override
+ public GeckoRuntimeSettings createFromParcel(final Parcel in) {
+ final GeckoRuntimeSettings settings = new GeckoRuntimeSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoRuntimeSettings[] newArray(final int size) {
+ return new GeckoRuntimeSettings[size];
+ }
+ };
+}
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
new file mode 100644
index 0000000000..746fce54f3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -0,0 +1,6760 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.content.ContentResolver;
+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;
+import android.net.Uri;
+import android.os.Binder;
+import android.os.Build;
+import android.os.IInterface;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.SystemClock;
+import android.text.TextUtils;
+import android.util.Base64;
+import android.util.Log;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.widget.Magnifier;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.AbstractSequentialList;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Map;
+import java.util.NoSuchElementException;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.MagnifiableSurfaceView;
+import org.mozilla.gecko.NativeQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.IntentUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo;
+
+public class GeckoSession {
+ private static final String LOGTAG = "GeckoSession";
+ private static final boolean DEBUG = false;
+
+ // Type of changes given to onWindowChanged.
+ // Window has been cleared due to the session being closed.
+ private static final int WINDOW_CLOSE = 0;
+ // Window has been set due to the session being opened.
+ private static final int WINDOW_OPEN = 1; // Window has been opened.
+ // Window has been cleared due to the session being transferred to another session.
+ private static final int WINDOW_TRANSFER_OUT = 2; // Window has been transfer.
+ // Window has been set due to another session being transferred to this one.
+ private static final int WINDOW_TRANSFER_IN = 3;
+
+ private static final int DATA_URI_MAX_LENGTH = 2 * 1024 * 1024;
+
+ // Delay running compositor memory pressure by 10s to avoid interfering with tab switching.
+ private static final int NOTIFY_MEMORY_PRESSURE_DELAY_MS = 10 * 1000;
+
+ private final Runnable mNotifyMemoryPressure =
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mCompositorReady) {
+ mCompositor.notifyMemoryPressure();
+ }
+ }
+ };
+
+ private enum State implements NativeQueue.State {
+ INITIAL(0),
+ READY(1);
+
+ private final int mRank;
+
+ private State(final int rank) {
+ mRank = rank;
+ }
+
+ @Override
+ public boolean is(final NativeQueue.State other) {
+ return this == other;
+ }
+
+ @Override
+ public boolean isAtLeast(final NativeQueue.State other) {
+ return (other instanceof State) && mRank >= ((State) other).mRank;
+ }
+ }
+
+ private final NativeQueue mNativeQueue = new NativeQueue(State.INITIAL, State.READY);
+
+ private final EventDispatcher mEventDispatcher = new EventDispatcher(mNativeQueue);
+
+ private final SessionTextInput mTextInput = new SessionTextInput(this, mNativeQueue);
+ private SessionAccessibility mAccessibility;
+ private SessionFinder mFinder;
+
+ /** {@code SessionMagnifier} handles magnifying glass. */
+ /* package */ interface SessionMagnifier {
+ /**
+ * Get the current {@link android.view.View} for magnifying glass.
+ *
+ * @return Current View for magnifying glass or null if not set.
+ */
+ @UiThread
+ default @Nullable View getView() {
+ return null;
+ }
+
+ /**
+ * Set the current {@link android.view.View} for magnifying glass.
+ *
+ * @param view View for magnifying glass or null to clear current View.
+ */
+ @UiThread
+ default void setView(final @NonNull View view) {}
+
+ /**
+ * Show magnifying glass.
+ *
+ * @param sourceCenter The source center of view that magnifying glass is attached
+ */
+ @UiThread
+ default void show(final @NonNull PointF sourceCenter) {}
+
+ /** Dismiss magnifying glass. */
+ @UiThread
+ default void dismiss() {}
+ }
+
+ @TargetApi(Build.VERSION_CODES.P)
+ private class SessionMagnifierP implements GeckoSession.SessionMagnifier {
+ private @Nullable View mView;
+ private @Nullable Magnifier mMagnifier;
+ private final @NonNull Compositor mCompositor;
+
+ private SessionMagnifierP(final Compositor compositor) {
+ mCompositor = compositor;
+ }
+
+ @Override
+ @UiThread
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+
+ return mView;
+ }
+
+ @Override
+ @UiThread
+ public void setView(final @NonNull View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mMagnifier != null) {
+ mMagnifier.dismiss();
+ mMagnifier = null;
+ }
+ mView = view;
+ }
+
+ @Override
+ @UiThread
+ public void show(final @NonNull PointF sourceCenter) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mView == null) {
+ return;
+ }
+ if (mMagnifier == null) {
+ mMagnifier = new Magnifier(mView);
+ }
+
+ if (mView instanceof MagnifiableSurfaceView) {
+ final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView;
+ view.setMagnifierSurface(mCompositor.getMagnifiableSurface());
+ }
+ mMagnifier.show(sourceCenter.x, sourceCenter.y);
+ if (mView instanceof MagnifiableSurfaceView) {
+ final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView;
+ view.setMagnifierSurface(null);
+ }
+ }
+
+ @Override
+ @UiThread
+ public void dismiss() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mMagnifier == null) {
+ return;
+ }
+
+ mMagnifier.dismiss();
+ mMagnifier = null;
+ }
+ }
+
+ private SessionMagnifier mMagnifier;
+
+ private String mId;
+ /* package */ String getId() {
+ return mId;
+ }
+
+ private boolean mShouldPinOnScreen;
+
+ // All fields are accessed on UI thread only.
+ private PanZoomController mPanZoomController = new PanZoomController(this);
+ private OverscrollEdgeEffect mOverscroll;
+ private CompositorController mController;
+ private Autofill.Support mAutofillSupport;
+
+ private boolean mAttachedCompositor;
+ private boolean mCompositorReady;
+ private SurfaceInfo mSurfaceInfo;
+
+ // All fields of coordinates are in screen units.
+ private int mLeft;
+ private int mTop; // Top of the surface (including toolbar);
+ private int mClientTop; // Top of the client area (i.e. excluding toolbar);
+ private int mWidth;
+ private int mHeight; // Height of the surface (including toolbar);
+ private int mClientHeight; // Height of the client area (i.e. excluding toolbar);
+ private int mFixedBottomOffset =
+ 0; // The margin for fixed elements attached to the bottom of the viewport.
+ private int mDynamicToolbarMaxHeight = 0; // The maximum height of the dynamic toolbar
+ private float mViewportLeft;
+ private float mViewportTop;
+ private float mViewportZoom = 1.0f;
+
+ //
+ // NOTE: These values are also defined in
+ // gfx/layers/ipc/UiCompositorControllerMessageTypes.h and must be kept in sync. Any
+ // new AnimatorMessageType added here must also be added there.
+ //
+ // Sent from compositor after first paint
+ /* package */ static final int FIRST_PAINT = 0;
+ // Sent from compositor when a layer has been updated
+ /* package */ static final int LAYERS_UPDATED = 1;
+ // Special message sent from UiCompositorControllerChild once it is open
+ /* package */ static final int COMPOSITOR_CONTROLLER_OPEN = 2;
+ // Special message sent from controller to query if the compositor controller is open.
+ /* package */ static final int IS_COMPOSITOR_CONTROLLER_OPEN = 3;
+
+ /* protected */ class Compositor extends JNIObject {
+ public boolean isReady() {
+ return GeckoSession.this.isCompositorReady();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void onCompositorAttached() {
+ GeckoSession.this.onCompositorAttached();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void onCompositorDetached() {
+ // Clear out any pending calls on the UI thread.
+ GeckoSession.this.onCompositorDetached();
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ @Override
+ protected native void disposeNative();
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void attachNPZC(PanZoomController.NativeProvider npzc);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void onBoundsChanged(int left, int top, int width, int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void setDynamicToolbarMaxHeight(int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void notifyMemoryPressure();
+
+ // Gecko thread pauses compositor; blocks UI thread.
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void syncPauseCompositor();
+
+ // UI thread resumes compositor and notifies Gecko thread; does not block UI thread.
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void syncResumeResizeCompositor(
+ int x, int y, int width, int height, Object surface, Object surfaceControl);
+
+ // Returns a Surface that content has been rendered in to, which should be used when the
+ // magnifier is shown. This may differ from the Surface we have passed to
+ // syncResumeResizeCompositor().
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native Surface getMagnifiableSurface();
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void setMaxToolbarHeight(int height);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void setFixedBottomOffset(int offset);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void sendToolbarAnimatorMessage(int message);
+
+ @WrapForJNI(calledFrom = "ui")
+ private void recvToolbarAnimatorMessage(final int message) {
+ GeckoSession.this.handleCompositorMessage(message);
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void setDefaultClearColor(int color);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ /* package */ native void requestScreenPixels(
+ final GeckoResult<Bitmap> result,
+ final Bitmap target,
+ final int x,
+ final int y,
+ final int srcWidth,
+ final int srcHeight,
+ final int outWidth,
+ final int outHeight);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void enableLayerUpdateNotifications(boolean enable);
+
+ // The compositor invokes this function just before compositing a frame where the
+ // document is different from the document composited on the last frame. In these
+ // cases, the viewport information we have in Java is no longer valid and needs to
+ // be replaced with the new viewport information provided.
+ @WrapForJNI(calledFrom = "ui")
+ private void updateRootFrameMetrics(
+ final float scrollX, final float scrollY, final float zoom) {
+ GeckoSession.this.onMetricsChanged(scrollX, scrollY, zoom);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollVelocity(final float x, final float y) {
+ GeckoSession.this.updateOverscrollVelocity(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollOffset(final float x, final float y) {
+ GeckoSession.this.updateOverscrollOffset(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void onSafeAreaInsetsChanged(int top, int right, int bottom, int left);
+
+ @WrapForJNI(calledFrom = "ui")
+ public void setPointerIcon(
+ final int defaultCursor, final Bitmap customCursor, final float x, final float y) {
+ GeckoSession.this.setPointerIcon(defaultCursor, customCursor, x, y);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ disposeNative();
+ }
+ }
+
+ /* package */ final Compositor mCompositor = new Compositor();
+
+ @WrapForJNI(stubName = "GetCompositor", calledFrom = "ui")
+ private Object getCompositorFromNative() {
+ // Only used by native code.
+ return mCompositorReady ? mCompositor : null;
+ }
+
+ private final GeckoSessionHandler<HistoryDelegate> mHistoryHandler =
+ new GeckoSessionHandler<HistoryDelegate>(
+ "GeckoViewHistory",
+ this,
+ new String[] {
+ "GeckoView:OnVisited", "GeckoView:GetVisited", "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final HistoryDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:OnVisited".equals(event)) {
+ final GeckoResult<Boolean> result =
+ delegate.onVisited(
+ GeckoSession.this,
+ message.getString("url"),
+ message.getString("lastVisitedURL"),
+ message.getInt("flags"));
+
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited.booleanValue()),
+ exception -> callback.sendSuccess(false));
+ } else if ("GeckoView:GetVisited".equals(event)) {
+ final String[] urls = message.getStringArray("urls");
+
+ final GeckoResult<boolean[]> result = delegate.getVisited(GeckoSession.this, urls);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited),
+ exception -> callback.sendError("Failed to fetch visited statuses for URIs"));
+ } else if ("GeckoView:StateUpdated".equals(event)) {
+
+ final GeckoBundle update = message.getBundle("data");
+
+ if (update == null) {
+ return;
+ }
+ final int previousHistorySize = mStateCache.size();
+ mStateCache.updateSessionState(update);
+
+ final ProgressDelegate progressDelegate = getProgressDelegate();
+ if (progressDelegate != null) {
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ progressDelegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+
+ if (update.getBundle("historychange") != null) {
+ final SessionState state = new SessionState(mStateCache);
+
+ delegate.onHistoryStateChange(GeckoSession.this, state);
+
+ // If the previous history was larger than one entry and the new size is one, it means
+ // the
+ // History has been purged and the navigation delegate needs to be update.
+ if ((previousHistorySize > 1)
+ && (state.size() == 1)
+ && mNavigationHandler.getDelegate() != null) {
+ mNavigationHandler.getDelegate().onCanGoForward(GeckoSession.this, false);
+ mNavigationHandler.getDelegate().onCanGoBack(GeckoSession.this, false);
+ }
+ }
+ }
+ }
+ };
+
+ private final WebExtension.SessionController mWebExtensionController;
+
+ private final GeckoSessionHandler<ContentDelegate> mContentHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewContent",
+ this,
+ new String[] {
+ "GeckoView:ContentCrash",
+ "GeckoView:ContentKill",
+ "GeckoView:ContextMenu",
+ "GeckoView:DOMMetaViewportFit",
+ "GeckoView:PageTitleChanged",
+ "GeckoView:DOMWindowClose",
+ "GeckoView:ExternalResponse",
+ "GeckoView:FocusRequest",
+ "GeckoView:FullScreenEnter",
+ "GeckoView:FullScreenExit",
+ "GeckoView:WebAppManifest",
+ "GeckoView:FirstContentfulPaint",
+ "GeckoView:PaintStatusReset",
+ "GeckoView:PreviewImage",
+ "GeckoView:CookieBannerEvent:Detected",
+ "GeckoView:CookieBannerEvent:Handled",
+ }) {
+ @Override
+ public void handleMessage(
+ final ContentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:ContentCrash".equals(event)) {
+ close();
+ delegate.onCrash(GeckoSession.this);
+ } else if ("GeckoView:ContentKill".equals(event)) {
+ close();
+ delegate.onKill(GeckoSession.this);
+ } else if ("GeckoView:ContextMenu".equals(event)) {
+ final ContentDelegate.ContextElement elem =
+ new ContentDelegate.ContextElement(
+ message.getString("baseUri"),
+ message.getString("uri"),
+ message.getString("title"),
+ message.getString("alt"),
+ message.getString("elementType"),
+ message.getString("elementSrc"));
+
+ delegate.onContextMenu(
+ GeckoSession.this, message.getInt("screenX"), message.getInt("screenY"), elem);
+
+ } else if ("GeckoView:DOMMetaViewportFit".equals(event)) {
+ delegate.onMetaViewportFitChange(GeckoSession.this, message.getString("viewportfit"));
+ } else if ("GeckoView:PageTitleChanged".equals(event)) {
+ delegate.onTitleChange(GeckoSession.this, message.getString("title"));
+ } else if ("GeckoView:FocusRequest".equals(event)) {
+ delegate.onFocusRequest(GeckoSession.this);
+ } else if ("GeckoView:DOMWindowClose".equals(event)) {
+ delegate.onCloseRequest(GeckoSession.this);
+ } else if ("GeckoView:FullScreenEnter".equals(event)) {
+ delegate.onFullScreen(GeckoSession.this, true);
+ } else if ("GeckoView:FullScreenExit".equals(event)) {
+ delegate.onFullScreen(GeckoSession.this, false);
+ } else if ("GeckoView:WebAppManifest".equals(event)) {
+ final GeckoBundle manifest = message.getBundle("manifest");
+ if (manifest == null) {
+ return;
+ }
+
+ try {
+ delegate.onWebAppManifest(
+ GeckoSession.this, fixupWebAppManifest(manifest.toJSONObject()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Failed to convert web app manifest to JSON", e);
+ }
+ } else if ("GeckoView:FirstContentfulPaint".equals(event)) {
+ delegate.onFirstContentfulPaint(GeckoSession.this);
+ } else if ("GeckoView:PaintStatusReset".equals(event)) {
+ delegate.onPaintStatusReset(GeckoSession.this);
+ } else if ("GeckoView:PreviewImage".equals(event)) {
+ delegate.onPreviewImage(GeckoSession.this, message.getString("previewImageUrl"));
+ } else if ("GeckoView:CookieBannerEvent:Detected".equals(event)) {
+ delegate.onCookieBannerDetected(GeckoSession.this);
+ } else if ("GeckoView:CookieBannerEvent:Handled".equals(event)) {
+ delegate.onCookieBannerHandled(GeckoSession.this);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<NavigationDelegate> mNavigationHandler =
+ new GeckoSessionHandler<NavigationDelegate>(
+ "GeckoViewNavigation",
+ this,
+ new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"},
+ new String[] {
+ "GeckoView:OnLoadError", "GeckoView:OnLoadRequest",
+ }) {
+ // This needs to match nsIBrowserDOMWindow.idl
+ private int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return NavigationDelegate.TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB
+ return NavigationDelegate.TARGET_WINDOW_NEW;
+ }
+ }
+
+ @Override
+ public void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+
+ if ("GeckoView:OnLoadRequest".equals(event)) {
+ callback.sendSuccess(false);
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ callback.sendSuccess(null);
+ } else {
+ super.handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ // For .isOpen(), the linter is not smart enough to figure out we're asserting that we're on
+ // the UI thread.
+ @SuppressLint("WrongThread")
+ @Override
+ public void handleMessage(
+ final NavigationDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:LocationChange".equals(event)) {
+ if (message.getBoolean("isTopLevel")) {
+ final GeckoBundle[] perms = message.getBundleArray("permissions");
+ final List<PermissionDelegate.ContentPermission> permList =
+ PermissionDelegate.ContentPermission.fromBundleArray(perms);
+ delegate.onLocationChange(GeckoSession.this, message.getString("uri"), permList);
+ }
+ delegate.onCanGoBack(GeckoSession.this, message.getBoolean("canGoBack"));
+ delegate.onCanGoForward(GeckoSession.this, message.getBoolean("canGoForward"));
+ } else if ("GeckoView:OnLoadRequest".equals(event)) {
+ final NavigationDelegate.LoadRequest request =
+ new NavigationDelegate.LoadRequest(
+ message.getString("uri"),
+ message.getString("triggerUri"),
+ message.getInt("where"),
+ message.getInt("flags"),
+ message.getBoolean("hasUserGesture"),
+ /* isDirectNavigation */ false);
+
+ if (!IntentUtils.isUriSafeForScheme(request.uri)) {
+ callback.sendError("Blocked unsafe intent URI");
+
+ delegate.onLoadError(
+ GeckoSession.this,
+ request.uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> result =
+ delegate.onLoadRequest(GeckoSession.this, request);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ value -> {
+ ThreadUtils.assertOnUiThread();
+ if (value == AllowOrDeny.ALLOW) {
+ return false;
+ }
+ if (value == AllowOrDeny.DENY) {
+ return true;
+ }
+ throw new IllegalArgumentException("Invalid response");
+ }));
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ final String uri = message.getString("uri");
+ final long errorCode = message.getLong("error");
+ final int errorModule = message.getInt("errorModule");
+ final int errorClass = message.getInt("errorClass");
+
+ final WebRequestError err =
+ WebRequestError.fromGeckoError(errorCode, errorModule, errorClass, null);
+
+ final GeckoResult<String> result = delegate.onLoadError(GeckoSession.this, uri, err);
+ if (result == null) {
+ callback.sendError("abort");
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ url -> {
+ if (url == null) {
+ throw new IllegalArgumentException("abort");
+ }
+ return url;
+ }));
+ } else if ("GeckoView:OnNewSession".equals(event)) {
+ final String uri = message.getString("uri");
+ final GeckoResult<GeckoSession> result = delegate.onNewSession(GeckoSession.this, uri);
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.getString("newSessionId");
+ callback.resolveTo(
+ result.map(
+ session -> {
+ ThreadUtils.assertOnUiThread();
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new AssertionError("Must use an unopened GeckoSession instance");
+ }
+
+ if (GeckoSession.this.mWindow == null) {
+ throw new IllegalArgumentException("Session is not attached to a window");
+ }
+
+ session.open(GeckoSession.this.mWindow.runtime, newSessionId);
+ return true;
+ }));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ContentDelegate> mProcessHangHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewProcessHangMonitor", this, new String[] {"GeckoView:HangReport"}) {
+
+ @Override
+ protected void handleMessage(
+ final ContentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback eventCallback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+
+ final GeckoResult<SlowScriptResponse> result =
+ delegate.onSlowScript(GeckoSession.this, message.getString("scriptFileName"));
+ if (result != null) {
+ final int mReportId = message.getInt("hangId");
+ result.accept(
+ stopOrContinue -> {
+ if (stopOrContinue != null) {
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", mReportId);
+ switch (stopOrContinue) {
+ case STOP:
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ break;
+ case CONTINUE:
+ mEventDispatcher.dispatch("GeckoView:HangReportWait", bundle);
+ break;
+ }
+ }
+ });
+ } else {
+ // default to stopping the script
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", message.getInt("hangId"));
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ProgressDelegate> mProgressHandler =
+ new GeckoSessionHandler<ProgressDelegate>(
+ "GeckoViewProgress",
+ this,
+ new String[] {
+ "GeckoView:PageStart",
+ "GeckoView:PageStop",
+ "GeckoView:ProgressChanged",
+ "GeckoView:SecurityChanged",
+ "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final ProgressDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:PageStart".equals(event)) {
+ delegate.onPageStart(GeckoSession.this, message.getString("uri"));
+ } else if ("GeckoView:PageStop".equals(event)) {
+ delegate.onPageStop(GeckoSession.this, message.getBoolean("success"));
+ } 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) {
+ if (getHistoryDelegate() == null) {
+ mStateCache.updateSessionState(update);
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ delegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ScrollDelegate> mScrollHandler =
+ new GeckoSessionHandler<ScrollDelegate>(
+ "GeckoViewScroll", this, new String[] {"GeckoView:ScrollChanged"}) {
+ @Override
+ public void handleMessage(
+ final ScrollDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ScrollChanged".equals(event)) {
+ delegate.onScrollChanged(
+ GeckoSession.this, message.getInt("scrollX"), message.getInt("scrollY"));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ContentBlocking.Delegate> mContentBlockingHandler =
+ new GeckoSessionHandler<ContentBlocking.Delegate>(
+ "GeckoViewContentBlocking", this, new String[] {"GeckoView:ContentBlockingEvent"}) {
+ @Override
+ public void handleMessage(
+ final ContentBlocking.Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ContentBlockingEvent".equals(event)) {
+ final ContentBlocking.BlockEvent be = ContentBlocking.BlockEvent.fromBundle(message);
+ if (be.isBlocking()) {
+ delegate.onContentBlocked(GeckoSession.this, be);
+ } else {
+ delegate.onContentLoaded(GeckoSession.this, be);
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<PermissionDelegate> mPermissionHandler =
+ new GeckoSessionHandler<PermissionDelegate>(
+ "GeckoViewPermission",
+ this,
+ new String[] {
+ "GeckoView:AndroidPermission",
+ "GeckoView:ContentPermission",
+ "GeckoView:MediaPermission"
+ }) {
+ @Override
+ public void handleMessage(
+ final PermissionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if (delegate == null) {
+ callback.sendSuccess(/* granted */ false);
+ return;
+ }
+ if ("GeckoView:AndroidPermission".equals(event)) {
+ delegate.onAndroidPermissionsRequest(
+ GeckoSession.this,
+ message.getStringArray("perms"),
+ new PermissionCallback("android", callback));
+ } else if ("GeckoView:ContentPermission".equals(event)) {
+ final GeckoResult<Integer> res =
+ delegate.onContentPermissionRequest(
+ GeckoSession.this, new PermissionDelegate.ContentPermission(message));
+ if (res == null) {
+ callback.sendSuccess(PermissionDelegate.ContentPermission.VALUE_PROMPT);
+ return;
+ }
+
+ callback.resolveTo(res);
+ } else if ("GeckoView:MediaPermission".equals(event)) {
+ final GeckoBundle[] videoBundles = message.getBundleArray("video");
+ final GeckoBundle[] audioBundles = message.getBundleArray("audio");
+ PermissionDelegate.MediaSource[] videos = null;
+ PermissionDelegate.MediaSource[] audios = null;
+
+ if (videoBundles != null) {
+ videos = new PermissionDelegate.MediaSource[videoBundles.length];
+ for (int i = 0; i < videoBundles.length; i++) {
+ videos[i] = new PermissionDelegate.MediaSource(videoBundles[i]);
+ }
+ }
+
+ if (audioBundles != null) {
+ audios = new PermissionDelegate.MediaSource[audioBundles.length];
+ for (int i = 0; i < audioBundles.length; i++) {
+ audios[i] = new PermissionDelegate.MediaSource(audioBundles[i]);
+ }
+ }
+
+ delegate.onMediaPermissionRequest(
+ GeckoSession.this,
+ message.getString("uri"),
+ videos,
+ audios,
+ new PermissionCallback("media", callback));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<SelectionActionDelegate> mSelectionActionDelegate =
+ new GeckoSessionHandler<SelectionActionDelegate>(
+ "GeckoViewSelectionAction",
+ this,
+ new String[] {
+ "GeckoView:HideSelectionAction",
+ "GeckoView:ShowSelectionAction",
+ "GeckoView:HideMagnifier",
+ "GeckoView:ShowMagnifier",
+ "GeckoView:ClipboardPermissionRequest",
+ "GeckoView:DismissClipboardPermissionRequest",
+ }) {
+ @Override
+ public void handleMessage(
+ final SelectionActionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if ("GeckoView:ShowSelectionAction".equals(event)) {
+ final @SelectionActionDelegateAction HashSet<String> actionsSet =
+ new HashSet<>(Arrays.asList(message.getStringArray("actions")));
+ final SelectionActionDelegate.Selection selection =
+ new SelectionActionDelegate.Selection(message, actionsSet, mEventDispatcher);
+
+ delegate.onShowActionRequest(GeckoSession.this, selection);
+
+ } else if ("GeckoView:HideSelectionAction".equals(event)) {
+ final String reasonString = message.getString("reason");
+ final int reason;
+ if ("invisibleselection".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION;
+ } else if ("presscaret".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION;
+ } else if ("scroll".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL;
+ } else if ("visibilitychange".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_NO_SELECTION;
+ } else {
+ throw new IllegalArgumentException();
+ }
+
+ delegate.onHideAction(GeckoSession.this, reason);
+ } else if ("GeckoView:ShowMagnifier".equals(event)) {
+ final PointF point = message.getPointF("screenPoint");
+ if (point == null) {
+ throw new IllegalArgumentException("Invalid argument");
+ }
+
+ // Magnifier is surface coordinate.
+ point.x -= GeckoSession.this.mLeft;
+ point.y -= GeckoSession.this.mClientTop;
+ GeckoSession.this.getMagnifier().show(point);
+ } 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<AllowOrDeny> 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);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<MediaDelegate> mMediaHandler =
+ new GeckoSessionHandler<MediaDelegate>(
+ "GeckoViewMedia",
+ this,
+ new String[] {
+ "GeckoView:MediaRecordingStatusChanged",
+ }) {
+ @Override
+ public void handleMessage(
+ final MediaDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:MediaRecordingStatusChanged".equals(event)) {
+ final GeckoBundle[] deviceBundles = message.getBundleArray("devices");
+ final MediaDelegate.RecordingDevice[] devices =
+ new MediaDelegate.RecordingDevice[deviceBundles.length];
+ for (int i = 0; i < deviceBundles.length; i++) {
+ devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]);
+ }
+ delegate.onRecordingStatusChanged(GeckoSession.this, devices);
+ return;
+ }
+ }
+ };
+
+ private final MediaSession.Handler mMediaSessionHandler = new MediaSession.Handler(this);
+
+ /* package */ int handlersCount;
+
+ private final GeckoSessionHandler<?>[] mSessionHandlers =
+ new GeckoSessionHandler<?>[] {
+ mContentHandler, mHistoryHandler, mMediaHandler,
+ mNavigationHandler, mPermissionHandler, mProcessHangHandler,
+ mProgressHandler, mScrollHandler, mSelectionActionDelegate,
+ mContentBlockingHandler, mMediaSessionHandler
+ };
+
+ private static class PermissionCallback
+ implements PermissionDelegate.Callback, PermissionDelegate.MediaCallback {
+
+ private final String mType;
+ private EventCallback mCallback;
+
+ public PermissionCallback(final String type, final EventCallback callback) {
+ mType = type;
+ mCallback = callback;
+ }
+
+ private void submit(final Object response) {
+ if (mCallback != null) {
+ mCallback.sendSuccess(response);
+ mCallback = null;
+ }
+ }
+
+ @Override // PermissionDelegate.Callback
+ public void grant() {
+ if ("media".equals(mType)) {
+ throw new UnsupportedOperationException();
+ }
+ submit(/* response */ true);
+ }
+
+ @Override // PermissionDelegate.Callback, PermissionDelegate.MediaCallback
+ public void reject() {
+ submit(/* response */ false);
+ }
+
+ @Override // PermissionDelegate.MediaCallback
+ public void grant(final String video, final String audio) {
+ if (!"media".equals(mType)) {
+ throw new UnsupportedOperationException();
+ }
+ final GeckoBundle response = new GeckoBundle(2);
+ response.putString("video", video);
+ response.putString("audio", audio);
+ submit(response);
+ }
+
+ @Override // PermissionDelegate.MediaCallback
+ public void grant(
+ final PermissionDelegate.MediaSource video, final PermissionDelegate.MediaSource audio) {
+ grant(video != null ? video.id : null, audio != null ? audio.id : null);
+ }
+ }
+
+ /**
+ * Get the current user agent string for this GeckoSession.
+ *
+ * @return a {@link GeckoResult} containing the UserAgent string
+ */
+ @AnyThread
+ public @NonNull GeckoResult<String> getUserAgent() {
+ return mEventDispatcher.queryString("GeckoView:GetUserAgent");
+ }
+
+ /**
+ * Get the default user agent for this GeckoView build.
+ *
+ * <p>This method does not account for any override that might have been applied to the user agent
+ * string.
+ *
+ * @return the default user agent string
+ */
+ @AnyThread
+ public static @NonNull String getDefaultUserAgent() {
+ return BuildConfig.USER_AGENT_GECKOVIEW_MOBILE;
+ }
+
+ /**
+ * Get the current permission delegate for this GeckoSession.
+ *
+ * @return PermissionDelegate instance or null if using default delegate.
+ */
+ @UiThread
+ public @Nullable PermissionDelegate getPermissionDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mPermissionHandler.getDelegate();
+ }
+
+ /**
+ * Set the current permission delegate for this GeckoSession.
+ *
+ * @param delegate PermissionDelegate instance or null to use the default delegate.
+ */
+ @UiThread
+ public void setPermissionDelegate(final @Nullable PermissionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mPermissionHandler.setDelegate(delegate, this);
+ }
+
+ private PromptDelegate mPromptDelegate;
+
+ private final Listener mListener = new Listener();
+
+ /* package */ static final class Window extends JNIObject implements IInterface {
+ public final GeckoRuntime runtime;
+ private WeakReference<GeckoSession> mOwner;
+ private NativeQueue mNativeQueue;
+ private Binder mBinder;
+
+ public Window(
+ final @NonNull GeckoRuntime runtime,
+ final @NonNull GeckoSession owner,
+ final @NonNull NativeQueue nativeQueue) {
+ this.runtime = runtime;
+ mOwner = new WeakReference<>(owner);
+ mNativeQueue = nativeQueue;
+ }
+
+ @Override // IInterface
+ public Binder asBinder() {
+ if (mBinder == null) {
+ mBinder = new Binder();
+ mBinder.attachInterface(this, Window.class.getName());
+ }
+ return mBinder;
+ }
+
+ // Create a new Gecko window and assign an initial set of Java session objects to it.
+ @WrapForJNI(dispatchTo = "proxy")
+ public static native void open(
+ Window instance,
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData,
+ String id,
+ String chromeUri,
+ boolean privateMode);
+
+ @Override // JNIObject
+ public void disposeNative() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeDisposeNative();
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, this, "nativeDisposeNative");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "DisposeNative")
+ private native void nativeDisposeNative();
+
+ // Force the underlying Gecko window to close and release assigned Java objects.
+ public void close() {
+ // Reset our queue, so we don't end up with queued calls on a disposed object.
+ synchronized (this) {
+ if (mNativeQueue == null) {
+ // Already closed elsewhere.
+ return;
+ }
+ mNativeQueue.reset(State.INITIAL);
+ mNativeQueue = null;
+ mOwner = new WeakReference<>(null);
+ }
+
+ // Detach ourselves from the binder as well, to prevent this window from being
+ // read from any parcels.
+ asBinder().attachInterface(null, Window.class.getName());
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeClose();
+ } else {
+ GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, "nativeClose");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Close")
+ private native void nativeClose();
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Transfer")
+ private native void nativeTransfer(
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachEditable(IGeckoEditableParent parent);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachAccessibility(
+ SessionAccessibility.NativeProvider sessionAccessibility);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void printToPdf(GeckoResult<InputStream> geckoResult);
+
+ @WrapForJNI(calledFrom = "gecko")
+ private synchronized void onReady(final @Nullable NativeQueue queue) {
+ // onReady is called the first time the Gecko window is ready, with a null queue
+ // argument. In this case, we simply set the current queue to ready state.
+ //
+ // After the initial call, onReady is called again every time Window.transfer()
+ // is called, with a non-null queue argument. In this case, we only set the
+ // current queue to ready state _if_ the current queue matches the given queue,
+ // because if the queues don't match, we know there is another onReady call coming.
+
+ if ((queue == null && mNativeQueue == null) || (queue != null && mNativeQueue != queue)) {
+ return;
+ }
+
+ if (mNativeQueue.checkAndSetState(State.INITIAL, State.READY) && queue == null) {
+ Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - chrome startup finished");
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ close();
+ disposeNative();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private GeckoResult<Boolean> onLoadRequest(
+ final @NonNull String uri,
+ final int windowType,
+ final int flags,
+ final @Nullable String triggeringUri,
+ final boolean hasUserGesture,
+ final boolean isTopLevel) {
+ final ProfilerController profilerController = runtime.getProfilerController();
+ final Double onLoadRequestProfilerStartTime = profilerController.getProfilerTime();
+ final Runnable addMarker =
+ () ->
+ profilerController.addMarker(
+ "GeckoSession.onLoadRequest", onLoadRequestProfilerStartTime);
+
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ // Don't handle any load request if we can't get the session for some reason.
+ return GeckoResult.fromValue(false);
+ }
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+
+ ThreadUtils.postToUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ final NavigationDelegate delegate = session.getNavigationDelegate();
+
+ if (delegate == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ if (!IntentUtils.isUriSafeForScheme(uri)) {
+ delegate.onLoadError(
+ session,
+ uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+ res.complete(true);
+ addMarker.run();
+ return;
+ }
+
+ final String trigger = TextUtils.isEmpty(triggeringUri) ? null : triggeringUri;
+ final NavigationDelegate.LoadRequest req =
+ new NavigationDelegate.LoadRequest(
+ uri,
+ trigger,
+ windowType,
+ flags,
+ hasUserGesture,
+ false /* isDirectNavigation */);
+ final GeckoResult<AllowOrDeny> reqResponse =
+ isTopLevel
+ ? delegate.onLoadRequest(session, req)
+ : delegate.onSubframeLoadRequest(session, req);
+
+ if (reqResponse == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ reqResponse.accept(
+ value -> {
+ if (value == AllowOrDeny.DENY) {
+ res.complete(true);
+ } else {
+ res.complete(false);
+ }
+ addMarker.run();
+ },
+ ex -> {
+ // This is incredibly ugly and unreadable because checkstyle sucks.
+ res.complete(false);
+ addMarker.run();
+ });
+ }
+ });
+
+ return res;
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void passExternalWebResponse(final WebResponse response) {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onExternalResponse(session, response);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onShowDynamicToolbar() {
+ final Window self = this;
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = self.mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onShowDynamicToolbar(session);
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onUpdateSessionStore(final GeckoBundle aBundle) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ GeckoBundle scroll = aBundle.getBundle("scroll");
+ if (scroll == null) {
+ scroll = new GeckoBundle();
+ aBundle.putBundle("scroll", scroll);
+ }
+
+ // Here we unfortunately need to do some re-mapping since `zoom` is passed in a separate
+ // bunds and we wish to keep the bundle format.
+ scroll.putBundle("zoom", aBundle.getBundle("zoom"));
+ final SessionState stateCache = session.mStateCache;
+ stateCache.updateSessionState(aBundle);
+ final SessionState state = new SessionState(stateCache);
+ if (!state.isEmpty()) {
+ final ProgressDelegate progressDelegate = session.getProgressDelegate();
+ if (progressDelegate != null) {
+ progressDelegate.onSessionStateChange(session, state);
+ } else {
+ }
+ }
+ });
+ }
+ }
+
+ private class Listener implements BundleEventListener {
+ /* package */ void registerListeners() {
+ getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:PinOnScreen",
+ "GeckoView:Prompt",
+ "GeckoView:Prompt:Dismiss",
+ "GeckoView:Prompt:Update",
+ null);
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:PinOnScreen".equals(event)) {
+ GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned"));
+ } else if ("GeckoView:Prompt".equals(event)) {
+ mPromptController.handleEvent(GeckoSession.this, message.getBundle("prompt"), callback);
+ } else if ("GeckoView:Prompt:Dismiss".equals(event)) {
+ mPromptController.dismissPrompt(message.getString("id"));
+ } else if ("GeckoView:Prompt:Update".equals(event)) {
+ mPromptController.updatePrompt(message.getBundle("prompt"));
+ }
+ }
+ }
+
+ private final PromptController mPromptController;
+
+ protected @Nullable Window mWindow;
+ private GeckoSessionSettings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession() {
+ this(null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession(final @Nullable GeckoSessionSettings settings) {
+ mSettings = new GeckoSessionSettings(settings, this);
+ mListener.registerListeners();
+
+ mWebExtensionController = new WebExtension.SessionController(this);
+ mPromptController = new PromptController();
+
+ mAutofillSupport = new Autofill.Support(this);
+ mAutofillSupport.registerListeners();
+
+ if (BuildConfig.DEBUG_BUILD && handlersCount != mSessionHandlers.length) {
+ throw new AssertionError("Add new handler to handlers list");
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mWindow == null) {
+ return null;
+ }
+ return mWindow.runtime;
+ }
+
+ /* package */ synchronized void abandonWindow() {
+ if (mWindow == null) {
+ return;
+ }
+
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ true);
+ mWindow = null;
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ false);
+ }
+
+ /**
+ * Return whether this session is open.
+ *
+ * @return True if session is open.
+ * @see #open
+ * @see #close
+ */
+ @UiThread
+ public boolean isOpen() {
+ ThreadUtils.assertOnUiThread();
+ return mWindow != null;
+ }
+
+ /* package */ boolean isReady() {
+ return mNativeQueue.isReady();
+ }
+
+ private GeckoBundle createInitData() {
+ final GeckoBundle initData = new GeckoBundle(2);
+ initData.putBundle("settings", mSettings.toBundle());
+
+ final GeckoBundle modules = new GeckoBundle(mSessionHandlers.length);
+ for (final GeckoSessionHandler<?> handler : mSessionHandlers) {
+ modules.putBoolean(handler.getName(), handler.isEnabled());
+ }
+ initData.putBundle("modules", modules);
+ return initData;
+ }
+
+ /**
+ * Opens the session.
+ *
+ * <p>Call this when you are ready to use a GeckoSession instance.
+ *
+ * <p>The session is in a 'closed' state when first created. Opening it creates the underlying
+ * Gecko objects necessary to load a page, etc. Most GeckoSession methods only take affect on an
+ * open session, and are queued until the session is opened here. Opening a session is an
+ * asynchronous operation.
+ *
+ * @param runtime The Gecko runtime to attach this session to.
+ * @see #close
+ * @see #isOpen
+ */
+ @UiThread
+ public void open(final @NonNull GeckoRuntime runtime) {
+ open(runtime, UUID.randomUUID().toString().replace("-", ""));
+ }
+
+ /* package */ void open(final @NonNull GeckoRuntime runtime, final String id) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isOpen()) {
+ // We will leak the existing Window if we open another one.
+ throw new IllegalStateException("Session is open");
+ }
+
+ final String chromeUri = mSettings.getChromeUri();
+ final boolean isPrivate = mSettings.getUsePrivateMode();
+
+ mId = id;
+ mWindow = new Window(runtime, this, mNativeQueue);
+ mWebExtensionController.setRuntime(runtime);
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ true);
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ Window.open(
+ mWindow,
+ mNativeQueue,
+ mCompositor,
+ mEventDispatcher,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ createInitData(),
+ mId,
+ chromeUri,
+ isPrivate);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ Window.class,
+ "open",
+ Window.class,
+ mWindow,
+ NativeQueue.class,
+ mNativeQueue,
+ Compositor.class,
+ mCompositor,
+ EventDispatcher.class,
+ mEventDispatcher,
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ GeckoBundle.class,
+ createInitData(),
+ String.class,
+ mId,
+ String.class,
+ chromeUri,
+ isPrivate);
+ }
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ false);
+ }
+
+ /**
+ * Closes the session.
+ *
+ * <p>This frees the underlying Gecko objects and unloads the current page. The session may be
+ * reopened later, but page state is not restored. Call this when you are finished using a
+ * GeckoSession instance.
+ *
+ * @see #open
+ * @see #isOpen
+ */
+ @UiThread
+ public void close() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!isOpen()) {
+ Log.w(LOGTAG, "Attempted to close a GeckoSession that was already closed.");
+ return;
+ }
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ true);
+
+ // We need to ensure the compositor releases any Surface it currently holds.
+ onSurfaceDestroyed();
+
+ mWindow.close();
+ mWindow.disposeNative();
+ // Can't access the compositor after we dispose of the window
+ mCompositorReady = false;
+ mWindow = null;
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ false);
+ }
+
+ private void onWindowChanged(final int change, final boolean inProgress) {
+ if ((change == WINDOW_OPEN || change == WINDOW_TRANSFER_IN) && !inProgress) {
+ mTextInput.onWindowChanged(mWindow);
+ }
+ if ((change == WINDOW_CLOSE || change == WINDOW_TRANSFER_OUT) && !inProgress) {
+ getAutofillSupport().clear();
+ }
+ }
+
+ /**
+ * Get the SessionTextInput instance for this session. May be called on any thread.
+ *
+ * @return SessionTextInput instance.
+ */
+ @AnyThread
+ public @NonNull SessionTextInput getTextInput() {
+ // May be called on any thread.
+ return mTextInput;
+ }
+
+ /**
+ * Get the SessionAccessibility instance for this session.
+ *
+ * @return SessionAccessibility instance.
+ */
+ @UiThread
+ public @NonNull SessionAccessibility getAccessibility() {
+ ThreadUtils.assertOnUiThread();
+ if (mAccessibility != null) {
+ return mAccessibility;
+ }
+
+ mAccessibility = new SessionAccessibility(this);
+ if (mWindow != null) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ mWindow.attachAccessibility(mAccessibility.nativeProvider);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ mWindow,
+ "attachAccessibility",
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility.nativeProvider);
+ }
+ }
+ return mAccessibility;
+ }
+
+ /**
+ * Get the SessionMagnifier instance for this session.
+ *
+ * @return SessionMagnifier instance.
+ */
+ @UiThread
+ /* package */ @NonNull
+ SessionMagnifier getMagnifier() {
+ ThreadUtils.assertOnUiThread();
+ if (mMagnifier == null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ mMagnifier = new SessionMagnifierP(mCompositor);
+ } else {
+ mMagnifier = new SessionMagnifier() {};
+ }
+ }
+
+ return mMagnifier;
+ }
+
+ // The priority of the GeckoSession, either default or high.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PRIORITY_DEFAULT, PRIORITY_HIGH})
+ public @interface Priority {}
+
+ /** Value for Priority when it is default. */
+ public static final int PRIORITY_DEFAULT = 0;
+
+ /** Value for Priority when it is high. */
+ public static final int PRIORITY_HIGH = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ LOAD_FLAGS_NONE,
+ LOAD_FLAGS_BYPASS_CACHE,
+ LOAD_FLAGS_BYPASS_PROXY,
+ LOAD_FLAGS_EXTERNAL,
+ LOAD_FLAGS_ALLOW_POPUPS,
+ LOAD_FLAGS_FORCE_ALLOW_DATA_URI,
+ LOAD_FLAGS_REPLACE_HISTORY
+ })
+ public @interface LoadFlags {}
+
+ // These flags follow similarly named ones in Gecko's nsIWebNavigation.idl
+ // https://searchfox.org/mozilla-central/source/docshell/base/nsIWebNavigation.idl
+ //
+ // We do not use the same values directly in order to insulate ourselves from
+ // changes in Gecko. Instead, the flags are converted in GeckoViewNavigation.jsm.
+
+ /** Default load flag, no special considerations. */
+ public static final int LOAD_FLAGS_NONE = 0;
+
+ /** Bypass the cache. */
+ public static final int LOAD_FLAGS_BYPASS_CACHE = 1 << 0;
+
+ /** Bypass the proxy, if one has been configured. */
+ public static final int LOAD_FLAGS_BYPASS_PROXY = 1 << 1;
+
+ /** The load is coming from an external app. Perform additional checks. */
+ public static final int LOAD_FLAGS_EXTERNAL = 1 << 2;
+
+ /** Popup blocking will be disabled for this load */
+ public static final int LOAD_FLAGS_ALLOW_POPUPS = 1 << 3;
+
+ /** Bypass the URI classifier (content blocking and Safe Browsing). */
+ public static final int LOAD_FLAGS_BYPASS_CLASSIFIER = 1 << 4;
+
+ /**
+ * Allows a top-level data: navigation to occur. E.g. view-image is an explicit user action which
+ * should be allowed.
+ */
+ public static final int LOAD_FLAGS_FORCE_ALLOW_DATA_URI = 1 << 5;
+
+ /** This flag specifies that any existing history entry should be replaced. */
+ public static final int LOAD_FLAGS_REPLACE_HISTORY = 1 << 6;
+
+ /**
+ * Filter headers according to the CORS safelisted rules.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ */
+ public static final int HEADER_FILTER_CORS_SAFELISTED = 1;
+ /**
+ * Allows most headers.
+ *
+ * <p>Note: the <code>Host</code> and <code>Connection</code> headers are still ignored.
+ *
+ * <p>This should only be used when input is hard-coded from the app or when properly sanitized,
+ * as some headers could cause unexpected consequences and security issues.
+ *
+ * <p>Only use this if you know what you're doing.
+ */
+ public static final int HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(value = {HEADER_FILTER_CORS_SAFELISTED, HEADER_FILTER_UNRESTRICTED_UNSAFE})
+ public @interface HeaderFilter {}
+
+ /**
+ * Main entry point for loading URIs into a {@link GeckoSession}.
+ *
+ * <p>The simplest use case is loading a URIs with no extra options, this can be accomplished by
+ * specifying the URI in {@link #uri} and then calling {@link #load}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().uri("http://mozilla.org"));
+ * </code></pre>
+ *
+ * This class can also be used to load <code>data:</code> URIs, either from a <code>byte[]</code>
+ * array or a <code>String</code> using {@link #data}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().data("the data:1234,5678", "text/plain"));
+ * </code></pre>
+ *
+ * This class also allows you to specify some extra data, e.g. you can set a referrer using {@link
+ * #referrer} which can either be a {@link GeckoSession} or a plain URL string. You can also
+ * specify some Load Flags using {@link #flags}.
+ *
+ * <p>The class is structured as a Builder, so method calls can be easily chained, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader()
+ * .url("http://mozilla.org")
+ * .referrer("http://my-referrer.com")
+ * .flags(...));
+ * </code></pre>
+ */
+ @AnyThread
+ public static class Loader {
+ private String mUri;
+ private GeckoSession mReferrerSession;
+ private String mReferrerUri;
+ private GeckoBundle mHeaders;
+ private @LoadFlags int mLoadFlags = LOAD_FLAGS_NONE;
+ private boolean mIsDataUri;
+ private @HeaderFilter int mHeaderFilter = HEADER_FILTER_CORS_SAFELISTED;
+
+ private static @NonNull String createDataUri(
+ @NonNull final byte[] bytes, @Nullable final String mimeType) {
+ return String.format(
+ "data:%s;base64,%s",
+ mimeType != null ? mimeType : "", Base64.encodeToString(bytes, Base64.NO_WRAP));
+ }
+
+ private static @NonNull String createDataUri(
+ @NonNull final String data, @Nullable final String mimeType) {
+ return String.format("data:%s,%s", mimeType != null ? mimeType : "", data);
+ }
+
+ @Override
+ public int hashCode() {
+ // Move to Objects.hashCode once our MIN_SDK >= 19
+ return Arrays.hashCode(
+ new Object[] {
+ mUri, mReferrerSession, mReferrerUri, mHeaders, mLoadFlags, mIsDataUri, mHeaderFilter
+ });
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ return Objects.equals(a, b);
+ }
+
+ return (a == b) || (a != null && a.equals(b));
+ }
+
+ @Override
+ public boolean equals(final @Nullable Object obj) {
+ if (!(obj instanceof Loader)) {
+ return false;
+ }
+
+ final Loader other = (Loader) obj;
+ return equals(mUri, other.mUri)
+ && equals(mReferrerSession, other.mReferrerSession)
+ && equals(mReferrerUri, other.mReferrerUri)
+ && equals(mHeaders, other.mHeaders)
+ && equals(mLoadFlags, other.mLoadFlags)
+ && equals(mIsDataUri, other.mIsDataUri)
+ && equals(mHeaderFilter, other.mHeaderFilter);
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a String containg the URI
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull String uri) {
+ mUri = uri;
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a {@link Uri} instance
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull Uri uri) {
+ mUri = uri.toString();
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param bytes a <code>byte</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull byte[] bytes, final @Nullable String mimeType) {
+ mUri = createDataUri(bytes, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param data a <code>String</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull String data, final @Nullable String mimeType) {
+ mUri = createDataUri(data, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrer a <code>GeckoSession</code> that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull GeckoSession referrer) {
+ mReferrerSession = referrer;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a {@link Uri} that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull Uri referrerUri) {
+ mReferrerUri = referrerUri != null ? referrerUri.toString() : null;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a <code>String</code> containing the URI that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull String referrerUri) {
+ mReferrerUri = referrerUri;
+ return this;
+ }
+
+ /**
+ * Add headers for this load.
+ *
+ * <p>Note: only CORS safelisted headers are allowed by default. To modify this behavior use
+ * {@link #headerFilter}.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ *
+ * @param headers a <code>Map</code> containing headers that will be added to this load.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader additionalHeaders(final @NonNull Map<String, String> headers) {
+ final GeckoBundle bundle = new GeckoBundle(headers.size());
+ for (final Map.Entry<String, String> entry : headers.entrySet()) {
+ if (entry.getKey() == null) {
+ // Ignore null keys
+ continue;
+ }
+ bundle.putString(entry.getKey(), entry.getValue());
+ }
+ mHeaders = bundle;
+ return this;
+ }
+
+ /**
+ * Modify the header filter behavior. By default only CORS safelisted headers are allowed.
+ *
+ * @param filter one of the {@link GeckoSession#HEADER_FILTER_CORS_SAFELISTED HEADER_FILTER_*}
+ * constants.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader headerFilter(final @HeaderFilter int filter) {
+ mHeaderFilter = filter;
+ return this;
+ }
+
+ /**
+ * Set the load flags for this load.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ * that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader flags(final @LoadFlags int flags) {
+ mLoadFlags = flags;
+ return this;
+ }
+ }
+
+ /**
+ * Load page using the {@link Loader} specified.
+ *
+ * @param request Loader for this request.
+ * @see Loader
+ */
+ @AnyThread
+ public void load(final @NonNull Loader request) {
+ if (request.mUri == null) {
+ throw new IllegalArgumentException(
+ "You need to specify at least one between `uri` and `data`.");
+ }
+
+ if (request.mReferrerUri != null && request.mReferrerSession != null) {
+ throw new IllegalArgumentException(
+ "Cannot specify both a referrer session and a referrer URI.");
+ }
+
+ final NavigationDelegate navDelegate = mNavigationHandler.getDelegate();
+ final boolean isDataUriTooLong = !maybeCheckDataUriLength(request);
+ if (navDelegate == null && isDataUriTooLong) {
+ throw new IllegalArgumentException("data URI is too long");
+ }
+
+ final int loadFlags =
+ request.mIsDataUri
+ // If this is a data: load then we need to force allow it.
+ ? request.mLoadFlags | LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+ : request.mLoadFlags;
+
+ // For performance reasons we short-circuit the delegate here
+ // instead of making Gecko call it for direct load calls.
+ final NavigationDelegate.LoadRequest loadRequest =
+ new NavigationDelegate.LoadRequest(
+ request.mUri,
+ null, /* triggerUri */
+ 1, /* geckoTarget: OPEN_CURRENTWINDOW */
+ 0, /* flags */
+ false, /* hasUserGesture */
+ true /* isDirectNavigation */);
+
+ shouldLoadUri(loadRequest)
+ .getOrAccept(
+ allowOrDeny -> {
+ if (allowOrDeny == AllowOrDeny.DENY) {
+ return;
+ }
+
+ if (isDataUriTooLong) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ navDelegate.onLoadError(
+ this,
+ request.mUri,
+ new WebRequestError(
+ WebRequestError.ERROR_DATA_URI_TOO_LONG,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+ });
+ return;
+ }
+
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putString("uri", request.mUri);
+ msg.putInt("flags", loadFlags);
+ msg.putInt("headerFilter", request.mHeaderFilter);
+
+ if (request.mReferrerUri != null) {
+ msg.putString("referrerUri", request.mReferrerUri);
+ }
+
+ if (request.mReferrerSession != null) {
+ msg.putString("referrerSessionId", request.mReferrerSession.mId);
+ }
+
+ if (request.mHeaders != null) {
+ msg.putBundle("headers", request.mHeaders);
+ }
+
+ mEventDispatcher.dispatch("GeckoView:LoadUri", msg);
+ });
+ }
+
+ /**
+ * Load the given URI.
+ *
+ * <p>Convenience method for
+ *
+ * <pre><code>
+ * session.load(new Loader().uri(uri));
+ * </code></pre>
+ *
+ * @param uri The URI of the resource to load.
+ */
+ @AnyThread
+ public void loadUri(final @NonNull String uri) {
+ load(new Loader().uri(uri));
+ }
+
+ private GeckoResult<AllowOrDeny> shouldLoadUri(final NavigationDelegate.LoadRequest request) {
+ final NavigationDelegate delegate = mNavigationHandler.getDelegate();
+ if (delegate == null) {
+ return GeckoResult.allow();
+ }
+
+ // Always run the callback on the UI thread regardless of what thread we were called in.
+ final GeckoResult<AllowOrDeny> result = new GeckoResult<>(ThreadUtils.getUiHandler());
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<AllowOrDeny> delegateResult = delegate.onLoadRequest(this, request);
+
+ if (delegateResult == null) {
+ result.complete(AllowOrDeny.ALLOW);
+ } else {
+ delegateResult.getOrAccept(
+ allowOrDeny -> result.complete(allowOrDeny),
+ error -> result.completeExceptionally(error));
+ }
+ });
+
+ return result;
+ }
+
+ /** Reload the current URI. */
+ @AnyThread
+ public void reload() {
+ reload(LOAD_FLAGS_NONE);
+ }
+
+ /**
+ * Reload the current URI.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ */
+ @AnyThread
+ public void reload(final @LoadFlags int flags) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putInt("flags", flags);
+ mEventDispatcher.dispatch("GeckoView:Reload", msg);
+ }
+
+ /** Stop loading. */
+ @AnyThread
+ public void stop() {
+ mEventDispatcher.dispatch("GeckoView:Stop", null);
+ }
+
+ /**
+ * Go back in history and assumes the call was based on a user interaction.
+ *
+ * @see #goBack(boolean)
+ */
+ @AnyThread
+ public void goBack() {
+ goBack(true);
+ }
+
+ /**
+ * Go back in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goBack(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoBack", msg);
+ }
+
+ /**
+ * Go forward in history and assumes the call was based on a user interaction.
+ *
+ * @see #goForward(boolean)
+ */
+ @AnyThread
+ public void goForward() {
+ goForward(true);
+ }
+
+ /**
+ * Go forward in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goForward(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoForward", msg);
+ }
+
+ /**
+ * Navigate to an index in browser history; the index of the currently viewed page can be
+ * retrieved from an up-to-date HistoryList by calling {@link
+ * HistoryDelegate.HistoryList#getCurrentIndex()}.
+ *
+ * @param index The index of the location in browser history you want to navigate to.
+ */
+ @AnyThread
+ public void gotoHistoryIndex(final int index) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("index", index);
+ mEventDispatcher.dispatch("GeckoView:GotoHistoryIndex", msg);
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoSession. Delegates attached to this controller
+ * will receive events specific to this session.
+ *
+ * @return an instance of {@link WebExtension.SessionController}.
+ */
+ @UiThread
+ public @NonNull WebExtension.SessionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Purge history for the session. The session history is used for back and forward history.
+ * Purging the session history means {@link NavigationDelegate#onCanGoBack(GeckoSession, boolean)}
+ * and {@link NavigationDelegate#onCanGoForward(GeckoSession, boolean)} will be false.
+ */
+ @AnyThread
+ public void purgeHistory() {
+ mEventDispatcher.dispatch("GeckoView:PurgeHistory", null);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_FIND_BACKWARDS,
+ FINDER_FIND_LINKS_ONLY,
+ FINDER_FIND_MATCH_CASE,
+ FINDER_FIND_WHOLE_WORD
+ })
+ public @interface FinderFindFlags {}
+
+ /** Go backwards when finding the next match. */
+ public static final int FINDER_FIND_BACKWARDS = 1;
+ /** Perform case-sensitive match; default is to perform a case-insensitive match. */
+ public static final int FINDER_FIND_MATCH_CASE = 1 << 1;
+ /** Must match entire words; default is to allow matching partial words. */
+ public static final int FINDER_FIND_WHOLE_WORD = 1 << 2;
+ /** Limit matches to links on the page. */
+ public static final int FINDER_FIND_LINKS_ONLY = 1 << 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_DISPLAY_HIGHLIGHT_ALL,
+ FINDER_DISPLAY_DIM_PAGE,
+ FINDER_DISPLAY_DRAW_LINK_OUTLINE
+ })
+ public @interface FinderDisplayFlags {}
+
+ /** Highlight all find-in-page matches. */
+ public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
+ /** Dim the rest of the page when showing a find-in-page match. */
+ public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1;
+ /** Draw outlines around matching links. */
+ public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2;
+
+ /** Represent the result of a find-in-page operation. */
+ @AnyThread
+ public static class FinderResult {
+ /** Whether a match was found. */
+ public final boolean found;
+ /** Whether the search wrapped around the top or bottom of the page. */
+ public final boolean wrapped;
+ /** Ordinal number of the current match starting from 1, or 0 if no match. */
+ public final int current;
+ /** Total number of matches found so far, or -1 if unknown. */
+ public final int total;
+ /** Search string. */
+ @NonNull public final String searchString;
+ /**
+ * Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS
+ * FINDER_FIND_*} flags.
+ */
+ @FinderFindFlags public final int flags;
+ /** URI of the link, if the current match is a link, or null otherwise. */
+ @Nullable public final String linkUri;
+ /** Bounds of the current match in client coordinates, or null if unknown. */
+ @Nullable public final RectF clientRect;
+
+ /* package */ FinderResult(@NonNull final GeckoBundle bundle) {
+ found = bundle.getBoolean("found");
+ wrapped = bundle.getBoolean("wrapped");
+ current = bundle.getInt("current", 0);
+ total = bundle.getInt("total", -1);
+ searchString = bundle.getString("searchString");
+ flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags"));
+ linkUri = bundle.getString("linkURL");
+ clientRect = bundle.getRectF("clientRect");
+ }
+
+ /** Empty constructor for tests */
+ protected FinderResult() {
+ found = false;
+ wrapped = false;
+ current = 0;
+ total = 0;
+ flags = 0;
+ searchString = "";
+ linkUri = "";
+ clientRect = null;
+ }
+ }
+
+ /**
+ * Get the SessionFinder instance for this session, to perform find-in-page operations.
+ *
+ * @return SessionFinder instance.
+ */
+ @AnyThread
+ public @NonNull SessionFinder getFinder() {
+ if (mFinder == null) {
+ mFinder = new SessionFinder(getEventDispatcher());
+ }
+ return mFinder;
+ }
+
+ /**
+ * Set this GeckoSession as active or inactive, which represents if the session is currently
+ * visible or not. Setting a GeckoSession to inactive will significantly reduce its memory
+ * footprint, but should only be done if the GeckoSession is not currently visible. Note that a
+ * session can be active (i.e. visible) but not focused. When a session is set inactive, it will
+ * flush the session state and trigger a `ProgressDelegate.onSessionStateChange` callback.
+ *
+ * @param active A boolean determining whether the GeckoSession is active.
+ * @see #setFocused
+ */
+ @AnyThread
+ public void setActive(final boolean active) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("active", active);
+ mEventDispatcher.dispatch("GeckoView:SetActive", msg);
+
+ if (!active) {
+ mEventDispatcher.dispatch("GeckoView:FlushSessionState", null);
+ ThreadUtils.postToUiThreadDelayed(mNotifyMemoryPressure, NOTIFY_MEMORY_PRESSURE_DELAY_MS);
+ } else {
+ // Delete any pending memory pressure events since we're active again.
+ ThreadUtils.removeUiThreadCallbacks(mNotifyMemoryPressure);
+ }
+
+ ThreadUtils.runOnUiThread(() -> getAutofillSupport().onActiveChanged(active));
+ }
+
+ /**
+ * Move focus to this session or away from this session. Only one session has focus at a given
+ * time. Note that a session can be unfocused but still active (i.e. visible).
+ *
+ * @param focused True if the session should gain focus or false if the session should lose focus.
+ * @see #setActive
+ */
+ @AnyThread
+ public void setFocused(final boolean focused) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("focused", focused);
+ mEventDispatcher.dispatch("GeckoView:SetFocused", msg);
+ }
+
+ /**
+ * Notify GeckoView of the priority for this GeckoSession.
+ *
+ * <p>Set this GeckoSession to high priority (PRIORITY_HIGH) whenever the app wants to signal to
+ * GeckoView that this GeckoSession is important to the app. GeckoView will keep the session state
+ * as long as possible. Set this to default priority (PRIORITY_DEFAULT) in any other case.
+ *
+ * @param priorityHint Priority of the geckosession, either high priority or default.
+ */
+ @AnyThread
+ public void setPriorityHint(final @Priority int priorityHint) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("priorityHint", priorityHint);
+ mEventDispatcher.dispatch("GeckoView:SetPriorityHint", msg);
+ }
+
+ /** Class representing a saved session state. */
+ @AnyThread
+ public static class SessionState extends AbstractSequentialList<HistoryDelegate.HistoryItem>
+ implements HistoryDelegate.HistoryList, Parcelable {
+ private GeckoBundle mState;
+
+ private class SessionStateItem implements HistoryDelegate.HistoryItem {
+ private final GeckoBundle mItem;
+
+ private SessionStateItem(final @NonNull GeckoBundle item) {
+ mItem = item;
+ }
+
+ @Override /* HistoryItem */
+ public String getUri() {
+ return mItem.getString("url");
+ }
+
+ @Override /* HistoryItem */
+ public String getTitle() {
+ return mItem.getString("title");
+ }
+ }
+
+ private class SessionStateIterator implements ListIterator<HistoryDelegate.HistoryItem> {
+ private final SessionState mState;
+ private int mIndex;
+
+ private SessionStateIterator(final @NonNull SessionState state) {
+ this(state, 0);
+ }
+
+ private SessionStateIterator(final @NonNull SessionState state, final int index) {
+ mIndex = index;
+ mState = state;
+ }
+
+ @Override /* ListIterator */
+ public void add(final HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public boolean hasNext() {
+ final GeckoBundle[] entries = mState.getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return false;
+ }
+
+ if (mIndex >= mState.getHistoryEntries().length) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override /* ListIterator */
+ public boolean hasPrevious() {
+ if (mIndex <= 0) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem next() {
+ if (hasNext()) {
+ mIndex++;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex - 1]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int nextIndex() {
+ return mIndex;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem previous() {
+ if (hasPrevious()) {
+ mIndex--;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int previousIndex() {
+ return mIndex - 1;
+ }
+
+ @Override /* ListIterator */
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public void set(final @NonNull HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private SessionState() {
+ mState = new GeckoBundle(3);
+ }
+
+ private SessionState(final @NonNull GeckoBundle state) {
+ mState = new GeckoBundle(state);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SessionState(final @NonNull SessionState state) {
+ mState = new GeckoBundle(state.mState);
+ }
+
+ /* package */ 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 int hashCode() {
+ return mState.hashCode();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (other == null || !(other instanceof SessionState)) {
+ return false;
+ }
+
+ final SessionState otherState = (SessionState) other;
+
+ return this.mState.equals(otherState.mState);
+ }
+
+ /**
+ * Creates a new SessionState instance from a value previously returned by {@link #toString()}.
+ *
+ * @param value The serialized SessionState in String form.
+ * @return A new SessionState instance if input is valid; otherwise null.
+ */
+ public static @Nullable SessionState fromString(final @Nullable String value) {
+ final GeckoBundle bundleState;
+ try {
+ bundleState = GeckoBundle.fromJSONObject(new JSONObject(value));
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "String does not represent valid session state.");
+ return null;
+ }
+
+ if (bundleState == null) {
+ return null;
+ }
+
+ return new SessionState(bundleState);
+ }
+
+ @Override
+ public @Nullable String toString() {
+ 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 (final 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(toString());
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ if (source.readString() == null) {
+ Log.w(LOGTAG, "Can't reproduce session state from Parcel");
+ }
+
+ try {
+ mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+ } catch (final 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) {
+ 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 (final 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];
+ }
+ };
+
+ @Override /* AbstractSequentialList */
+ public @NonNull HistoryDelegate.HistoryItem get(final int index) {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null || index < 0 || index >= entries.length) {
+ throw new NoSuchElementException();
+ }
+
+ return new SessionStateItem(entries[index]);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull Iterator<HistoryDelegate.HistoryItem> iterator() {
+ return listIterator(0);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull ListIterator<HistoryDelegate.HistoryItem> listIterator(final int index) {
+ return new SessionStateIterator(this, index);
+ }
+
+ @Override /* AbstractSequentialList */
+ public int size() {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return 0;
+ }
+
+ return entries.length;
+ }
+
+ @Override /* HistoryList */
+ public int getCurrentIndex() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ throw new IllegalStateException("No history state exists.");
+ }
+
+ return history.getInt("index") + history.getInt("fromIdx");
+ }
+
+ // Some helpers for common code.
+ private GeckoBundle getHistory() {
+ if (mState == null) {
+ return null;
+ }
+
+ return mState.getBundle("history");
+ }
+
+ private GeckoBundle[] getHistoryEntries() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ return null;
+ }
+
+ return history.getBundleArray("entries");
+ }
+ }
+
+ 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 onSessionStateChange().
+ */
+ @AnyThread
+ public void restoreState(final @NonNull SessionState state) {
+ mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState);
+ }
+
+ /**
+ * Get whether this GeckoSession has form data.
+ *
+ * @return a {@link GeckoResult} result of if there is existing form data.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> containsFormData() {
+ return mEventDispatcher.queryBoolean("GeckoView:ContainsFormData");
+ }
+
+ // This is the GeckoDisplay acquired via acquireDisplay(), if any.
+ private GeckoDisplay mDisplay;
+
+ /* package */ interface Owner {
+ void onRelease();
+ }
+
+ private static final WeakReference<Owner> NO_OWNER = new WeakReference<>(null);
+ private WeakReference<Owner> mOwner = NO_OWNER;
+
+ @UiThread
+ /* package */ void releaseOwner() {
+ ThreadUtils.assertOnUiThread();
+ mOwner = NO_OWNER;
+ }
+
+ @UiThread
+ /* package */ void setOwner(final Owner owner) {
+ ThreadUtils.assertOnUiThread();
+ final Owner oldOwner = mOwner.get();
+ if (oldOwner != null && owner != oldOwner) {
+ oldOwner.onRelease();
+ }
+ mOwner = new WeakReference<>(owner);
+ }
+
+ /* package */ GeckoDisplay getDisplay() {
+ return mDisplay;
+ }
+
+ /**
+ * Acquire the GeckoDisplay instance for providing the session with a drawing Surface. Be sure to
+ * call {@link GeckoDisplay#surfaceChanged(SurfaceInfo)} on the acquired display if there is
+ * already a valid Surface.
+ *
+ * @return GeckoDisplay instance.
+ * @see #releaseDisplay(GeckoDisplay)
+ */
+ @UiThread
+ public @NonNull GeckoDisplay acquireDisplay() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDisplay != null) {
+ throw new IllegalStateException("Display already acquired");
+ }
+
+ mDisplay = new GeckoDisplay(this);
+ return mDisplay;
+ }
+
+ /**
+ * Release an acquired GeckoDisplay instance. Be sure to call {@link
+ * GeckoDisplay#surfaceDestroyed()} before releasing the display if it still has a valid Surface.
+ *
+ * @param display Acquired GeckoDisplay instance.
+ * @see #acquireDisplay()
+ */
+ @UiThread
+ public void releaseDisplay(final @NonNull GeckoDisplay display) {
+ ThreadUtils.assertOnUiThread();
+
+ if (display != mDisplay) {
+ throw new IllegalArgumentException("Display not attached");
+ }
+
+ mDisplay = null;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoSessionSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Exits fullscreen mode */
+ @AnyThread
+ public void exitFullScreen() {
+ mEventDispatcher.dispatch("GeckoViewContent:ExitFullScreen", null);
+ }
+
+ /**
+ * Set the content callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ContentDelegate.
+ */
+ @UiThread
+ public void setContentDelegate(final @Nullable ContentDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mContentHandler.setDelegate(delegate, this);
+ mProcessHangHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content callback handler.
+ *
+ * @return The current content callback handler.
+ */
+ @UiThread
+ public @Nullable ContentDelegate getContentDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mContentHandler.getDelegate();
+ }
+
+ /**
+ * Set the progress callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ProgressDelegate.
+ */
+ @UiThread
+ public void setProgressDelegate(final @Nullable ProgressDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mProgressHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the progress callback handler.
+ *
+ * @return The current progress callback handler.
+ */
+ @UiThread
+ public @Nullable ProgressDelegate getProgressDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mProgressHandler.getDelegate();
+ }
+
+ /**
+ * Set the navigation callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of NavigationDelegate.
+ */
+ @UiThread
+ public void setNavigationDelegate(final @Nullable NavigationDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mNavigationHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the navigation callback handler.
+ *
+ * @return The current navigation callback handler.
+ */
+ @UiThread
+ public @Nullable NavigationDelegate getNavigationDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mNavigationHandler.getDelegate();
+ }
+
+ /**
+ * Set the content scroll callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ScrollDelegate.
+ */
+ @UiThread
+ public void setScrollDelegate(final @Nullable ScrollDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mScrollHandler.setDelegate(delegate, this);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable ScrollDelegate getScrollDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mScrollHandler.getDelegate();
+ }
+
+ /**
+ * Set the history tracking delegate for this session, replacing the current delegate if one is
+ * set.
+ *
+ * @param delegate The history tracking delegate, or {@code null} to unset.
+ */
+ @AnyThread
+ public void setHistoryDelegate(final @Nullable HistoryDelegate delegate) {
+ mHistoryHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * @return The history tracking delegate for this session.
+ */
+ @AnyThread
+ public @Nullable HistoryDelegate getHistoryDelegate() {
+ return mHistoryHandler.getDelegate();
+ }
+
+ /**
+ * Set the content blocking callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link ContentBlocking.Delegate}.
+ */
+ @AnyThread
+ public void setContentBlockingDelegate(final @Nullable ContentBlocking.Delegate delegate) {
+ mContentBlockingHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content blocking callback handler.
+ *
+ * @return The current content blocking callback handler.
+ */
+ @AnyThread
+ public @Nullable ContentBlocking.Delegate getContentBlockingDelegate() {
+ return mContentBlockingHandler.getDelegate();
+ }
+
+ /**
+ * Set the current prompt delegate for this GeckoSession.
+ *
+ * @param delegate PromptDelegate instance or null to use the built-in delegate.
+ */
+ @AnyThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Get the current prompt delegate for this GeckoSession.
+ *
+ * @return PromptDelegate instance or null if using built-in delegate.
+ */
+ @AnyThread
+ public @Nullable PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the current selection action delegate for this GeckoSession.
+ *
+ * @param delegate SelectionActionDelegate instance or null to unset.
+ */
+ @UiThread
+ public void setSelectionActionDelegate(final @Nullable SelectionActionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ if (getSelectionActionDelegate() != null) {
+ // When the delegate is changed or cleared, make sure onHideAction is called
+ // one last time to hide any existing selection action UI. Gecko doesn't keep
+ // track of the old delegate, so we can't rely on Gecko to do that for us.
+ getSelectionActionDelegate()
+ .onHideAction(this, GeckoSession.SelectionActionDelegate.HIDE_REASON_NO_SELECTION);
+ }
+ mSelectionActionDelegate.setDelegate(delegate, this);
+ }
+
+ /**
+ * Set the media callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of MediaDelegate.
+ */
+ @AnyThread
+ public void setMediaDelegate(final @Nullable MediaDelegate delegate) {
+ mMediaHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the Media callback handler.
+ *
+ * @return The current Media callback handler.
+ */
+ @AnyThread
+ public @Nullable MediaDelegate getMediaDelegate() {
+ return mMediaHandler.getDelegate();
+ }
+
+ /**
+ * Set the media session delegate. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link MediaSession.Delegate}.
+ */
+ @AnyThread
+ public void setMediaSessionDelegate(final @Nullable MediaSession.Delegate delegate) {
+ mMediaSessionHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the media session delegate.
+ *
+ * @return The current media session delegate.
+ */
+ @AnyThread
+ public @Nullable MediaSession.Delegate getMediaSessionDelegate() {
+ return mMediaSessionHandler.getDelegate();
+ }
+
+ /**
+ * Get the current selection action delegate for this GeckoSession.
+ *
+ * @return SelectionActionDelegate instance or null if not set.
+ */
+ @AnyThread
+ public @Nullable SelectionActionDelegate getSelectionActionDelegate() {
+ return mSelectionActionDelegate.getDelegate();
+ }
+
+ @UiThread
+ protected void setShouldPinOnScreen(final boolean pinned) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mShouldPinOnScreen = pinned;
+ }
+
+ /* package */ boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mShouldPinOnScreen;
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ EventDispatcher getEventDispatcher() {
+ return mEventDispatcher;
+ }
+
+ public interface ProgressDelegate {
+ /** Class representing security information for a site. */
+ public class SecurityInformation {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SECURITY_MODE_UNKNOWN, SECURITY_MODE_IDENTIFIED, SECURITY_MODE_VERIFIED})
+ public @interface SecurityMode {}
+
+ public static final int SECURITY_MODE_UNKNOWN = 0;
+ public static final int SECURITY_MODE_IDENTIFIED = 1;
+ public static final int SECURITY_MODE_VERIFIED = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CONTENT_UNKNOWN, CONTENT_BLOCKED, CONTENT_LOADED})
+ public @interface ContentType {}
+
+ public static final int CONTENT_UNKNOWN = 0;
+ public static final int CONTENT_BLOCKED = 1;
+ public static final int CONTENT_LOADED = 2;
+ /** Indicates whether or not the site is secure. */
+ public final boolean isSecure;
+ /** Indicates whether or not the site is a security exception. */
+ public final boolean isException;
+ /** Contains the origin of the certificate. */
+ public final @Nullable String origin;
+ /** Contains the host associated with the certificate. */
+ public final @NonNull String host;
+
+ /** The server certificate in use, if any. */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Indicates the security level of the site; possible values are SECURITY_MODE_UNKNOWN,
+ * SECURITY_MODE_IDENTIFIED, and SECURITY_MODE_VERIFIED. SECURITY_MODE_IDENTIFIED indicates
+ * domain validation only, while SECURITY_MODE_VERIFIED indicates extended validation.
+ */
+ public final @SecurityMode int securityMode;
+ /**
+ * Indicates the presence of passive mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModePassive;
+ /**
+ * Indicates the presence of active mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModeActive;
+
+ /* package */ SecurityInformation(final GeckoBundle identityData) {
+ final GeckoBundle mode = identityData.getBundle("mode");
+
+ mixedModePassive = mode.getInt("mixed_display");
+ mixedModeActive = mode.getInt("mixed_active");
+
+ securityMode = mode.getInt("identity");
+
+ isSecure = identityData.getBoolean("secure");
+ isException = identityData.getBoolean("securityException");
+ origin = identityData.getString("origin");
+ host = identityData.getString("host");
+
+ X509Certificate decodedCert = null;
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ final String certString = identityData.getString("certificate");
+ if (certString != null) {
+ final byte[] certBytes = Base64.decode(certString, Base64.NO_WRAP);
+ decodedCert =
+ (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes));
+ }
+ } catch (final CertificateException e) {
+ Log.e(LOGTAG, "Failed to decode certificate", e);
+ }
+
+ certificate = decodedCert;
+ }
+
+ /** Empty constructor for tests */
+ protected SecurityInformation() {
+ mixedModePassive = CONTENT_UNKNOWN;
+ mixedModeActive = CONTENT_UNKNOWN;
+ securityMode = SECURITY_MODE_UNKNOWN;
+ isSecure = false;
+ isException = false;
+ origin = "";
+ host = "";
+ certificate = null;
+ }
+ }
+
+ /**
+ * A View has started loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ */
+ @UiThread
+ default void onPageStart(@NonNull final GeckoSession session, @NonNull final String url) {}
+
+ /**
+ * A View has finished loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param success Whether the page loaded successfully or an error occurred.
+ */
+ @UiThread
+ default void onPageStop(@NonNull final GeckoSession session, final boolean success) {}
+
+ /**
+ * Page loading has progressed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param progress Current page load progress value [0, 100].
+ */
+ @UiThread
+ default void onProgressChange(@NonNull final GeckoSession session, final int progress) {}
+
+ /**
+ * The security status has been updated.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param securityInfo The new security information.
+ */
+ @UiThread
+ default void onSecurityChange(
+ @NonNull final GeckoSession session, @NonNull final 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 final GeckoSession session, @NonNull final SessionState sessionState) {}
+ }
+
+ /** WebResponseInfo contains information about a single web response. */
+ @AnyThread
+ public static class WebResponseInfo {
+ /** The URI of the response. Cannot be null. */
+ @NonNull public final String uri;
+
+ /** The content type (mime type) of the response. May be null. */
+ @Nullable public final String contentType;
+
+ /** The content length of the response. May be 0 if unknokwn. */
+ @Nullable public final long contentLength;
+
+ /** The filename obtained from the content disposition, if any. May be null. */
+ @Nullable public final String filename;
+
+ /* package */ WebResponseInfo(final GeckoBundle message) {
+ uri = message.getString("uri");
+ if (uri == null) {
+ throw new IllegalArgumentException("URI cannot be null");
+ }
+
+ contentType = message.getString("contentType");
+ contentLength = message.getLong("contentLength");
+ filename = message.getString("filename");
+ }
+
+ /** Empty constructor for tests. */
+ protected WebResponseInfo() {
+ uri = "";
+ contentType = "";
+ contentLength = 0;
+ filename = "";
+ }
+ }
+
+ public interface ContentDelegate {
+ /**
+ * A page title was discovered in the content or updated after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param title The title sent from the content.
+ */
+ @UiThread
+ default void onTitleChange(@NonNull final GeckoSession session, @Nullable final String title) {}
+
+ /**
+ * A preview image was discovered in the content after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param previewImageUrl The preview image URL sent from the content.
+ */
+ @UiThread
+ default void onPreviewImage(
+ @NonNull final GeckoSession session, @NonNull final String previewImageUrl) {}
+
+ /**
+ * A page has requested focus. Note that window.focus() in content will not result in this being
+ * called.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onFocusRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to close
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onCloseRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has entered or exited full screen mode. Typically, the implementation would set the
+ * Activity containing the GeckoSession to full screen when the page is in full screen mode.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param fullScreen True if the page is in full screen mode.
+ */
+ @UiThread
+ default void onFullScreen(@NonNull final GeckoSession session, final boolean fullScreen) {}
+
+ /**
+ * A viewport-fit was discovered in the content or updated after the content.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param viewportFit The value of viewport-fit of meta element in content.
+ * @see <a href="https://drafts.csswg.org/css-round-display/#viewport-fit-descriptor">4.1. The
+ * viewport-fit descriptor</a>
+ */
+ @UiThread
+ default void onMetaViewportFitChange(
+ @NonNull final GeckoSession session, @NonNull final String viewportFit) {}
+
+ /** Element details for onContextMenu callbacks. */
+ public static class ContextElement {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ public static final int TYPE_NONE = 0;
+ public static final int TYPE_IMAGE = 1;
+ public static final int TYPE_VIDEO = 2;
+ public static final int TYPE_AUDIO = 3;
+
+ /** The base URI of the element's document. */
+ public final @Nullable String baseUri;
+
+ /** The absolute link URI (href) of the element. */
+ public final @Nullable String linkUri;
+
+ /** The title text of the element. */
+ public final @Nullable String title;
+
+ /** The alternative text (alt) for the element. */
+ public final @Nullable String altText;
+
+ /** The type of the element. One of the {@link ContextElement#TYPE_NONE} flags. */
+ public final @Type int type;
+
+ /** The source URI (src) of the element. Set for (nested) media elements. */
+ public final @Nullable String srcUri;
+
+ // TODO: Bug 1595822 make public
+ final List<WebExtension.Menu> extensionMenus;
+
+ protected ContextElement(
+ final @Nullable String baseUri,
+ final @Nullable String linkUri,
+ final @Nullable String title,
+ final @Nullable String altText,
+ final @NonNull String typeStr,
+ final @Nullable String srcUri) {
+ this.baseUri = baseUri;
+ this.linkUri = linkUri;
+ this.title = title;
+ this.altText = altText;
+ this.type = getType(typeStr);
+ this.srcUri = srcUri;
+ this.extensionMenus = null;
+ }
+
+ private static int getType(final String name) {
+ if ("HTMLImageElement".equals(name)) {
+ return TYPE_IMAGE;
+ } else if ("HTMLVideoElement".equals(name)) {
+ return TYPE_VIDEO;
+ } else if ("HTMLAudioElement".equals(name)) {
+ return TYPE_AUDIO;
+ }
+ return TYPE_NONE;
+ }
+ }
+
+ /**
+ * A user has initiated the context menu via long-press. This event is fired on links, (nested)
+ * images and (nested) media elements.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param screenX The screen coordinates of the press.
+ * @param screenY The screen coordinates of the press.
+ * @param element The details for the pressed element.
+ */
+ @UiThread
+ default void onContextMenu(
+ @NonNull final GeckoSession session,
+ final int screenX,
+ final int screenY,
+ @NonNull final ContextElement element) {}
+
+ /**
+ * This is fired when there is a response that cannot be handled by Gecko (e.g., a download).
+ *
+ * @param session the GeckoSession that received the external response.
+ * @param response the external WebResponse.
+ */
+ @UiThread
+ default void onExternalResponse(
+ @NonNull final GeckoSession session, @NonNull final WebResponse response) {}
+
+ /**
+ * The content process hosting this GeckoSession has crashed. The GeckoSession is now closed and
+ * unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state is
+ * preserved. Most applications will want to call {@link #load} or {@link
+ * #restoreState(SessionState)} at this point.
+ *
+ * @param session The GeckoSession for which the content process has crashed.
+ */
+ @UiThread
+ default void onCrash(@NonNull final GeckoSession session) {}
+
+ /**
+ * The content process hosting this GeckoSession has been killed. The GeckoSession is now closed
+ * and unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state
+ * is preserved. Most applications will want to call {@link #load} or {@link
+ * #restoreState(SessionState)} at this point.
+ *
+ * @param session The GeckoSession for which the content process has been killed.
+ */
+ @UiThread
+ default void onKill(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the first content composition has occurred. This callback is invoked for
+ * the first content composite after either a start or a restart of the compositor.
+ *
+ * @param session The GeckoSession that had a first paint event.
+ */
+ @UiThread
+ default void onFirstComposite(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the first content paint has occurred. This callback is invoked for the
+ * first content paint after a page has been loaded, or after a {@link
+ * #onPaintStatusReset(GeckoSession)} event. The function {@link
+ * #onFirstComposite(GeckoSession)} will be called once the compositor has started rendering.
+ * However, it is possible for the compositor to start rendering before there is any content to
+ * render. onFirstContentfulPaint() is called once some content has been rendered. It may be
+ * nothing more than the page background color. It is not an indication that the whole page has
+ * been rendered.
+ *
+ * @param session The GeckoSession that had a first paint event.
+ */
+ @UiThread
+ default void onFirstContentfulPaint(@NonNull final GeckoSession session) {}
+
+ /**
+ * Notification that the paint status has been reset.
+ *
+ * <p>This callback is invoked whenever the painted content is no longer being displayed. This
+ * can occur in response to the session being paused. After this has fired the compositor may
+ * continue rendering, but may not render the page content. This callback can therefore be used
+ * in conjunction with {@link #onFirstContentfulPaint(GeckoSession)} to determine when there is
+ * valid content being rendered.
+ *
+ * @param session The GeckoSession that had the paint status reset event.
+ */
+ @UiThread
+ default void onPaintStatusReset(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to change pointer icon.
+ *
+ * <p>If the application wants to control pointer icon, it should override this, then handle it.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param icon The pointer icon sent from the content.
+ */
+ @TargetApi(Build.VERSION_CODES.N)
+ @UiThread
+ default void onPointerIconChange(
+ @NonNull final GeckoSession session, @NonNull final PointerIcon icon) {
+ final View view = session.getTextInput().getView();
+ if (view != null) {
+ view.setPointerIcon(icon);
+ }
+ }
+
+ /**
+ * This is fired when the loaded document has a valid Web App Manifest present.
+ *
+ * <p>The various colors (theme_color, background_color, etc.) present in the manifest have been
+ * transformed into #AARRGGBB format.
+ *
+ * @param session The GeckoSession that contains the Web App Manifest
+ * @param manifest A parsed and validated {@link JSONObject} containing the manifest contents.
+ * @see <a href="https://www.w3.org/TR/appmanifest/">Web App Manifest specification</a>
+ */
+ @UiThread
+ default void onWebAppManifest(
+ @NonNull final GeckoSession session, @NonNull final JSONObject manifest) {}
+
+ /**
+ * A script has exceeded its execution timeout value
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ * @param scriptFileName Filename of the slow script
+ * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to
+ * allow the Slow Script to continue processing. Stop will halt the slow script. Continue
+ * will pause notifications for a period of time before resuming.
+ */
+ @UiThread
+ default @Nullable GeckoResult<SlowScriptResponse> onSlowScript(
+ @NonNull final GeckoSession geckoSession, @NonNull final String scriptFileName) {
+ return null;
+ }
+
+ /**
+ * The app should display its dynamic toolbar, fully expanded to the height that was previously
+ * specified via {@link GeckoView#setDynamicToolbarMaxHeight}.
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onShowDynamicToolbar(@NonNull final GeckoSession geckoSession) {}
+
+ /**
+ * This method is called when a cookie banner was detected.
+ *
+ * <p>Note: this method is called only if the cookie banner setting is such that allows to
+ * handle the banner. For example, if cookiebanners.service.mode=1 (Reject only) but a cookie
+ * banner can only be accepted on the website - the detection in that case won't be reported.
+ * The exception is MODE_DETECT_ONLY mode, when only the detection event is emitted.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerDetected(@NonNull final GeckoSession session) {}
+
+ /**
+ * This method is called when a cookie banner was handled.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerHandled(@NonNull final GeckoSession session) {}
+ }
+
+ public interface SelectionActionDelegate {
+ /** The selection is collapsed at a single position. */
+ final int FLAG_IS_COLLAPSED = 1;
+ /**
+ * The selection is inside editable content such as an input element or contentEditable node.
+ */
+ final int FLAG_IS_EDITABLE = 2;
+ /** The selection is inside a password field. */
+ final int FLAG_IS_PASSWORD = 4;
+
+ /** Hide selection actions and cause {@link #onHideAction} to be called. */
+ final String ACTION_HIDE = "org.mozilla.geckoview.HIDE";
+ /** Copy onto the clipboard then delete the selected content. Selection must be editable. */
+ final String ACTION_CUT = "org.mozilla.geckoview.CUT";
+ /** Copy the selected content onto the clipboard. */
+ final String ACTION_COPY = "org.mozilla.geckoview.COPY";
+ /** Delete the selected content. Selection must be editable. */
+ final String ACTION_DELETE = "org.mozilla.geckoview.DELETE";
+ /** Replace the selected content with the clipboard content. Selection must be editable. */
+ final String ACTION_PASTE = "org.mozilla.geckoview.PASTE";
+ /**
+ * Replace the selected content with the clipboard content as plain text. Selection must be
+ * editable.
+ */
+ final String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT";
+ /** Select the entire content of the document or editor. */
+ final String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL";
+ /** Clear the current selection. Selection must not be editable. */
+ final String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT";
+ /** Collapse the current selection to its start position. Selection must be editable. */
+ final String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START";
+ /** Collapse the current selection to its end position. Selection must be editable. */
+ final String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END";
+
+ /** Represents attributes of a selection. */
+ class Selection {
+ /**
+ * Flags describing the current selection, as a bitwise combination of the {@link
+ * #FLAG_IS_COLLAPSED FLAG_*} constants.
+ */
+ public final @SelectionActionDelegateFlag int flags;
+
+ /**
+ * Text content of the current selection. An empty string indicates the selection is collapsed
+ * or the selection cannot be represented as plain text.
+ */
+ public final @NonNull String text;
+
+ /**
+ * The bounds of the current selection in client coordinates. Use {@link
+ * GeckoSession#getClientToScreenMatrix} to perform transformation to screen coordinates.
+ *
+ * @deprecated Use {@link #screenRect}.
+ */
+ @Deprecated
+ @DeprecationSchedule(id = "selection-fission", version = 112)
+ public final @Nullable RectF clientRect;
+
+ /** The bounds of the current selection in screen coordinates. */
+ public final @Nullable RectF screenRect;
+
+ /** Set of valid actions available through {@link Selection#execute(String)} */
+ public final @NonNull @SelectionActionDelegateAction Collection<String> availableActions;
+
+ private final String mActionId;
+
+ private final WeakReference<EventDispatcher> mEventDispatcher;
+
+ /* package */ Selection(
+ final GeckoBundle bundle,
+ final @NonNull @SelectionActionDelegateAction Set<String> actions,
+ final EventDispatcher eventDispatcher) {
+ flags =
+ (bundle.getBoolean("collapsed") ? SelectionActionDelegate.FLAG_IS_COLLAPSED : 0)
+ | (bundle.getBoolean("editable") ? SelectionActionDelegate.FLAG_IS_EDITABLE : 0)
+ | (bundle.getBoolean("password") ? SelectionActionDelegate.FLAG_IS_PASSWORD : 0);
+ text = bundle.getString("selection");
+ clientRect = bundle.getRectF("clientRect");
+ screenRect = bundle.getRectF("screenRect");
+ availableActions = actions;
+ mActionId = bundle.getString("actionId");
+ mEventDispatcher = new WeakReference<>(eventDispatcher);
+ }
+
+ /** Empty constructor for tests. */
+ protected Selection() {
+ flags = 0;
+ text = "";
+ clientRect = null;
+ screenRect = null;
+ availableActions = new HashSet<>();
+ mActionId = null;
+ mEventDispatcher = null;
+ }
+
+ /**
+ * Checks if the passed action is available
+ *
+ * @param action An {@link SelectionActionDelegate} to perform
+ * @return True if the action is available.
+ */
+ @AnyThread
+ public boolean isActionAvailable(
+ @NonNull @SelectionActionDelegateAction final String action) {
+ return availableActions.contains(action);
+ }
+
+ /**
+ * Execute an {@link SelectionActionDelegate} action.
+ *
+ * @throws IllegalStateException If the action was not available.
+ * @param action A {@link SelectionActionDelegate} action.
+ */
+ @AnyThread
+ public void execute(@NonNull @SelectionActionDelegateAction final String action) {
+ if (!isActionAvailable(action)) {
+ throw new IllegalStateException("Action not available");
+ }
+ final EventDispatcher eventDispatcher = mEventDispatcher.get();
+ if (eventDispatcher == null) {
+ // The session is not available anymore, nothing really to do
+ Log.w(LOGTAG, "Calling execute on a stale Selection.");
+ return;
+ }
+ final GeckoBundle response = new GeckoBundle(2);
+ response.putString("id", action);
+ response.putString("actionId", mActionId);
+ eventDispatcher.dispatch("GeckoView:ExecuteSelectionAction", response);
+ }
+
+ /**
+ * Hide selection actions and cause {@link #onHideAction} to be called.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void hide() {
+ execute(ACTION_HIDE);
+ }
+
+ /**
+ * Copy onto the clipboard then delete the selected content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void cut() {
+ execute(ACTION_CUT);
+ }
+
+ /**
+ * Copy the selected content onto the clipboard.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void copy() {
+ execute(ACTION_COPY);
+ }
+
+ /**
+ * Delete the selected content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void delete() {
+ execute(ACTION_DELETE);
+ }
+
+ /**
+ * Replace the selected content with the clipboard content.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void paste() {
+ execute(ACTION_PASTE);
+ }
+
+ /**
+ * Replace the selected content with the clipboard content as plain text.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void pasteAsPlainText() {
+ execute(ACTION_PASTE_AS_PLAIN_TEXT);
+ }
+
+ /**
+ * Select the entire content of the document or editor.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void selectAll() {
+ execute(ACTION_SELECT_ALL);
+ }
+
+ /**
+ * Clear the current selection.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void unselect() {
+ execute(ACTION_UNSELECT);
+ }
+
+ /**
+ * Collapse the current selection to its start position.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void collapseToStart() {
+ execute(ACTION_COLLAPSE_TO_START);
+ }
+
+ /**
+ * Collapse the current selection to its end position.
+ *
+ * @throws IllegalStateException If the action was not available.
+ */
+ @AnyThread
+ public void collapseToEnd() {
+ execute(ACTION_COLLAPSE_TO_END);
+ }
+ }
+
+ /**
+ * Selection actions are available. Selection actions become available when the user selects
+ * some content in the document or editor. Inside an editor, selection actions can also become
+ * available when the user explicitly requests editor action UI, for example by tapping on the
+ * caret handle.
+ *
+ * <p>In response to this callback, applications typically display a toolbar containing the
+ * selection actions. To perform a certain action, check if the action is available with {@link
+ * Selection#isActionAvailable} then either use the relevant helper method or {@link
+ * Selection#execute}
+ *
+ * <p>Once an {@link #onHideAction} call (with particular reasons) or another {@link
+ * #onShowActionRequest} call is received, the previous Selection object is no longer usable.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param selection Current selection attributes and Callback object for performing built-in
+ * actions. May be used multiple times to perform multiple actions at once.
+ */
+ @UiThread
+ default void onShowActionRequest(
+ @NonNull final GeckoSession session, @NonNull final Selection selection) {}
+
+ /** Actions are no longer available due to the user clearing the selection. */
+ final int HIDE_REASON_NO_SELECTION = 0;
+ /**
+ * Actions are no longer available due to the user moving the selection out of view. Previous
+ * actions are still available after a callback with this reason.
+ */
+ final int HIDE_REASON_INVISIBLE_SELECTION = 1;
+ /**
+ * Actions are no longer available due to the user actively changing the selection. {@link
+ * #onShowActionRequest} may be called again once the user has set a selection, if the new
+ * selection has available actions.
+ */
+ final int HIDE_REASON_ACTIVE_SELECTION = 2;
+ /**
+ * Actions are no longer available due to the user actively scrolling the page. {@link
+ * #onShowActionRequest} may be called again once the user has stopped scrolling the page, if
+ * the selection is still visible. Until then, previous actions are still available after a
+ * callback with this reason.
+ */
+ final int HIDE_REASON_ACTIVE_SCROLL = 3;
+
+ /**
+ * Previous actions are no longer available due to the user interacting with the page.
+ * Applications typically hide the action toolbar in response.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param reason The reason that actions are no longer available, as one of the {@link
+ * #HIDE_REASON_NO_SELECTION HIDE_REASON_*} constants.
+ */
+ @UiThread
+ default void onHideAction(
+ @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {}
+
+ /**
+ * Permission for reading clipboard data. See: <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText">Clipboard.readText()</a>
+ */
+ 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<AllowOrDeny> 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)
+ @StringDef({
+ SelectionActionDelegate.ACTION_HIDE,
+ SelectionActionDelegate.ACTION_CUT,
+ SelectionActionDelegate.ACTION_COPY,
+ SelectionActionDelegate.ACTION_DELETE,
+ SelectionActionDelegate.ACTION_PASTE,
+ SelectionActionDelegate.ACTION_PASTE_AS_PLAIN_TEXT,
+ SelectionActionDelegate.ACTION_SELECT_ALL,
+ SelectionActionDelegate.ACTION_UNSELECT,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_START,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_END
+ })
+ public @interface SelectionActionDelegateAction {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SelectionActionDelegate.FLAG_IS_COLLAPSED,
+ SelectionActionDelegate.FLAG_IS_EDITABLE,
+ SelectionActionDelegate.FLAG_IS_PASSWORD
+ })
+ public @interface SelectionActionDelegateFlag {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SelectionActionDelegate.HIDE_REASON_NO_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL
+ })
+ 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.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ * @param perms The permissions currently associated with this url.
+ */
+ @UiThread
+ default void onLocationChange(
+ @NonNull GeckoSession session,
+ @Nullable String url,
+ final @NonNull List<PermissionDelegate.ContentPermission> perms) {}
+
+ /**
+ * The view's ability to go back has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoBack The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoBack(@NonNull final GeckoSession session, final boolean canGoBack) {}
+
+ /**
+ * The view's ability to go forward has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoForward The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoForward(@NonNull final GeckoSession session, final boolean canGoForward) {}
+
+ public static final int TARGET_WINDOW_NONE = 0;
+ public static final int TARGET_WINDOW_CURRENT = 1;
+ public static final int TARGET_WINDOW_NEW = 2;
+
+ // Match with nsIWebNavigation.idl.
+ /** The load request was triggered by an HTTP redirect. */
+ static final int LOAD_REQUEST_IS_REDIRECT = 0x800000;
+
+ /** Load request details. */
+ public static class LoadRequest {
+ /* package */ LoadRequest(
+ @NonNull final String uri,
+ @Nullable final String triggerUri,
+ final int geckoTarget,
+ final int flags,
+ final boolean hasUserGesture,
+ final boolean isDirectNavigation) {
+ this.uri = uri;
+ this.triggerUri = triggerUri;
+ this.target = convertGeckoTarget(geckoTarget);
+ this.isRedirect = (flags & LOAD_REQUEST_IS_REDIRECT) != 0;
+ this.hasUserGesture = hasUserGesture;
+ this.isDirectNavigation = isDirectNavigation;
+ }
+
+ /** Empty constructor for tests. */
+ protected LoadRequest() {
+ uri = "";
+ triggerUri = null;
+ target = TARGET_WINDOW_NONE;
+ isRedirect = false;
+ hasUserGesture = false;
+ isDirectNavigation = false;
+ }
+
+ // This needs to match nsIBrowserDOMWindow.idl
+ private @TargetWindow int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB
+ return TARGET_WINDOW_NEW;
+ }
+ }
+
+ /** The URI to be loaded. */
+ public final @NonNull String uri;
+
+ /**
+ * The URI of the origin page that triggered the load request. null for initial loads and
+ * loads originating from data: URIs.
+ */
+ public final @Nullable String triggerUri;
+
+ /**
+ * The target where the window has requested to open. One of {@link #TARGET_WINDOW_NONE
+ * TARGET_WINDOW_*}.
+ */
+ public final @TargetWindow int target;
+
+ /**
+ * True if and only if the request was triggered by an HTTP redirect.
+ *
+ * <p>If the user loads URI "a", which redirects to URI "b", then <code>onLoadRequest</code>
+ * will be called twice, first with uri "a" and <code>isRedirect = false</code>, then with uri
+ * "b" and <code>isRedirect = true</code>.
+ */
+ public final boolean isRedirect;
+
+ /** True if there was an active user gesture when the load was requested. */
+ public final boolean hasUserGesture;
+
+ /**
+ * This load request was initiated by a direct navigation from the application. E.g. when
+ * calling {@link GeckoSession#load}.
+ */
+ public final boolean isDirectNavigation;
+
+ @Override
+ public String toString() {
+ final StringBuilder out = new StringBuilder("LoadRequest { ");
+ out.append("uri: " + uri)
+ .append(", triggerUri: " + triggerUri)
+ .append(", target: " + target)
+ .append(", isRedirect: " + isRedirect)
+ .append(", hasUserGesture: " + hasUserGesture)
+ .append(", fromLoadUri: " + hasUserGesture)
+ .append(" }");
+ return out.toString();
+ }
+ }
+
+ /**
+ * A request to open an URI. This is called before each top-level page load to allow custom
+ * behavior. For example, this can be used to override the behavior of TAGET_WINDOW_NEW
+ * requests, which defaults to requesting a new GeckoSession via onNewSession.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request to load a URI in a non-top-level context.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onSubframeLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request has been made to open a new session. The URI is provided only for informational
+ * purposes. Do not call GeckoSession.load here. Additionally, the returned GeckoSession must be
+ * a newly-created one.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI to be loaded.
+ * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which
+ * case the request for a new window by web content will fail. e.g., <code>window.open()
+ * </code> will return null. The implementation of onNewSession is responsible for
+ * maintaining a reference to the returned object, to prevent it from being garbage
+ * collected.
+ */
+ @UiThread
+ default @Nullable GeckoResult<GeckoSession> onNewSession(
+ @NonNull final GeckoSession session, @NonNull final String uri) {
+ return null;
+ }
+
+ /**
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI that failed to load.
+ * @param error A WebRequestError containing details about the error
+ * @return A URI to display as an error. Returning null will halt the load entirely. The
+ * following special methods are made available to the URI: -
+ * document.addCertException(isTemporary), returns Promise -
+ * document.getFailedCertSecurityInfo(), returns FailedCertSecurityInfo -
+ * document.getNetErrorInfo(), returns NetErrorInfo - document.allowDeprecatedTls, a
+ * property indicating whether or not TLS 1.0/1.1 is allowed -
+ * document.reloadWithHttpsOnlyException()
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/FailedCertSecurityInfo.webidl">FailedCertSecurityInfo
+ * IDL</a>
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/NetErrorInfo.webidl">NetErrorInfo
+ * IDL</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<String> onLoadError(
+ @NonNull final GeckoSession session,
+ @Nullable final String uri,
+ @NonNull final WebRequestError error) {
+ return null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NavigationDelegate.TARGET_WINDOW_NONE,
+ NavigationDelegate.TARGET_WINDOW_CURRENT,
+ NavigationDelegate.TARGET_WINDOW_NEW
+ })
+ public @interface TargetWindow {}
+
+ /**
+ * GeckoSession applications implement this interface to handle prompts triggered by content in
+ * the GeckoSession, such as alerts, authentication dialogs, and select list pickers.
+ */
+ public interface PromptDelegate {
+ /** PromptResponse is an opaque class created upon confirming or dismissing a prompt. */
+ public class PromptResponse {
+ private final BasePrompt mPrompt;
+
+ /* package */ PromptResponse(@NonNull final BasePrompt prompt) {
+ mPrompt = prompt;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (mPrompt == null) {
+ throw new RuntimeException("Trying to confirm/dismiss a null prompt.");
+ }
+ mPrompt.dispatch(callback);
+ }
+ }
+
+ interface PromptInstanceDelegate {
+ /**
+ * Called when this prompt has been dismissed by the system.
+ *
+ * <p>This can happen e.g. when the page navigates away and the content of the prompt is not
+ * relevant anymore.
+ *
+ * <p>When this method is called, you should hide the prompt UI elements.
+ *
+ * @param prompt the prompt that should be dismissed.
+ */
+ @UiThread
+ default void onPromptDismiss(final @NonNull BasePrompt prompt) {}
+
+ /**
+ * Called when this prompt has been updated.
+ *
+ * <p>This is called if inner &lt;option&gt; elements are updated when using &lt;select&gt;
+ * element.
+ *
+ * <p>When this method is called, you should update the prompt UI elements.
+ *
+ * @param prompt the new prompt that should be updated.
+ */
+ @UiThread
+ default void onPromptUpdate(final @NonNull BasePrompt prompt) {}
+ }
+
+ // Prompt classes.
+ public class BasePrompt {
+ private boolean mIsCompleted;
+ private boolean mIsConfirmed;
+ private GeckoBundle mResult;
+ private final WeakReference<Observer> mObserver;
+ private PromptInstanceDelegate mDelegate;
+
+ protected interface Observer {
+ @AnyThread
+ default void onPromptCompleted(@NonNull BasePrompt prompt) {}
+ }
+
+ private void complete() {
+ mIsCompleted = true;
+ final Observer observer = mObserver.get();
+ if (observer != null) {
+ observer.onPromptCompleted(this);
+ }
+ }
+
+ /** The title of this prompt; may be null. */
+ public final @Nullable String title;
+ /* package */ String id;
+
+ private BasePrompt(
+ @NonNull final String id, @Nullable final String title, final Observer observer) {
+ this.title = title;
+ this.id = id;
+ mIsConfirmed = false;
+ mIsCompleted = false;
+ mObserver = new WeakReference<>(observer);
+ }
+
+ @UiThread
+ protected @NonNull PromptResponse confirm() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ mIsConfirmed = true;
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * This dismisses the prompt without sending any meaningful information back to content.
+ *
+ * @return A {@link PromptResponse} with which you can complete the {@link GeckoResult} that
+ * corresponds to this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * Set the delegate for this prompt.
+ *
+ * @param delegate the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable PromptInstanceDelegate delegate) {
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the delegate for this prompt.
+ *
+ * @return the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public PromptInstanceDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ /* package */ GeckoBundle ensureResult() {
+ if (mResult == null) {
+ // Usually result object contains two items.
+ mResult = new GeckoBundle(2);
+ }
+ return mResult;
+ }
+
+ /**
+ * This returns true if the prompt has already been confirmed or dismissed.
+ *
+ * @return A boolean which is true if the prompt has been confirmed or dismissed, and false
+ * otherwise.
+ */
+ @UiThread
+ public boolean isComplete() {
+ return mIsCompleted;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (!mIsCompleted) {
+ throw new RuntimeException("Trying to dispatch an incomplete prompt.");
+ }
+
+ if (!mIsConfirmed) {
+ callback.sendSuccess(null);
+ } else {
+ callback.sendSuccess(mResult);
+ }
+ }
+ }
+
+ /**
+ * BeforeUnloadPrompt represents the onbeforeunload prompt. See
+ * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ */
+ class BeforeUnloadPrompt extends BasePrompt {
+ protected BeforeUnloadPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the navigation should be allowed to continue or not.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * RepostConfirmPrompt represents a prompt shown whenever the browser needs to resubmit POST
+ * data (e.g. due to page refresh).
+ */
+ class RepostConfirmPrompt extends BasePrompt {
+ protected RepostConfirmPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the browser should allow resubmitting data.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AlertPrompt contains the information necessary to represent a JavaScript alert() call from
+ * content; it can only be dismissed, not confirmed.
+ */
+ public class AlertPrompt extends BasePrompt {
+ /** The message to be displayed with this alert; may be null. */
+ public final @Nullable String message;
+
+ protected AlertPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+ }
+
+ /**
+ * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from
+ * content.
+ */
+ public class ButtonPrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.POSITIVE, Type.NEGATIVE})
+ public @interface ButtonType {}
+
+ public static class Type {
+ /** Index of positive response button (eg, "Yes", "OK") */
+ public static final int POSITIVE = 0;
+
+ /** Index of negative response button (eg, "No", "Cancel") */
+ public static final int NEGATIVE = 2;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ protected ButtonPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+
+ /**
+ * Confirms this prompt, returning the selected button to content.
+ *
+ * @param selection An int representing the selected button, must be one of {@link Type}.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ButtonType final int selection) {
+ ensureResult().putInt("button", selection);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * TextPrompt contains the information necessary to represent a Javascript prompt() call from
+ * content.
+ */
+ public class TextPrompt extends BasePrompt {
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The default value for the text field; may be null. */
+ public final @Nullable String defaultValue;
+
+ protected TextPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @Nullable final String defaultValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.defaultValue = defaultValue;
+ }
+
+ /**
+ * Confirms this prompt, returning the input text to content.
+ *
+ * @param text A String containing the text input given by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String text) {
+ ensureResult().putString("text", text);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AuthPrompt contains the information necessary to represent an HTML authorization prompt
+ * generated by content.
+ */
+ public class AuthPrompt extends BasePrompt {
+ public static class AuthOptions {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Flags.HOST,
+ Flags.PROXY,
+ Flags.ONLY_PASSWORD,
+ Flags.PREVIOUS_FAILED,
+ Flags.CROSS_ORIGIN_SUB_RESOURCE
+ })
+ public @interface AuthFlag {}
+
+ /** Auth prompt flags. */
+ public static class Flags {
+ /** The auth prompt is for a network host. */
+ public static final int HOST = 1 << 0;
+ /** The auth prompt is for a proxy. */
+ public static final int PROXY = 1 << 1;
+ /** The auth prompt should only request a password. */
+ public static final int ONLY_PASSWORD = 1 << 3;
+ /** The auth prompt is the result of a previous failed login. */
+ public static final int PREVIOUS_FAILED = 1 << 4;
+ /** The auth prompt is for a cross-origin sub-resource. */
+ public static final int CROSS_ORIGIN_SUB_RESOURCE = 1 << 5;
+
+ protected Flags() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE})
+ public @interface AuthLevel {}
+
+ /** Auth prompt levels. */
+ public static class Level {
+ /** The auth request is unencrypted or the encryption status is unknown. */
+ public static final int NONE = 0;
+ /** The auth request only encrypts password but not data. */
+ public static final int PW_ENCRYPTED = 1;
+ /** The auth request encrypts both password and data. */
+ public static final int SECURE = 2;
+
+ protected Level() {}
+ }
+
+ /** An int bit-field of {@link Flags}. */
+ public @AuthFlag final int flags;
+
+ /** A string containing the URI for the auth request or null if unknown. */
+ public @Nullable final String uri;
+
+ /** An int, one of {@link Level}, indicating level of encryption. */
+ public @AuthLevel final int level;
+
+ /** A string containing the initial username or null if password-only. */
+ public @Nullable final String username;
+
+ /** A string containing the initial password. */
+ public @Nullable final String password;
+
+ /* package */ AuthOptions(final GeckoBundle options) {
+ flags = options.getInt("flags");
+ uri = options.getString("uri");
+ level = options.getInt("level");
+ username = options.getString("username");
+ password = options.getString("password");
+ }
+
+ /** Empty constructor for tests */
+ protected AuthOptions() {
+ flags = 0;
+ uri = "";
+ level = Level.NONE;
+ username = "";
+ password = "";
+ }
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The {@link AuthOptions} that describe the type of authorization prompt. */
+ public final @NonNull AuthOptions authOptions;
+
+ protected AuthPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final AuthOptions authOptions,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.authOptions = authOptions;
+ }
+
+ /**
+ * Confirms this prompt with just a password, returning the password to content.
+ *
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String password) {
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a username and password, returning both to content.
+ *
+ * @param username A String containing the username input by the user.
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final String username, @NonNull final String password) {
+ ensureResult().putString("username", username);
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * ChoicePrompt contains the information necessary to display a menu or list prompt generated by
+ * content.
+ */
+ public class ChoicePrompt extends BasePrompt {
+ public static class Choice {
+ /**
+ * A boolean indicating if the item is disabled. Item should not be selectable if this is
+ * true.
+ */
+ public final boolean disabled;
+
+ /**
+ * A String giving the URI of the item icon, or null if none exists (only valid for menus)
+ */
+ public final @Nullable String icon;
+
+ /** A String giving the ID of the item or group */
+ public final @NonNull String id;
+
+ /** A Choice array of sub-items in a group, or null if not a group */
+ public final @Nullable Choice[] items;
+
+ /** A string giving the label for displaying the item or group */
+ public final @NonNull String label;
+
+ /** A boolean indicating if the item should be pre-selected (pre-checked for menu items) */
+ public final boolean selected;
+
+ /** A boolean indicating if the item should be a menu separator (only valid for menus) */
+ public final boolean separator;
+
+ /* package */ Choice(final GeckoBundle choice) {
+ disabled = choice.getBoolean("disabled");
+ icon = choice.getString("icon");
+ id = choice.getString("id");
+ label = choice.getString("label");
+ selected = choice.getBoolean("selected");
+ separator = choice.getBoolean("separator");
+
+ final GeckoBundle[] choices = choice.getBundleArray("items");
+ if (choices == null) {
+ items = null;
+ } else {
+ items = new Choice[choices.length];
+ for (int i = 0; i < choices.length; i++) {
+ items[i] = new Choice(choices[i]);
+ }
+ }
+ }
+
+ /** Empty constructor for tests. */
+ protected Choice() {
+ disabled = false;
+ icon = "";
+ id = "";
+ label = "";
+ selected = false;
+ separator = false;
+ items = null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.MENU, Type.SINGLE, Type.MULTIPLE})
+ public @interface ChoiceType {}
+
+ public static class Type {
+ /** Display choices in a menu that dismisses as soon as an item is chosen. */
+ public static final int MENU = 1;
+
+ /** Display choices in a list that allows a single selection. */
+ public static final int SINGLE = 2;
+
+ /** Display choices in a list that allows multiple selections. */
+ public static final int MULTIPLE = 3;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** One of {@link Type}. */
+ public final @ChoiceType int type;
+
+ /** An array of {@link Choice} representing possible choices. */
+ public final @NonNull Choice[] choices;
+
+ protected ChoicePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @ChoiceType final int type,
+ @NonNull final Choice[] choices,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.type = type;
+ this.choices = choices;
+ }
+
+ /**
+ * Confirms this prompt with the string id of a single choice.
+ *
+ * @param selectedId The string ID of the selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String selectedId) {
+ return confirm(new String[] {selectedId});
+ }
+
+ /**
+ * Confirms this prompt with the string ids of multiple choices
+ *
+ * @param selectedIds The string IDs of the selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String[] selectedIds) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedIds == null || selectedIds.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+ ensureResult().putStringArray("choices", selectedIds);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a single choice.
+ *
+ * @param selectedChoice The selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice selectedChoice) {
+ return confirm(selectedChoice == null ? null : selectedChoice.id);
+ }
+
+ /**
+ * Confirms this prompt with multiple choices.
+ *
+ * @param selectedChoices The selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice[] selectedChoices) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedChoices == null || selectedChoices.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ if (selectedChoices == null) {
+ return confirm((String[]) null);
+ }
+
+ final String[] ids = new String[selectedChoices.length];
+ for (int i = 0; i < ids.length; i++) {
+ ids[i] = (selectedChoices[i] == null) ? null : selectedChoices[i].id;
+ }
+
+ return confirm(ids);
+ }
+ }
+
+ /**
+ * ColorPrompt contains the information necessary to represent a prompt for color input
+ * generated by content.
+ */
+ public class ColorPrompt extends BasePrompt {
+ /** The default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** The predefined values by &lt;datalist&gt; element */
+ public final @Nullable String[] predefinedValues;
+
+ protected ColorPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String defaultValue,
+ @Nullable final String[] predefinedValues,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.defaultValue = defaultValue;
+ this.predefinedValues = predefinedValues;
+ }
+
+ /**
+ * Confirms the prompt and passes the color value back to content.
+ *
+ * @param color A String representing the color to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String color) {
+ ensureResult().putString("color", color);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * DateTimePrompt contains the information necessary to represent a prompt for date and/or time
+ * input generated by content.
+ */
+ public class DateTimePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.DATE, Type.MONTH, Type.WEEK, Type.TIME, Type.DATETIME_LOCAL})
+ public @interface DatetimeType {}
+
+ public static class Type {
+ /** Prompt for year, month, and day. */
+ public static final int DATE = 1;
+
+ /** Prompt for year and month. */
+ public static final int MONTH = 2;
+
+ /** Prompt for year and week. */
+ public static final int WEEK = 3;
+
+ /** Prompt for hour and minute. */
+ public static final int TIME = 4;
+
+ /** Prompt for year, month, day, hour, and minute, without timezone. */
+ public static final int DATETIME_LOCAL = 5;
+
+ protected Type() {}
+ }
+
+ /** One of {@link Type} indicating the type of prompt. */
+ public final @DatetimeType int type;
+
+ /** A String representing the default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** A String representing the minimum value allowed by content. */
+ public final @Nullable String minValue;
+
+ /** A String representing the maximum value allowed by content. */
+ public final @Nullable String maxValue;
+
+ /** A String representing the step value allowed by content. */
+ public final @Nullable String stepValue;
+
+ /** For testing. */
+ private DateTimePrompt() {
+ // Initialize final members
+ super("", null, null);
+ this.type = Type.DATE;
+ this.defaultValue = null;
+ this.minValue = null;
+ this.maxValue = null;
+ this.stepValue = null;
+ }
+
+ /* package */ DateTimePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @DatetimeType final int type,
+ @Nullable final String defaultValue,
+ @Nullable final String minValue,
+ @Nullable final String maxValue,
+ @Nullable final String stepValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.defaultValue = defaultValue;
+ this.minValue = minValue;
+ this.maxValue = maxValue;
+ this.stepValue = stepValue;
+ }
+
+ /**
+ * Confirms the prompt and passes the date and/or time value back to content.
+ *
+ * @param datetime A String representing the date and time to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String datetime) {
+ ensureResult().putString("datetime", datetime);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * FilePrompt contains the information necessary to represent a prompt for a file or files
+ * generated by content.
+ */
+ public class FilePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.SINGLE, Type.MULTIPLE})
+ public @interface FileType {}
+
+ /** Types of file prompts. */
+ public static class Type {
+ /** Prompt for a single file. */
+ public static final int SINGLE = 1;
+
+ /** Prompt for multiple files. */
+ public static final int MULTIPLE = 2;
+
+ protected Type() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Capture.NONE, Capture.ANY, Capture.USER, Capture.ENVIRONMENT})
+ public @interface CaptureType {}
+
+ /** Possible capture attribute values. */
+ public static class Capture {
+ // These values should match the corresponding values in nsIFilePicker.idl
+ /** No capture attribute has been supplied by content. */
+ public static final int NONE = 0;
+
+ /** The capture attribute was supplied with a missing or invalid value. */
+ public static final int ANY = 1;
+
+ /** The "user" capture attribute has been supplied by content. */
+ public static final int USER = 2;
+
+ /** The "environment" capture attribute has been supplied by content. */
+ public static final int ENVIRONMENT = 3;
+
+ protected Capture() {}
+ }
+
+ /** One of {@link Type} indicating the prompt type. */
+ public final @FileType int type;
+
+ /**
+ * An array of Strings giving the MIME types specified by the "accept" attribute, if any are
+ * specified.
+ */
+ public final @Nullable String[] mimeTypes;
+
+ /** One of {@link Capture} indicating the capture attribute supplied by content. */
+ public final @CaptureType int capture;
+
+ protected FilePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @FileType final int type,
+ @CaptureType final int capture,
+ @Nullable final String[] mimeTypes,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.capture = capture;
+ this.mimeTypes = mimeTypes;
+ }
+
+ /**
+ * Confirms the prompt and passes the file URI back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uri The URI of the file chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri uri) {
+ return confirm(context, new Uri[] {uri});
+ }
+
+ /**
+ * Confirms the prompt and passes the file URIs back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uris The URIs of the files chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri[] uris) {
+ if (Type.SINGLE == type && (uris == null || uris.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ final String[] paths = new String[uris != null ? uris.length : 0];
+ for (int i = 0; i < paths.length; i++) {
+ paths[i] = getFile(context, uris[i]);
+ if (paths[i] == null) {
+ Log.e(LOGTAG, "Only file URIs are supported: " + uris[i]);
+ }
+ }
+ ensureResult().putStringArray("files", paths);
+
+ return super.confirm();
+ }
+
+ private static String getFile(final @NonNull Context context, final @NonNull Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ if ("file".equals(uri.getScheme())) {
+ return uri.getPath();
+ }
+ final ContentResolver cr = context.getContentResolver();
+ final Cursor cur =
+ cr.query(
+ uri,
+ new String[] {"_data"}, /* selection */
+ null,
+ /* args */ null, /* sort */
+ null);
+ if (cur == null) {
+ return null;
+ }
+ try {
+ final int idx = cur.getColumnIndex("_data");
+ if (idx < 0 || !cur.moveToFirst()) {
+ return null;
+ }
+ do {
+ try {
+ final String path = cur.getString(idx);
+ if (path != null && !path.isEmpty()) {
+ return path;
+ }
+ } catch (final Exception e) {
+ }
+ } while (cur.moveToNext());
+ } finally {
+ cur.close();
+ }
+ return null;
+ }
+ }
+
+ /** PopupPrompt contains the information necessary to represent a popup blocking request. */
+ public class PopupPrompt extends BasePrompt {
+ /** The target URI for the popup; may be null. */
+ public final @Nullable String targetUri;
+
+ protected PopupPrompt(
+ @NonNull final String id,
+ @Nullable final String targetUri,
+ @NonNull final Observer observer) {
+ super(id, null, observer);
+ this.targetUri = targetUri;
+ }
+
+ /**
+ * Confirms the prompt and either allows or blocks the popup.
+ *
+ * @param response An {@link AllowOrDeny} specifying whether to allow or deny the popup.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final AllowOrDeny response) {
+ boolean res = false;
+ if (AllowOrDeny.ALLOW == response) {
+ res = true;
+ }
+ ensureResult().putBoolean("response", res);
+ return super.confirm();
+ }
+ }
+
+ /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */
+ public class SharePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Result.SUCCESS, Result.FAILURE, Result.ABORT})
+ public @interface ShareResult {}
+
+ /** Possible results to a {@link SharePrompt}. */
+ public static class Result {
+ /** The user shared with another app successfully. */
+ public static final int SUCCESS = 0;
+
+ /** The user attempted to share with another app, but it failed. */
+ public static final int FAILURE = 1;
+
+ /** The user aborted the share. */
+ public static final int ABORT = 2;
+
+ protected Result() {}
+ }
+
+ /** The text for the share request. */
+ public final @Nullable String text;
+
+ /** The uri for the share request. */
+ public final @Nullable String uri;
+
+ protected SharePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String text,
+ @Nullable final String uri,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.text = text;
+ this.uri = uri;
+ }
+
+ /**
+ * Confirms the prompt and either blocks or allows the share request.
+ *
+ * @param response One of {@link Result} specifying the outcome of the share attempt.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ShareResult final int response) {
+ ensureResult().putInt("response", response);
+ return super.confirm();
+ }
+
+ /**
+ * Dismisses the prompt and returns {@link Result#ABORT} to web content.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ ensureResult().putInt("response", Result.ABORT);
+ return super.dismiss();
+ }
+ }
+
+ /** Request containing information required to resolve Autocomplete prompt requests. */
+ public class AutocompleteRequest<T extends Autocomplete.Option<?>> extends BasePrompt {
+ /**
+ * The Autocomplete options for this request. This can contain a single or multiple entries.
+ */
+ public final @NonNull T[] options;
+
+ protected AutocompleteRequest(
+ final @NonNull String id, final @NonNull T[] options, final Observer observer) {
+ super(id, null, observer);
+ this.options = options;
+ }
+
+ /**
+ * Confirm the request by responding with a selection. See the PromptDelegate callbacks for
+ * specifics.
+ *
+ * @param selection The {@link Autocomplete.Option} used to confirm the request.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @NonNull Autocomplete.Option<?> selection) {
+ ensureResult().putBundle("selection", selection.toBundle());
+ return super.confirm();
+ }
+
+ /**
+ * Dismiss the request. See the PromptDelegate callbacks for specifics.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ return super.dismiss();
+ }
+ }
+
+ // Delegate functions.
+ /**
+ * Display an alert prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AlertPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAlertPrompt(
+ @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a onbeforeunload prompt.
+ *
+ * <p>See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ * See {@link BeforeUnloadPrompt}
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link BeforeUnloadPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation or {@link AllowOrDeny#DENY} otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onBeforeUnloadPrompt(
+ @NonNull final GeckoSession session, @NonNull final BeforeUnloadPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a POST resubmission confirmation prompt.
+ *
+ * <p>This prompt will trigger whenever refreshing or navigating to a page needs resubmitting
+ * POST data that has been submitted already.
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link RepostConfirmPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation and resubmit the POST data or {@link AllowOrDeny#DENY}
+ * otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onRepostConfirmPrompt(
+ @NonNull final GeckoSession session, @NonNull final RepostConfirmPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a button prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ButtonPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onButtonPrompt(
+ @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a text prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link TextPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onTextPrompt(
+ @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display an authorization prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AuthPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAuthPrompt(
+ @NonNull final GeckoSession session, @NonNull final AuthPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a list/menu prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ChoicePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onChoicePrompt(
+ @NonNull final GeckoSession session, @NonNull final ChoicePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a color prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ColorPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onColorPrompt(
+ @NonNull final GeckoSession session, @NonNull final ColorPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a date/time prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link DateTimePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onDateTimePrompt(
+ @NonNull final GeckoSession session, @NonNull final DateTimePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a file prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link FilePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onFilePrompt(
+ @NonNull final GeckoSession session, @NonNull final FilePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a popup request prompt; this occurs when content attempts to open a new window in a
+ * way that doesn't appear to be the result of user input.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link PopupPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onPopupPrompt(
+ @NonNull final GeckoSession session, @NonNull final PopupPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a share request prompt; this occurs when content attempts to use the WebShare API.
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link SharePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onSharePrompt(
+ @NonNull final GeckoSession session, @NonNull final SharePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle a login save prompt request. This is triggered by the user entering new or modified
+ * login credentials into a login form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onLoginSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created login entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address save prompt request. This is triggered by the user entering new or modified
+ * address credentials into a address form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onAddressSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created address entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card save prompt request. This is triggered by the user entering new or
+ * modified credit card credentials into a form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onCreditCardSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created credit card entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a login selection prompt request. This is triggered by the user focusing on a login
+ * username field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * login forms with the given selection details. The confirmed selection may be an entry out
+ * of the request's options, a modified option, or a freshly created login entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card selection prompt request. This is triggered by the user focusing on a
+ * credit card input field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * credit card forms with the given selection details. The confirmed selection may be an
+ * entry out of the request's options, a modified option, or a freshly created credit card
+ * entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address selection prompt request. This is triggered by the user focusing on a
+ * address field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * address forms with the given selection details. The confirmed selection may be an entry
+ * out of the request's options, a modified option, or a freshly created address entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSelectOption> request) {
+ return null;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content scroll events. */
+ public interface ScrollDelegate {
+ /**
+ * The scroll position of the content has changed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param scrollX The new horizontal scroll position in pixels.
+ * @param scrollY The new vertical scroll position in pixels.
+ */
+ @UiThread
+ default void onScrollChanged(
+ @NonNull final GeckoSession session, final int scrollX, final int scrollY) {}
+ }
+
+ /**
+ * Get the PanZoomController instance for this session.
+ *
+ * @return PanZoomController instance.
+ */
+ @UiThread
+ public @NonNull PanZoomController getPanZoomController() {
+ ThreadUtils.assertOnUiThread();
+
+ return mPanZoomController;
+ }
+
+ /**
+ * Get the OverscrollEdgeEffect instance for this session.
+ *
+ * @return OverscrollEdgeEffect instance.
+ */
+ @UiThread
+ public @NonNull OverscrollEdgeEffect getOverscrollEdgeEffect() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOverscroll == null) {
+ mOverscroll = new OverscrollEdgeEffect();
+ }
+ return mOverscroll;
+ }
+
+ /**
+ * Get the CompositorController instance for this session.
+ *
+ * @return CompositorController instance.
+ */
+ @UiThread
+ public @NonNull CompositorController getCompositorController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mController == null) {
+ mController = new CompositorController(this);
+ if (mCompositorReady) {
+ mController.onCompositorReady();
+ }
+ }
+ return mController;
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.setScale(mViewportZoom, mViewportZoom);
+ if (mClientTop != mTop) {
+ matrix.postTranslate(0, mClientTop - mTop);
+ }
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to screen coordinates. The client
+ * coordinates are in CSS pixels and are relative to the viewport origin; their relation to screen
+ * coordinates does not depend on the current scroll position.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToSurfaceMatrix(Matrix)
+ * @see #getPageToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to screen coordinates. The page coordinates
+ * are in CSS pixels and are relative to the page origin; their relation to screen coordinates
+ * depends on the current scroll position of the outermost frame.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToSurfaceMatrix(Matrix)
+ * @see #getClientToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getPageToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToScreenMatrix(Matrix)
+ * @see #getClientToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(-mViewportLeft, -mViewportTop);
+ }
+
+ /**
+ * Get a matrix for transforming from layout device client coordinates to screen coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ /* package */ void getClientToScreenOffsetMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get the bounds of the client area in client coordinates. The returned top-left coordinates are
+ * always (0, 0). Use the matrix from {@link #getClientToSurfaceMatrix(Matrix)} or {@link
+ * #getClientToScreenMatrix(Matrix)} to map these bounds to surface or screen coordinates,
+ * respectively.
+ *
+ * @param rect RectF to be replaced by the client bounds in client coordinates.
+ * @see #getSurfaceBounds(Rect)
+ */
+ @UiThread
+ public void getClientBounds(@NonNull final RectF rect) {
+ ThreadUtils.assertOnUiThread();
+
+ rect.set(0.0f, 0.0f, (float) mWidth / mViewportZoom, (float) mClientHeight / mViewportZoom);
+ }
+
+ /**
+ * Get the bounds of the client area in surface coordinates. This is equivalent to mapping the
+ * bounds returned by #getClientBounds(RectF) with the matrix returned by
+ * #getClientToSurfaceMatrix(Matrix).
+ *
+ * @param rect Rect to be replaced by the client bounds in surface coordinates.
+ */
+ @UiThread
+ public void getSurfaceBounds(@NonNull final Rect rect) {
+ ThreadUtils.assertOnUiThread();
+
+ rect.set(0, mClientTop - mTop, mWidth, mHeight);
+ }
+
+ /**
+ * GeckoSession applications implement this interface to handle requests for permissions from
+ * content, such as geolocation and notifications. For each permission, usually two requests are
+ * generated: one request for the Android app permission through requestAppPermissions, which is
+ * typically handled by a system permission dialog; and another request for the content permission
+ * (e.g. through requestContentPermission), which is typically handled by an app-specific
+ * permission dialog.
+ *
+ * <p>When denying an Android app permission, the response is not stored by GeckoView. It is the
+ * responsibility of the consumer to store the response state and therefore prevent further
+ * requests from being presented to the user.
+ */
+ public interface PermissionDelegate {
+ /**
+ * Permission for using the geolocation API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Geolocation
+ */
+ int PERMISSION_GEOLOCATION = 0;
+
+ /**
+ * 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;
+
+ /** Permission for using the WebXR API. See: https://www.w3.org/TR/webxr */
+ int PERMISSION_XR = 3;
+
+ /** Permission for allowing autoplay of inaudible (silent) video. */
+ int PERMISSION_AUTOPLAY_INAUDIBLE = 4;
+
+ /** Permission for allowing autoplay of audible video. */
+ int PERMISSION_AUTOPLAY_AUDIBLE = 5;
+
+ /** Permission for accessing system media keys used to decode DRM media. */
+ int PERMISSION_MEDIA_KEY_SYSTEM_ACCESS = 6;
+
+ /**
+ * Permission for trackers to operate on the page -- disables all tracking protection features
+ * for a given site.
+ */
+ int PERMISSION_TRACKING = 7;
+
+ /**
+ * Permission for third party frames to access first party cookies and storage. May be granted
+ * heuristically in some cases.
+ */
+ int PERMISSION_STORAGE_ACCESS = 8;
+
+ /**
+ * Represents a content permission -- including the type of permission, the present value of the
+ * permission, the URL the permission pertains to, and other information.
+ */
+ class ContentPermission {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({VALUE_PROMPT, VALUE_DENY, VALUE_ALLOW})
+ public @interface Value {}
+
+ /** The corresponding permission is currently set to default/prompt behavior. */
+ public static final int VALUE_PROMPT = 3;
+
+ /** The corresponding permission is currently set to deny. */
+ public static final int VALUE_DENY = 2;
+
+ /** The corresponding permission is currently set to allow. */
+ public static final int VALUE_ALLOW = 1;
+
+ /** The URI associated with this content permission. */
+ public final @NonNull String uri;
+
+ /**
+ * The third party origin associated with the request; currently only used for storage access
+ * permission.
+ */
+ public final @Nullable String thirdPartyOrigin;
+
+ /**
+ * A boolean indicating whether this content permission is associated with private browsing.
+ */
+ public final boolean privateMode;
+
+ /** The type of this permission; one of {@link #PERMISSION_GEOLOCATION PERMISSION_*}. */
+ public final int permission;
+
+ /** The value of the permission; one of {@link #VALUE_PROMPT VALUE_}. */
+ public final @Value int value;
+
+ /**
+ * The context ID associated with the permission if any.
+ *
+ * @see GeckoSessionSettings.Builder#contextId
+ */
+ public final @Nullable String contextId;
+
+ private final String mPrincipal;
+
+ protected ContentPermission() {
+ this.uri = "";
+ this.thirdPartyOrigin = null;
+ this.privateMode = false;
+ this.permission = PERMISSION_GEOLOCATION;
+ this.value = VALUE_ALLOW;
+ this.mPrincipal = "";
+ this.contextId = null;
+ }
+
+ private ContentPermission(final @NonNull GeckoBundle bundle) {
+ this.uri = bundle.getString("uri");
+ this.mPrincipal = bundle.getString("principal");
+ this.privateMode = bundle.getBoolean("privateMode");
+
+ final String permission = bundle.getString("perm");
+ this.permission = convertType(permission);
+ if (permission.startsWith("3rdPartyStorage^")) {
+ // Storage access permissions are stored with the key "3rdPartyStorage^https://foo.com"
+ // where the third party origin is "https://foo.com".
+ this.thirdPartyOrigin = permission.substring(16);
+ } else {
+ this.thirdPartyOrigin = bundle.getString("thirdPartyOrigin");
+ }
+
+ this.value = bundle.getInt("value");
+ this.contextId =
+ StorageController.retrieveUnsafeSessionContextId(bundle.getString("contextId"));
+ }
+
+ /**
+ * Converts a JSONObject to a ContentPermission -- should only be used on the output of {@link
+ * #toJson()}.
+ *
+ * @param perm A JSONObject representing a ContentPermission, output by {@link #toJson()}.
+ * @return The corresponding ContentPermission.
+ */
+ @AnyThread
+ public static @Nullable ContentPermission fromJson(final @NonNull JSONObject perm) {
+ ContentPermission res = null;
+ try {
+ res = new ContentPermission(GeckoBundle.fromJSONObject(perm));
+ } catch (final JSONException e) {
+ Log.w(LOGTAG, "Failed to create ContentPermission; invalid JSONObject.", e);
+ }
+ return res;
+ }
+
+ /**
+ * Converts a ContentPermission to a JSONObject that can be converted back to a
+ * ContentPermission by {@link #fromJson(JSONObject)}.
+ *
+ * @return A JSONObject representing this ContentPermission. Modifying any of the fields may
+ * result in undefined behavior when converted back to a ContentPermission and used.
+ * @throws JSONException if the conversion fails for any reason.
+ */
+ @AnyThread
+ public @NonNull JSONObject toJson() throws JSONException {
+ return toGeckoBundle().toJSONObject();
+ }
+
+ private static int convertType(final @NonNull String type) {
+ if ("geolocation".equals(type)) {
+ return PERMISSION_GEOLOCATION;
+ } else if ("desktop-notification".equals(type)) {
+ return PERMISSION_DESKTOP_NOTIFICATION;
+ } else if ("persistent-storage".equals(type)) {
+ return PERMISSION_PERSISTENT_STORAGE;
+ } else if ("xr".equals(type)) {
+ return PERMISSION_XR;
+ } else if ("autoplay-media-inaudible".equals(type)) {
+ return PERMISSION_AUTOPLAY_INAUDIBLE;
+ } else if ("autoplay-media-audible".equals(type)) {
+ return PERMISSION_AUTOPLAY_AUDIBLE;
+ } else if ("media-key-system-access".equals(type)) {
+ return PERMISSION_MEDIA_KEY_SYSTEM_ACCESS;
+ } else if ("trackingprotection".equals(type) || "trackingprotection-pb".equals(type)) {
+ return PERMISSION_TRACKING;
+ } else if ("storage-access".equals(type) || type.startsWith("3rdPartyStorage^")) {
+ return PERMISSION_STORAGE_ACCESS;
+ } else {
+ return -1;
+ }
+ }
+
+ // This also gets used in StorageController, so it's package rather than private.
+ /* package */ static String convertType(final int type, final boolean privateMode) {
+ switch (type) {
+ case PERMISSION_GEOLOCATION:
+ return "geolocation";
+ case PERMISSION_DESKTOP_NOTIFICATION:
+ return "desktop-notification";
+ case PERMISSION_PERSISTENT_STORAGE:
+ return "persistent-storage";
+ case PERMISSION_XR:
+ return "xr";
+ case PERMISSION_AUTOPLAY_INAUDIBLE:
+ return "autoplay-media-inaudible";
+ case PERMISSION_AUTOPLAY_AUDIBLE:
+ return "autoplay-media-audible";
+ case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS:
+ return "media-key-system-access";
+ case PERMISSION_TRACKING:
+ return privateMode ? "trackingprotection-pb" : "trackingprotection";
+ case PERMISSION_STORAGE_ACCESS:
+ return "storage-access";
+ default:
+ return "";
+ }
+ }
+
+ /* package */ static @NonNull ArrayList<ContentPermission> fromBundleArray(
+ final @NonNull GeckoBundle[] bundleArray) {
+ final ArrayList<ContentPermission> res = new ArrayList<ContentPermission>();
+ if (bundleArray == null) {
+ return res;
+ }
+
+ for (final GeckoBundle bundle : bundleArray) {
+ final ContentPermission temp = new ContentPermission(bundle);
+ if (temp.permission == -1 || temp.value < 1 || temp.value > 3) {
+ continue;
+ }
+ res.add(temp);
+ }
+ return res;
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toGeckoBundle() {
+ final GeckoBundle res = new GeckoBundle(7);
+ res.putString("uri", uri);
+ res.putString("thirdPartyOrigin", thirdPartyOrigin);
+ res.putString("principal", mPrincipal);
+ res.putBoolean("privateMode", privateMode);
+ res.putString("perm", convertType(permission, privateMode));
+ res.putInt("value", value);
+ res.putString("contextId", contextId);
+ return res;
+ }
+ }
+
+ /** 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
+ default void grant() {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * either grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request Android app permissions.
+ *
+ * @param session GeckoSession instance requesting the permissions.
+ * @param permissions List of permissions to request; possible values are,
+ * android.Manifest.permission.ACCESS_COARSE_LOCATION
+ * android.Manifest.permission.ACCESS_FINE_LOCATION android.Manifest.permission.CAMERA
+ * android.Manifest.permission.RECORD_AUDIO
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onAndroidPermissionsRequest(
+ @NonNull final GeckoSession session,
+ @Nullable final String[] permissions,
+ @NonNull final Callback callback) {
+ callback.reject();
+ }
+
+ /**
+ * Request content permission.
+ *
+ * <p>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 perm An {@link ContentPermission} describing the permission being requested and its
+ * current status.
+ * @return A {@link GeckoResult} resolving to one of {@link ContentPermission#VALUE_PROMPT
+ * VALUE_*}, determining the response to the permission request and updating the permissions
+ * for this site.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Integer> onContentPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ContentPermission perm) {
+ return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT);
+ }
+
+ class MediaSource {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SOURCE_CAMERA, SOURCE_SCREEN,
+ SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE,
+ SOURCE_OTHER
+ })
+ public @interface Source {}
+
+ /** Constant to indicate that camera will be recorded. */
+ public static final int SOURCE_CAMERA = 0;
+
+ /** Constant to indicate that screen will be recorded. */
+ public static final int SOURCE_SCREEN = 1;
+
+ /** Constant to indicate that microphone will be recorded. */
+ public static final int SOURCE_MICROPHONE = 2;
+
+ /** Constant to indicate that device audio playback will be recorded. */
+ public static final int SOURCE_AUDIOCAPTURE = 3;
+
+ /** Constant to indicate a media source that does not fall under the other categories. */
+ public static final int SOURCE_OTHER = 4;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ /** The media type is video. */
+ public static final int TYPE_VIDEO = 0;
+
+ /** The media type is audio. */
+ public static final int TYPE_AUDIO = 1;
+
+ /** A string giving a unique source identifier. */
+ public final @NonNull String id;
+
+ /**
+ * A string giving the name of the video source from the system (for example, "Camera 0,
+ * Facing back, Orientation 90"). May be empty.
+ */
+ public final @Nullable String name;
+
+ /**
+ * An int indicating the media source type. Possible values for a video source are:
+ * SOURCE_CAMERA, SOURCE_SCREEN, and SOURCE_OTHER. Possible values for an audio source are:
+ * SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, and SOURCE_OTHER.
+ */
+ public final @Source int source;
+
+ /** An int giving the type of media, must be either TYPE_VIDEO or TYPE_AUDIO. */
+ public final @Type int type;
+
+ private static @Source int getSourceFromString(final String src) {
+ // The strings here should match those in MediaSourceEnum in MediaStreamTrack.webidl
+ if ("camera".equals(src)) {
+ return SOURCE_CAMERA;
+ } else if ("screen".equals(src) || "window".equals(src) || "browser".equals(src)) {
+ return SOURCE_SCREEN;
+ } else if ("microphone".equals(src)) {
+ return SOURCE_MICROPHONE;
+ } else if ("audioCapture".equals(src)) {
+ return SOURCE_AUDIOCAPTURE;
+ } else if ("other".equals(src) || "application".equals(src)) {
+ return SOURCE_OTHER;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + src + " is not a valid media source string");
+ }
+ }
+
+ private static @Type int getTypeFromString(final String type) {
+ // The strings here should match the possible types in MediaDevice::MediaDevice in
+ // MediaManager.cpp
+ if ("videoinput".equals(type)) {
+ return TYPE_VIDEO;
+ } else if ("audioinput".equals(type)) {
+ return TYPE_AUDIO;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + type + " is not a valid media type string");
+ }
+ }
+
+ /* package */ MediaSource(final GeckoBundle media) {
+ id = media.getString("id");
+ name = media.getString("name");
+ source = getSourceFromString(media.getString("mediaSource"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /** Empty constructor for tests. */
+ protected MediaSource() {
+ id = null;
+ name = null;
+ source = SOURCE_CAMERA;
+ type = TYPE_VIDEO;
+ }
+ }
+
+ /**
+ * Callback interface for notifying the result of a media permission request, including which
+ * media source(s) to use.
+ */
+ interface MediaCallback {
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video "id" value from the bundle for the video source to use, or null when video is
+ * not requested.
+ * @param audio "id" value from the bundle for the audio source to use, or null when audio is
+ * not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable String video, final @Nullable String audio) {}
+
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video MediaSource for the video source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when video is not requested.
+ * @param audio MediaSource for the audio source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when audio is not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable MediaSource video, final @Nullable MediaSource audio) {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * one of grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request content media permissions, including request for which video and/or audio source to
+ * use.
+ *
+ * <p>Media permissions will still be requested if the associated device permissions have been
+ * denied if there are video or audio sources in that category that can still be accessed. It is
+ * the responsibility of consumers to ensure that media permission requests are not displayed in
+ * this case.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param uri The URI of the content requesting the permission.
+ * @param video List of video sources, or null if not requesting video.
+ * @param audio List of audio sources, or null if not requesting audio.
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onMediaPermissionRequest(
+ @NonNull final GeckoSession session,
+ @NonNull final String uri,
+ @Nullable final MediaSource[] video,
+ @Nullable final MediaSource[] audio,
+ @NonNull final MediaCallback callback) {
+ callback.reject();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ PermissionDelegate.PERMISSION_GEOLOCATION,
+ PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION,
+ PermissionDelegate.PERMISSION_PERSISTENT_STORAGE,
+ PermissionDelegate.PERMISSION_XR,
+ PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE,
+ PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE,
+ PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS,
+ PermissionDelegate.PERMISSION_TRACKING,
+ PermissionDelegate.PERMISSION_STORAGE_ACCESS
+ })
+ public @interface Permission {}
+
+ /**
+ * Interface that SessionTextInput uses for performing operations such as opening and closing the
+ * software keyboard. If the delegate is not set, these operations are forwarded to the system
+ * {@link android.view.inputmethod.InputMethodManager} automatically.
+ */
+ public interface TextInputDelegate {
+ /** Restarting input due to an input field gaining focus. */
+ int RESTART_REASON_FOCUS = 0;
+ /** Restarting input due to an input field losing focus. */
+ int RESTART_REASON_BLUR = 1;
+ /**
+ * Restarting input due to the content of the input field changing. For example, the input field
+ * type may have changed, or the current composition may have been committed outside of the
+ * input method.
+ */
+ int RESTART_REASON_CONTENT_CHANGE = 2;
+
+ /**
+ * Reset the input method, and discard any existing states such as the current composition or
+ * current autocompletion. Because the current focused editor may have changed, as part of the
+ * reset, a custom input method would normally call {@link
+ * SessionTextInput#onCreateInputConnection} to update its knowledge of the focused editor. Note
+ * that {@code restartInput} should be used to detect changes in focus, rather than {@link
+ * #showSoftInput} or {@link #hideSoftInput}, because focus changes are not always accompanied
+ * by requests to show or hide the soft input. This method is always called, even in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param reason Reason for the reset.
+ */
+ @UiThread
+ default void restartInput(
+ @NonNull final GeckoSession session, @RestartReason final int reason) {}
+
+ /**
+ * Display the soft input. May be called consecutively, even if the soft input is already shown.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #hideSoftInput
+ */
+ @UiThread
+ default void showSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Hide the soft input. May be called consecutively, even if the soft input is already hidden.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #showSoftInput
+ */
+ @UiThread
+ default void hideSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Update the soft input on the current selection. This method is <i>not</i> called in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param selStart Start offset of the selection.
+ * @param selEnd End offset of the selection.
+ * @param compositionStart Composition start offset, or -1 if there is no composition.
+ * @param compositionEnd Composition end offset, or -1 if there is no composition.
+ */
+ @UiThread
+ default void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {}
+
+ /**
+ * Update the soft input on the current extracted text, as requested through {@link
+ * android.view.inputmethod.InputConnection#getExtractedText}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param request The extract text request.
+ * @param text The extracted text.
+ */
+ @UiThread
+ default void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {}
+
+ /**
+ * Update the cursor-anchor information as requested through {@link
+ * android.view.inputmethod.InputConnection#requestCursorUpdates}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param info Cursor-anchor information.
+ */
+ @UiThread
+ default void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ TextInputDelegate.RESTART_REASON_FOCUS,
+ TextInputDelegate.RESTART_REASON_BLUR,
+ TextInputDelegate.RESTART_REASON_CONTENT_CHANGE
+ })
+ public @interface RestartReason {}
+
+ /* package */ void onSurfaceChanged(final @NonNull SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ mWidth = surfaceInfo.mWidth;
+ mHeight = surfaceInfo.mHeight;
+
+ if (mCompositorReady) {
+ mCompositor.syncResumeResizeCompositor(
+ surfaceInfo.mLeft,
+ surfaceInfo.mTop,
+ surfaceInfo.mWidth,
+ surfaceInfo.mHeight,
+ surfaceInfo.mSurface,
+ surfaceInfo.mSurfaceControl);
+ onWindowBoundsChanged();
+ return;
+ }
+
+ // We have a valid surface but we're not attached or the compositor
+ // is not ready; save the surface for later when we're ready.
+ mSurfaceInfo = surfaceInfo;
+
+ // Adjust bounds as the last step.
+ onWindowBoundsChanged();
+ }
+
+ /* package */ void onSurfaceDestroyed() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mCompositorReady) {
+ mCompositor.syncPauseCompositor();
+ return;
+ }
+
+ // While the surface was valid, we never became attached or the
+ // compositor never became ready; clear the saved surface.
+ mSurfaceInfo = null;
+ }
+
+ /* package */ void onScreenOriginChanged(final int left, final int top) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mLeft == left && mTop == top) {
+ return;
+ }
+
+ mLeft = left;
+ mTop = top;
+ onWindowBoundsChanged();
+ }
+
+ /* package */ void setDynamicToolbarMaxHeight(final int height) {
+ if (mDynamicToolbarMaxHeight == height) {
+ return;
+ }
+
+ if (mHeight != 0 && height != 0 && mHeight < height) {
+ Log.w(
+ LOGTAG,
+ new AssertionError(
+ "The maximum height of the dynamic toolbar ("
+ + height
+ + ") should be smaller than GeckoView height ("
+ + mHeight
+ + ")"));
+ }
+
+ mDynamicToolbarMaxHeight = height;
+
+ if (mAttachedCompositor) {
+ mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ }
+ }
+
+ /* package */ void setFixedBottomOffset(final int offset) {
+ if (mFixedBottomOffset == offset) {
+ return;
+ }
+
+ mFixedBottomOffset = offset;
+
+ if (mCompositorReady) {
+ mCompositor.setFixedBottomOffset(mFixedBottomOffset);
+ }
+ }
+
+ /* package */ void onCompositorAttached() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mAttachedCompositor = true;
+ mCompositor.attachNPZC(mPanZoomController.mNative);
+
+ if (mSurfaceInfo != null) {
+ // If we have a valid surface, create the compositor now that we're attached.
+ // Leave mSurface alone because we'll need it later for onCompositorReady.
+ onSurfaceChanged(mSurfaceInfo);
+ }
+
+ mCompositor.sendToolbarAnimatorMessage(IS_COMPOSITOR_CONTROLLER_OPEN);
+ mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ }
+
+ /* package */ void onCompositorDetached() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mController != null) {
+ mController.onCompositorDetached();
+ }
+
+ mAttachedCompositor = false;
+ mCompositorReady = false;
+ }
+
+ /* package */ void handleCompositorMessage(final int message) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ switch (message) {
+ case COMPOSITOR_CONTROLLER_OPEN:
+ {
+ if (isCompositorReady()) {
+ return;
+ }
+
+ // Delay calling onCompositorReady to avoid deadlock due
+ // to synchronous call to the compositor.
+ ThreadUtils.postToUiThread(this::onCompositorReady);
+ break;
+ }
+
+ case FIRST_PAINT:
+ {
+ if (mController != null) {
+ mController.onFirstPaint();
+ }
+ final ContentDelegate delegate = mContentHandler.getDelegate();
+ if (delegate != null) {
+ delegate.onFirstComposite(this);
+ }
+ break;
+ }
+
+ case LAYERS_UPDATED:
+ {
+ if (mController != null) {
+ mController.notifyDrawCallbacks();
+ }
+ break;
+ }
+
+ default:
+ {
+ Log.w(LOGTAG, "Unexpected message: " + message);
+ break;
+ }
+ }
+ }
+
+ /* package */ boolean isCompositorReady() {
+ return mCompositorReady;
+ }
+
+ /* package */ void onCompositorReady() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (!mAttachedCompositor) {
+ return;
+ }
+
+ mCompositorReady = true;
+
+ if (mController != null) {
+ mController.onCompositorReady();
+ }
+
+ if (mSurfaceInfo != null) {
+ // If we have a valid surface, resume the
+ // compositor now that the compositor is ready.
+ onSurfaceChanged(mSurfaceInfo);
+ mSurfaceInfo = null;
+ }
+
+ if (mFixedBottomOffset != 0) {
+ mCompositor.setFixedBottomOffset(mFixedBottomOffset);
+ }
+ }
+
+ /* package */ void updateOverscrollVelocity(final float x, final float y) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mOverscroll == null) {
+ return;
+ }
+
+ // Multiply the velocity by 1000 to match what was done in JPZ.
+ mOverscroll.setVelocity(x * 1000.0f, OverscrollEdgeEffect.AXIS_X);
+ mOverscroll.setVelocity(y * 1000.0f, OverscrollEdgeEffect.AXIS_Y);
+ }
+
+ /* package */ void updateOverscrollOffset(final float x, final float y) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mOverscroll == null) {
+ return;
+ }
+
+ mOverscroll.setDistance(x, OverscrollEdgeEffect.AXIS_X);
+ mOverscroll.setDistance(y, OverscrollEdgeEffect.AXIS_Y);
+ }
+
+ /* package */ void onMetricsChanged(final float scrollX, final float scrollY, final float zoom) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mViewportLeft = scrollX;
+ mViewportTop = scrollY;
+ mViewportZoom = zoom;
+ }
+
+ /* protected */ void onWindowBoundsChanged() {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ if (mHeight != 0 && mDynamicToolbarMaxHeight != 0 && mHeight < mDynamicToolbarMaxHeight) {
+ Log.w(
+ LOGTAG,
+ new AssertionError(
+ "The maximum height of the dynamic toolbar ("
+ + mDynamicToolbarMaxHeight
+ + ") should be smaller than GeckoView height ("
+ + mHeight
+ + ")"));
+ }
+
+ final int toolbarHeight = 0;
+
+ mClientTop = mTop + toolbarHeight;
+ // If the view is not tall enough to even fix the toolbar we just
+ // default the client height to 0
+ mClientHeight = Math.max(mHeight - toolbarHeight, 0);
+
+ if (mAttachedCompositor) {
+ mCompositor.onBoundsChanged(mLeft, mClientTop, mWidth, mClientHeight);
+ }
+
+ if (mOverscroll != null) {
+ mOverscroll.setSize(mWidth, mClientHeight);
+ }
+ }
+
+ /* pacakge */ void onSafeAreaInsetsChanged(
+ final int top, final int right, final int bottom, final int left) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttachedCompositor) {
+ mCompositor.onSafeAreaInsetsChanged(top, right, bottom, left);
+ }
+ }
+
+ /* package */ void setPointerIcon(
+ final int defaultCursor, final @Nullable Bitmap customCursor, final float x, final float y) {
+ ThreadUtils.assertOnUiThread();
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
+ return;
+ }
+
+ final PointerIcon icon;
+ if (customCursor != null) {
+ try {
+ icon = PointerIcon.create(customCursor, x, y);
+ } catch (final IllegalArgumentException e) {
+ // x/y hotspot might be invalid
+ return;
+ }
+ } else {
+ final Context context = GeckoAppShell.getApplicationContext();
+ icon = PointerIcon.getSystemIcon(context, defaultCursor);
+ }
+
+ final ContentDelegate delegate = getContentDelegate();
+ if (delegate != null) {
+ delegate.onPointerIconChange(this, icon);
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle media events. */
+ public interface MediaDelegate {
+
+ class RecordingDevice {
+
+ /*
+ * Default status flags for this RecordingDevice.
+ */
+ public static class Status {
+ public static final long RECORDING = 0;
+ public static final long INACTIVE = 1 << 0;
+
+ // Do not instantiate this class.
+ protected Status() {}
+ }
+
+ /*
+ * Default device types for this RecordingDevice.
+ */
+ public static class Type {
+ public static final long CAMERA = 0;
+ public static final long MICROPHONE = 1 << 0;
+
+ // Do not instantiate this class.
+ protected Type() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {Status.RECORDING, Status.INACTIVE})
+ public @interface RecordingStatus {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {Type.CAMERA, Type.MICROPHONE})
+ public @interface DeviceType {}
+
+ /**
+ * A long giving the current recording status, must be either Status.RECORDING, Status.PAUSED
+ * or Status.INACTIVE.
+ */
+ public final @RecordingStatus long status;
+
+ /**
+ * A long giving the type of the recording device, must be either Type.CAMERA or
+ * Type.MICROPHONE.
+ */
+ public final @DeviceType long type;
+
+ private static @DeviceType long getTypeFromString(final String type) {
+ if ("microphone".equals(type)) {
+ return Type.MICROPHONE;
+ } else if ("camera".equals(type)) {
+ return Type.CAMERA;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + type + " is not a valid recording device string");
+ }
+ }
+
+ private static @RecordingStatus long getStatusFromString(final String type) {
+ if ("recording".equals(type)) {
+ return Status.RECORDING;
+ } else {
+ return Status.INACTIVE;
+ }
+ }
+
+ /* package */ RecordingDevice(final GeckoBundle media) {
+ status = getStatusFromString(media.getString("status"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /** Empty constructor for tests. */
+ protected RecordingDevice() {
+ status = Status.INACTIVE;
+ type = Type.CAMERA;
+ }
+ }
+
+ /**
+ * A recording device has changed state. Any change to the recording state of the devices
+ * microphone or camera will call this delegate method. The argument provides details of the
+ * active recording devices.
+ *
+ * @param session The session that the event has originated from.
+ * @param devices The list of active devices and their recording state.
+ */
+ @UiThread
+ default void onRecordingStatusChanged(
+ @NonNull final GeckoSession session, @NonNull final RecordingDevice[] devices) {}
+ }
+
+ /** An interface for recording new history visits and fetching the visited status for links. */
+ public interface HistoryDelegate {
+ /** A representation of an entry in browser history. */
+ public interface HistoryItem {
+ /**
+ * Get the URI of this history element.
+ *
+ * @return A String representing the URI of this history element.
+ */
+ @AnyThread
+ default @NonNull String getUri() {
+ throw new UnsupportedOperationException("HistoryItem.getUri() called on invalid object.");
+ }
+
+ /**
+ * Get the title of this history element.
+ *
+ * @return A String representing the title of this history element.
+ */
+ @AnyThread
+ default @NonNull String getTitle() {
+ throw new UnsupportedOperationException(
+ "HistoryItem.getString() called on invalid object.");
+ }
+ }
+
+ /**
+ * A representation of browser history, accessible as a `List`. The list itself and its entries
+ * are immutable; any attempt to mutate will result in an `UnsupportedOperationException`.
+ */
+ public interface HistoryList extends List<HistoryItem> {
+ /**
+ * Get the current index in browser history.
+ *
+ * @return An int representing the current index in browser history.
+ */
+ @AnyThread
+ default int getCurrentIndex() {
+ throw new UnsupportedOperationException(
+ "HistoryList.getCurrentIndex() called on invalid object.");
+ }
+ }
+
+ // These flags are similar to those in `IHistory::LoadFlags`, but we use
+ // different values to decouple GeckoView from Gecko changes. These
+ // should be kept in sync with `GeckoViewHistory::GeckoViewVisitFlags`.
+
+ /** The URL was visited a top-level window. */
+ final int VISIT_TOP_LEVEL = 1 << 0;
+ /** The URL is the target of a temporary redirect. */
+ final int VISIT_REDIRECT_TEMPORARY = 1 << 1;
+ /** The URL is the target of a permanent redirect. */
+ final int VISIT_REDIRECT_PERMANENT = 1 << 2;
+ /** The URL is temporarily redirected to another URL. */
+ final int VISIT_REDIRECT_SOURCE = 1 << 3;
+ /** The URL is permanently redirected to another URL. */
+ final int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4;
+ /** The URL failed to load due to a client or server error. */
+ final int VISIT_UNRECOVERABLE_ERROR = 1 << 5;
+
+ /**
+ * Records a visit to a page.
+ *
+ * @param session The session where the URL was visited.
+ * @param url The visited URL.
+ * @param lastVisitedURL The last visited URL in this session, to detect redirects and reloads.
+ * @param flags Additional flags for this visit, including redirect and error statuses. This is
+ * a bitmask of one or more {@link #VISIT_TOP_LEVEL VISIT_*} flags, OR-ed together.
+ * @return A {@link GeckoResult} completed with a boolean indicating whether to highlight links
+ * for the new URL as visited ({@code true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<Boolean> onVisited(
+ @NonNull final GeckoSession session,
+ @NonNull final String url,
+ @Nullable final String lastVisitedURL,
+ @VisitFlags final int flags) {
+ return null;
+ }
+
+ /**
+ * Returns the visited statuses for links on a page. This is used to highlight links as visited
+ * or unvisited, for example.
+ *
+ * @param session The session requesting the visited statuses.
+ * @param urls A list of URLs to check.
+ * @return A {@link GeckoResult} completed with a list of booleans corresponding to the URLs in
+ * {@code urls}, and indicating whether to highlight links for each URL as visited ({@code
+ * true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<boolean[]> getVisited(
+ @NonNull final GeckoSession session, @NonNull final String[] urls) {
+ return null;
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ default void onHistoryStateChange(
+ @NonNull final GeckoSession session, @NonNull final HistoryList historyList) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ HistoryDelegate.VISIT_TOP_LEVEL,
+ HistoryDelegate.VISIT_REDIRECT_TEMPORARY,
+ HistoryDelegate.VISIT_REDIRECT_PERMANENT,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT,
+ HistoryDelegate.VISIT_UNRECOVERABLE_ERROR
+ })
+ public @interface VisitFlags {}
+
+ private Autofill.Support getAutofillSupport() {
+ return mAutofillSupport;
+ }
+
+ /**
+ * Sets the autofill delegate for this session.
+ *
+ * @param delegate An instance of {@link Autofill.Delegate}.
+ */
+ @UiThread
+ public void setAutofillDelegate(final @Nullable Autofill.Delegate delegate) {
+ getAutofillSupport().setDelegate(delegate);
+ }
+
+ /**
+ * @return The current {@link Autofill.Delegate} for this session, if any.
+ */
+ @UiThread
+ public @Nullable Autofill.Delegate getAutofillDelegate() {
+ return getAutofillSupport().getDelegate();
+ }
+
+ /**
+ * Provides an autofill structure similar to {@link
+ * View#onProvideAutofillVirtualStructure(ViewStructure, int)} , but does not rely on {@link
+ * ViewStructure} to build the tree. This is useful for apps that want to provide autofill
+ * functionality without using the Android autofill system or requiring API 26.
+ *
+ * @return The elements available for autofill.
+ */
+ @UiThread
+ public @NonNull Autofill.Session getAutofillSession() {
+ return getAutofillSupport().getAutofillSession();
+ }
+
+ /**
+ * Saves a PDF of the currently displayed page.
+ *
+ * @return A GeckoResult with an InputStream containing the PDF. The result could
+ * CompleteExceptionally with a {@link GeckoPrintException}s, if there are any issues while
+ * generating the PDF.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<InputStream> saveAsPdf() {
+ final GeckoResult<InputStream> geckoResult = new GeckoResult<>();
+ this.mWindow.printToPdf(geckoResult);
+ return geckoResult;
+ }
+
+ private static String rgbaToArgb(final String color) {
+ // We expect #rrggbbaa
+ if (color.length() != 9 || !color.startsWith("#")) {
+ throw new IllegalArgumentException("Invalid color format");
+ }
+
+ return "#" + color.substring(7) + color.substring(1, 7);
+ }
+
+ private static void fixupManifestColor(final JSONObject manifest, final String name)
+ throws JSONException {
+ if (manifest.isNull(name)) {
+ return;
+ }
+
+ manifest.put(name, rgbaToArgb(manifest.getString(name)));
+ }
+
+ private static JSONObject fixupWebAppManifest(final JSONObject manifest) {
+ // Colors are #rrggbbaa, but we want them to be #aarrggbb, since that's what
+ // android.graphics.Color expects.
+ try {
+ fixupManifestColor(manifest, "theme_color");
+ fixupManifestColor(manifest, "background_color");
+ } catch (final JSONException e) {
+ Log.w(LOGTAG, "Failed to fixup web app manifest", e);
+ }
+
+ return manifest;
+ }
+
+ private static boolean maybeCheckDataUriLength(final @NonNull Loader request) {
+ if (!request.mIsDataUri) {
+ return true;
+ }
+
+ return request.mUri.length() <= DATA_URI_MAX_LENGTH;
+ }
+
+ /** Thrown when failure occurs when printing from a website. */
+ @WrapForJNI
+ public static class GeckoPrintException extends Exception {
+ /** The print service was not available. */
+ public static final int ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE = -1;
+ /** The print service was not created due to an initialization error. */
+ public static final int ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS = -2;
+ /** An error happened while trying to find the canonical browing context */
+ public static final int ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT = -3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE,
+ ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS,
+ ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT,
+ })
+ public @interface Codes {}
+
+ /** One of {@link Codes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ @Override
+ public String toString() {
+ return "GeckoPrintException: " + code;
+ }
+
+ /* package */ GeckoPrintException(final @Codes int code) {
+ this.code = code;
+ }
+
+ /** For testing. */
+ protected GeckoPrintException() {
+ code = ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java
new file mode 100644
index 0000000000..629211a4a6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java
@@ -0,0 +1,106 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/* package */ abstract class GeckoSessionHandler<Delegate> implements BundleEventListener {
+
+ private static final String LOGTAG = "GeckoSessionHandler";
+ private static final boolean DEBUG = false;
+
+ private final String mModuleName;
+ private final String[] mEvents;
+ private Delegate mDelegate;
+ private boolean mRegisteredListeners;
+
+ /* package */ GeckoSessionHandler(
+ final String module, final GeckoSession session, final String[] events) {
+ this(module, session, events, new String[] {});
+ }
+
+ /* package */ GeckoSessionHandler(
+ final String module,
+ final GeckoSession session,
+ final String[] events,
+ final String[] defaultEvents) {
+ session.handlersCount++;
+
+ mModuleName = module;
+ mEvents = events;
+
+ // Default events are always active
+ session.getEventDispatcher().registerUiThreadListener(this, defaultEvents);
+ }
+
+ public Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ public void setDelegate(final Delegate delegate, final GeckoSession session) {
+ if (mDelegate == delegate) {
+ return;
+ }
+
+ mDelegate = delegate;
+
+ if (!mRegisteredListeners && delegate != null) {
+ session.getEventDispatcher().registerUiThreadListener(this, mEvents);
+ mRegisteredListeners = true;
+ }
+
+ // If session is not open, we will update module state during session opening.
+ if (!session.isOpen()) {
+ return;
+ }
+
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("module", mModuleName);
+ msg.putBoolean("enabled", isEnabled());
+ session.getEventDispatcher().dispatch("GeckoView:UpdateModuleState", msg);
+ }
+
+ public String getName() {
+ return mModuleName;
+ }
+
+ public boolean isEnabled() {
+ return mDelegate != null;
+ }
+
+ @Override
+ @UiThread
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, mModuleName + " handleMessage: event = " + event);
+ }
+
+ if (mDelegate != null) {
+ handleMessage(mDelegate, event, message, callback);
+ } else {
+ handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ protected abstract void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback);
+
+ protected void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (callback != null) {
+ callback.sendError("No delegate registered");
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
new file mode 100644
index 0000000000..886b2c4b5a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
@@ -0,0 +1,690 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import java.util.Collection;
+import org.mozilla.gecko.util.GeckoBundle;
+
+@AnyThread
+public final class GeckoSessionSettings implements Parcelable {
+
+ /** Settings builder used to construct the settings object. */
+ @AnyThread
+ public static final class Builder {
+ private final GeckoSessionSettings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mSettings = new GeckoSessionSettings();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder(final GeckoSessionSettings settings) {
+ mSettings = new GeckoSessionSettings(settings);
+ }
+
+ /**
+ * Finalize and return the settings.
+ *
+ * @return The constructed settings.
+ */
+ public @NonNull GeckoSessionSettings build() {
+ return new GeckoSessionSettings(mSettings);
+ }
+
+ /**
+ * Set the chrome URI.
+ *
+ * @param uri The URI to set the Chrome URI to.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder chromeUri(final @NonNull String uri) {
+ mSettings.setChromeUri(uri);
+ return this;
+ }
+
+ /**
+ * Set the screen id.
+ *
+ * @param id The screen id.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder screenId(final int id) {
+ mSettings.setScreenId(id);
+ return this;
+ }
+
+ /**
+ * Set the privacy mode for this instance.
+ *
+ * @param flag A flag determining whether Private Mode should be enabled. Default is false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder usePrivateMode(final boolean flag) {
+ mSettings.setUsePrivateMode(flag);
+ return this;
+ }
+
+ /**
+ * Set the session context ID for this instance. Setting a context ID partitions the cookie jars
+ * based on the provided IDs. This isolates the browser storage like cookies and localStorage
+ * between sessions, only sessions that share the same ID share storage data.
+ *
+ * <p>Warning: Storage data is collected persistently for each context, to delete context data,
+ * call {@link StorageController#clearDataForSessionContext} for the given context.
+ *
+ * @param value The custom context ID. The default ID is null, which removes isolation for this
+ * instance.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder contextId(final @Nullable String value) {
+ mSettings.setContextId(value);
+ return this;
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param flag A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder useTrackingProtection(final boolean flag) {
+ mSettings.setUseTrackingProtection(flag);
+ return this;
+ }
+
+ /**
+ * Set the user agent mode.
+ *
+ * @param mode The mode to set the user agent to. Use one or more of the {@link
+ * GeckoSessionSettings#USER_AGENT_MODE_MOBILE GeckoSessionSettings.USER_AGENT_MODE_*}
+ * flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder userAgentMode(final int mode) {
+ mSettings.setUserAgentMode(mode);
+ return this;
+ }
+
+ /**
+ * Override the user agent.
+ *
+ * @param agent The user agent to use.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder userAgentOverride(final @NonNull String agent) {
+ mSettings.setUserAgentOverride(agent);
+ return this;
+ }
+
+ /**
+ * Specify which display-mode to use.
+ *
+ * @param mode The mode to set the display to. Use one or more of the {@link
+ * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder displayMode(final int mode) {
+ mSettings.setDisplayMode(mode);
+ return this;
+ }
+
+ /**
+ * Set whether to suspend the playing of media when the session is inactive.
+ *
+ * @param flag A flag determining whether media should be suspended. Default is false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder suspendMediaWhenInactive(final boolean flag) {
+ mSettings.setSuspendMediaWhenInactive(flag);
+ return this;
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder allowJavascript(final boolean flag) {
+ mSettings.setAllowJavascript(flag);
+ return this;
+ }
+
+ /**
+ * Set whether the entire accessible tree should be exposed with no caching.
+ *
+ * @param flag A flag determining if the entire accessible tree should be exposed. Default is
+ * false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder fullAccessibilityTree(final boolean flag) {
+ mSettings.setFullAccessibilityTree(flag);
+ return this;
+ }
+
+ /**
+ * Specify which viewport mode to use.
+ *
+ * @param mode The mode to set the viewport to. Use one or more of the {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder viewportMode(final int mode) {
+ mSettings.setViewportMode(mode);
+ return this;
+ }
+ }
+
+ private static final String LOGTAG = "GeckoSessionSettings";
+ private static final boolean DEBUG = false;
+
+ // This needs to match GeckoViewSettings.jsm
+ public static final int DISPLAY_MODE_BROWSER = 0;
+ public static final int DISPLAY_MODE_MINIMAL_UI = 1;
+ public static final int DISPLAY_MODE_STANDALONE = 2;
+ public static final int DISPLAY_MODE_FULLSCREEN = 3;
+
+ // This needs to match GeckoViewSettingsChild.js and GeckoViewSettings.jsm
+ public static final int USER_AGENT_MODE_MOBILE = 0;
+ public static final int USER_AGENT_MODE_DESKTOP = 1;
+ public static final int USER_AGENT_MODE_VR = 2;
+
+ // This needs to match GeckoViewSettingsChild.js
+ /**
+ * Mobile-friendly pages will be rendered using a viewport based on their &lt;meta&gt; viewport
+ * tag. All other pages will be rendered using a special desktop mode viewport, which has a width
+ * of 980 CSS px.
+ */
+ public static final int VIEWPORT_MODE_MOBILE = 0;
+
+ /**
+ * All pages will be rendered using the special desktop mode viewport, which has a width of 980
+ * CSS px, regardless of whether the page has a &lt;meta&gt; viewport tag specified or not.
+ */
+ public static final int VIEWPORT_MODE_DESKTOP = 1;
+
+ public static class Key<T> {
+ /* package */ final String name;
+ /* package */ final boolean initOnly;
+ /* package */ final Collection<T> values;
+
+ /* package */ Key(final String name) {
+ this(name, /* initOnly */ false, /* values */ null);
+ }
+
+ /* package */ Key(final String name, final boolean initOnly, final Collection<T> values) {
+ this.name = name;
+ this.initOnly = initOnly;
+ this.values = values;
+ }
+ }
+
+ /**
+ * Key to set the chrome window URI, or null to use default URI. Read-only once session is open.
+ */
+ private static final Key<String> CHROME_URI =
+ new Key<String>("chromeUri", /* initOnly */ true, /* values */ null);
+ /** Key to set the window screen ID, or 0 to use default ID. Read-only once session is open. */
+ private static final Key<Integer> SCREEN_ID =
+ new Key<Integer>("screenId", /* initOnly */ true, /* values */ null);
+
+ /** Key to enable and disable tracking protection. */
+ private static final Key<Boolean> USE_TRACKING_PROTECTION =
+ new Key<Boolean>("useTrackingProtection");
+ /** Key to enable and disable private mode browsing. Read-only once session is open. */
+ private static final Key<Boolean> USE_PRIVATE_MODE =
+ new Key<Boolean>("usePrivateMode", /* initOnly */ true, /* values */ null);
+
+ /** Key to specify which user agent mode we should use. */
+ private static final Key<Integer> USER_AGENT_MODE =
+ new Key<Integer>(
+ "userAgentMode", /* initOnly */
+ false,
+ Arrays.asList(USER_AGENT_MODE_MOBILE, USER_AGENT_MODE_DESKTOP, USER_AGENT_MODE_VR));
+
+ /**
+ * Key to specify the user agent override string. Set value to null to use the user agent
+ * specified by USER_AGENT_MODE.
+ */
+ private static final Key<String> USER_AGENT_OVERRIDE =
+ new Key<String>("userAgentOverride", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify which viewport mode we should use. */
+ private static final Key<Integer> VIEWPORT_MODE =
+ new Key<Integer>(
+ "viewportMode", /* initOnly */
+ false,
+ Arrays.asList(VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP));
+
+ /** Key to specify which display-mode we should use. */
+ private static final Key<Integer> DISPLAY_MODE =
+ new Key<Integer>(
+ "displayMode", /* initOnly */
+ false,
+ Arrays.asList(
+ DISPLAY_MODE_BROWSER, DISPLAY_MODE_MINIMAL_UI,
+ DISPLAY_MODE_STANDALONE, DISPLAY_MODE_FULLSCREEN));
+
+ /** Key to specify if media should be suspended when the session is inactive. */
+ private static final Key<Boolean> SUSPEND_MEDIA_WHEN_INACTIVE =
+ new Key<Boolean>("suspendMediaWhenInactive", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify if JavaScript should be allowed on this session. */
+ private static final Key<Boolean> ALLOW_JAVASCRIPT =
+ new Key<Boolean>("allowJavascript", /* initOnly */ false, /* values */ null);
+ /** Key to specify if entire accessible tree should be exposed with no caching. */
+ private static final Key<Boolean> FULL_ACCESSIBILITY_TREE =
+ new Key<Boolean>("fullAccessibilityTree", /* initOnly */ false, /* values */ null);
+
+ /**
+ * Key to specify if this GeckoSession is a Popup or not. Popup sessions can paint over other
+ * sessions and are not exposed to the tabs WebExtension API.
+ */
+ private static final Key<Boolean> IS_POPUP =
+ new Key<Boolean>("isPopup", /* initOnly */ false, /* values */ null);
+
+ /** Internal Gecko key to specify the session context ID. Derived from `UNSAFE_CONTEXT_ID`. */
+ private static final Key<String> CONTEXT_ID =
+ new Key<String>("sessionContextId", /* initOnly */ true, /* values */ null);
+
+ /** User-provided key to specify the session context ID. */
+ private static final Key<String> UNSAFE_CONTEXT_ID =
+ new Key<String>("unsafeSessionContextId", /* initOnly */ true, /* values */ null);
+
+ private final GeckoSession mSession;
+ private final GeckoBundle mBundle;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings() {
+ this(null, null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings(final @NonNull GeckoSessionSettings settings) {
+ this(settings, null);
+ }
+
+ /* package */ GeckoSessionSettings(
+ final @Nullable GeckoSessionSettings settings, final @Nullable GeckoSession session) {
+ mSession = session;
+
+ if (settings != null) {
+ mBundle = new GeckoBundle(settings.mBundle);
+ return;
+ }
+
+ mBundle = new GeckoBundle();
+ mBundle.putString(CHROME_URI.name, null);
+ mBundle.putInt(SCREEN_ID.name, 0);
+ mBundle.putBoolean(USE_TRACKING_PROTECTION.name, false);
+ mBundle.putBoolean(USE_PRIVATE_MODE.name, false);
+ mBundle.putBoolean(SUSPEND_MEDIA_WHEN_INACTIVE.name, false);
+ mBundle.putBoolean(ALLOW_JAVASCRIPT.name, true);
+ mBundle.putBoolean(FULL_ACCESSIBILITY_TREE.name, false);
+ mBundle.putBoolean(IS_POPUP.name, false);
+ mBundle.putInt(USER_AGENT_MODE.name, USER_AGENT_MODE_MOBILE);
+ mBundle.putString(USER_AGENT_OVERRIDE.name, null);
+ mBundle.putInt(VIEWPORT_MODE.name, VIEWPORT_MODE_MOBILE);
+ mBundle.putInt(DISPLAY_MODE.name, DISPLAY_MODE_BROWSER);
+ mBundle.putString(CONTEXT_ID.name, null);
+ mBundle.putString(UNSAFE_CONTEXT_ID.name, null);
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param value A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ */
+ public void setUseTrackingProtection(final boolean value) {
+ setBoolean(USE_TRACKING_PROTECTION, value);
+ }
+
+ /**
+ * Set the privacy mode for this instance.
+ *
+ * @param value A flag determining whether Private Mode should be enabled. Default is false.
+ */
+ private void setUsePrivateMode(final boolean value) {
+ setBoolean(USE_PRIVATE_MODE, value);
+ }
+
+ /**
+ * Set whether to suspend the playing of media when the session is inactive.
+ *
+ * @param value A flag determining whether media should be suspended. Default is false.
+ */
+ public void setSuspendMediaWhenInactive(final boolean value) {
+ setBoolean(SUSPEND_MEDIA_WHEN_INACTIVE, value);
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param value A flag determining whether JavaScript should be enabled. Default is true.
+ */
+ public void setAllowJavascript(final boolean value) {
+ setBoolean(ALLOW_JAVASCRIPT, value);
+ }
+
+ /**
+ * Set whether the entire accessible tree should be exposed with no caching.
+ *
+ * @param value A flag determining full accessibility tree should be exposed. Default is false.
+ */
+ public void setFullAccessibilityTree(final boolean value) {
+ setBoolean(FULL_ACCESSIBILITY_TREE, value);
+ }
+
+ /* package */ void setIsPopup(final boolean value) {
+ setBoolean(IS_POPUP, value);
+ }
+
+ private void setBoolean(final Key<Boolean> key, final boolean value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putBoolean(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Whether tracking protection is enabled.
+ *
+ * @return true if tracking protection is enabled, false if not.
+ */
+ public boolean getUseTrackingProtection() {
+ return getBoolean(USE_TRACKING_PROTECTION);
+ }
+
+ /**
+ * Whether private mode is enabled.
+ *
+ * @return true if private mode is enabled, false if not.
+ */
+ public boolean getUsePrivateMode() {
+ return getBoolean(USE_PRIVATE_MODE);
+ }
+
+ /**
+ * The context ID for this session.
+ *
+ * @return The context ID for this session.
+ */
+ public @Nullable String getContextId() {
+ // Return the user-provided unsafe string.
+ return getString(UNSAFE_CONTEXT_ID);
+ }
+
+ /**
+ * Whether media will be suspended when the session is inactice.
+ *
+ * @return true if media will be suspended, false if not.
+ */
+ public boolean getSuspendMediaWhenInactive() {
+ return getBoolean(SUSPEND_MEDIA_WHEN_INACTIVE);
+ }
+
+ /**
+ * Whether javascript execution is allowed.
+ *
+ * @return true if javascript execution is allowed, false if not.
+ */
+ public boolean getAllowJavascript() {
+ return getBoolean(ALLOW_JAVASCRIPT);
+ }
+
+ /**
+ * Whether entire accessible tree is exposed with no caching.
+ *
+ * @return true if accessibility tree is exposed, false if not.
+ */
+ public boolean getFullAccessibilityTree() {
+ return getBoolean(FULL_ACCESSIBILITY_TREE);
+ }
+
+ /* package */ boolean getIsPopup() {
+ return getBoolean(IS_POPUP);
+ }
+
+ private boolean getBoolean(final Key<Boolean> key) {
+ synchronized (mBundle) {
+ return mBundle.getBoolean(key.name);
+ }
+ }
+
+ /**
+ * Set the screen id.
+ *
+ * @param value The screen id.
+ */
+ private void setScreenId(final int value) {
+ setInt(SCREEN_ID, value);
+ }
+
+ /**
+ * Specify which user agent mode we should use
+ *
+ * @param value One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public void setUserAgentMode(final int value) {
+ setInt(USER_AGENT_MODE, value);
+ }
+
+ /**
+ * Set the display mode.
+ *
+ * @param value The mode to set the display to. Use one or more of the {@link
+ * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ */
+ public void setDisplayMode(final int value) {
+ setInt(DISPLAY_MODE, value);
+ }
+
+ /**
+ * Specify which viewport mode we should use
+ *
+ * @param value One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE_MOBILE
+ * GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ */
+ public void setViewportMode(final int value) {
+ setInt(VIEWPORT_MODE, value);
+ }
+
+ private void setInt(final Key<Integer> key, final int value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putInt(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the window screen ID. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the window screen ID. 0 is the default ID.
+ */
+ public int getScreenId() {
+ return getInt(SCREEN_ID);
+ }
+
+ /**
+ * The current user agent Mode
+ *
+ * @return One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public int getUserAgentMode() {
+ return getInt(USER_AGENT_MODE);
+ }
+
+ /**
+ * The current display mode.
+ *
+ * @return )One or more of the {@link GeckoSessionSettings#DISPLAY_MODE_BROWSER
+ * GeckoSessionSettings.DISPLAY_MODE_*} flags.
+ */
+ public int getDisplayMode() {
+ return getInt(DISPLAY_MODE);
+ }
+
+ /**
+ * The current viewport Mode
+ *
+ * @return One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE
+ * GeckoSessionSettings.VIEWPORT_MODE_*} flags.
+ */
+ public int getViewportMode() {
+ return getInt(VIEWPORT_MODE);
+ }
+
+ private int getInt(final Key<Integer> key) {
+ synchronized (mBundle) {
+ return mBundle.getInt(key.name);
+ }
+ }
+
+ /**
+ * Set the chrome URI.
+ *
+ * @param value The URI to set the Chrome URI to.
+ */
+ private void setChromeUri(final @NonNull String value) {
+ setString(CHROME_URI, value);
+ }
+
+ /**
+ * Specify the user agent override string. Set value to null to use the user agent specified by
+ * USER_AGENT_MODE.
+ *
+ * @param value The string to override the user agent with.
+ */
+ public void setUserAgentOverride(final @Nullable String value) {
+ setString(USER_AGENT_OVERRIDE, value);
+ }
+
+ private void setContextId(final @Nullable String value) {
+ setString(UNSAFE_CONTEXT_ID, value);
+ setString(CONTEXT_ID, StorageController.createSafeSessionContextId(value));
+ }
+
+ private void setString(final Key<String> key, final String value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putString(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the chrome window URI. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the chrome window URI, or null to use default URI.
+ */
+ public @Nullable String getChromeUri() {
+ return getString(CHROME_URI);
+ }
+
+ /**
+ * The user agent override string.
+ *
+ * @return The current user agent string or null if the agent is specified by {@link
+ * GeckoSessionSettings#USER_AGENT_MODE}
+ */
+ public @Nullable String getUserAgentOverride() {
+ return getString(USER_AGENT_OVERRIDE);
+ }
+
+ private String getString(final Key<String> key) {
+ synchronized (mBundle) {
+ return mBundle.getString(key.name);
+ }
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ return new GeckoBundle(mBundle);
+ }
+
+ @Override
+ public String toString() {
+ return mBundle.toString();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ return other instanceof GeckoSessionSettings
+ && mBundle.equals(((GeckoSessionSettings) other).mBundle);
+ }
+
+ @Override
+ public int hashCode() {
+ return mBundle.hashCode();
+ }
+
+ private <T> boolean valueChangedLocked(final Key<T> key, final T value) {
+ if (key.initOnly && mSession != null) {
+ throw new IllegalStateException("Read-only property");
+ } else if (key.values != null && !key.values.contains(value)) {
+ throw new IllegalArgumentException("Invalid value");
+ }
+
+ final Object old = mBundle.get(key.name);
+ return (old != value) && (old == null || !old.equals(value));
+ }
+
+ private void dispatchUpdate() {
+ if (mSession != null) {
+ mSession.getEventDispatcher().dispatch("GeckoView:UpdateSettings", toBundle());
+ }
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final @NonNull Parcel out, final int flags) {
+ mBundle.writeToParcel(out, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mBundle.readFromParcel(source);
+ }
+
+ public static final Parcelable.Creator<GeckoSessionSettings> CREATOR =
+ new Parcelable.Creator<GeckoSessionSettings>() {
+ @Override
+ public GeckoSessionSettings createFromParcel(final Parcel in) {
+ final GeckoSessionSettings settings = new GeckoSessionSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoSessionSettings[] newArray(final int size) {
+ return new GeckoSessionSettings[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
new file mode 100644
index 0000000000..754414a0ea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
@@ -0,0 +1,42 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * Interface for registering the external VR context with WebVR. The context must be registered
+ * before Gecko is started. This API is not intended for external consumption. To see an example of
+ * how it is used please see the <a href="https://github.com/MozillaReality/FirefoxReality"
+ * target="_blank">Firefox Reality browser</a>.
+ *
+ * @see <a href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">External VR Context</a>
+ */
+public class GeckoVRManager {
+ private static long mExternalContext;
+
+ private GeckoVRManager() {}
+
+ @WrapForJNI
+ private static synchronized long getExternalContext() {
+ return mExternalContext;
+ }
+
+ /**
+ * Sets the external VR context. The external VR context is defined <a
+ * href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">here</a>.
+ *
+ * @param externalContext A pointer to the external VR context.
+ */
+ @AnyThread
+ public static synchronized void setExternalContext(final long externalContext) {
+ mExternalContext = externalContext;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
new file mode 100644
index 0000000000..0f29e90dde
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
@@ -0,0 +1,1122 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.os.Build;
+import android.os.Handler;
+import android.util.AttributeSet;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.util.TypedValue;
+import android.view.DisplayCutout;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
+import android.view.TextureView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillManager;
+import android.view.autofill.AutofillValue;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.FrameLayout;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.core.view.ViewCompat;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+import org.mozilla.gecko.AndroidGamepadManager;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.SurfaceViewWrapper;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class GeckoView extends FrameLayout {
+ private static final String LOGTAG = "GeckoView";
+ private static final boolean DEBUG = false;
+
+ protected final @NonNull Display mDisplay = new Display();
+
+ private Integer mLastCoverColor;
+ protected @Nullable GeckoSession mSession;
+ WeakReference<Autofill.Session> mAutofillSession = new WeakReference<>(null);
+
+ // Whether this GeckoView instance has a session that is no longer valid, e.g. because the session
+ // associated to this GeckoView was attached to a different GeckoView instance.
+ private boolean mIsSessionPoisoned = false;
+
+ private boolean mStateSaved;
+
+ private @Nullable SurfaceViewWrapper mSurfaceWrapper;
+
+ private boolean mIsResettingFocus;
+
+ private boolean mAutofillEnabled = true;
+
+ private GeckoSession.SelectionActionDelegate mSelectionActionDelegate;
+ private Autofill.Delegate mAutofillDelegate;
+
+ private class Display implements SurfaceViewWrapper.Listener {
+ private final int[] mOrigin = new int[2];
+
+ private GeckoDisplay mDisplay;
+ private boolean mValid;
+
+ private int mClippingHeight;
+ private int mDynamicToolbarMaxHeight;
+
+ public void acquire(final GeckoDisplay display) {
+ mDisplay = display;
+
+ if (!mValid) {
+ return;
+ }
+
+ setVerticalClipping(mClippingHeight);
+
+ // Tell display there is already a surface.
+ onGlobalLayout();
+ if (GeckoView.this.mSurfaceWrapper != null) {
+ final SurfaceViewWrapper wrapper = GeckoView.this.mSurfaceWrapper;
+
+ // On some devices, we have seen that the Surface can become abandoned sometime in between
+ // the surfaceChanged callback and attempting to use the Surface here. In such cases,
+ // rendering in to the Surface will always fail, resulting in the user being presented a
+ // blank, unresponsive screen or the application crashing. To work around this, check
+ // whether the Surface is in such a state, and if so toggle the SurfaceView's visibility
+ // in order to request a new Surface. See bug 1772839.
+ final boolean isAbandoned = SurfaceViewWrapper.isSurfaceAbandoned(wrapper.getSurface());
+ if (isAbandoned && wrapper.getView().getVisibility() == View.VISIBLE) {
+ wrapper.getView().setVisibility(View.INVISIBLE);
+ wrapper.getView().setVisibility(View.VISIBLE);
+ } else {
+ mDisplay.surfaceChanged(
+ new GeckoDisplay.SurfaceInfo.Builder(wrapper.getSurface())
+ .surfaceControl(wrapper.getSurfaceControl())
+ .size(wrapper.getWidth(), wrapper.getHeight())
+ .build());
+ mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ GeckoView.this.setActive(true);
+ }
+ }
+ }
+
+ public GeckoDisplay release() {
+ if (mValid) {
+ if (mDisplay != null) {
+ mDisplay.surfaceDestroyed();
+ }
+ GeckoView.this.setActive(false);
+ }
+
+ final GeckoDisplay display = mDisplay;
+ mDisplay = null;
+ return display;
+ }
+
+ @Override // SurfaceListener
+ public void onSurfaceChanged(
+ final Surface surface,
+ @Nullable final SurfaceControl surfaceControl,
+ final int width,
+ final int height) {
+ if (mDisplay != null) {
+ mDisplay.surfaceChanged(
+ new GeckoDisplay.SurfaceInfo.Builder(surface)
+ .surfaceControl(surfaceControl)
+ .size(width, height)
+ .build());
+ mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight);
+ if (!mValid) {
+ GeckoView.this.setActive(true);
+ }
+ }
+ mValid = true;
+ }
+
+ @Override // SurfaceListener
+ public void onSurfaceDestroyed() {
+ if (mDisplay != null) {
+ mDisplay.surfaceDestroyed();
+ GeckoView.this.setActive(false);
+ }
+ mValid = false;
+ }
+
+ public void onGlobalLayout() {
+ if (mDisplay == null) {
+ return;
+ }
+ if (GeckoView.this.mSurfaceWrapper != null) {
+ GeckoView.this.mSurfaceWrapper.getView().getLocationOnScreen(mOrigin);
+ mDisplay.screenOriginChanged(mOrigin[0], mOrigin[1]);
+ // cutout support
+ if (Build.VERSION.SDK_INT >= 28) {
+ final DisplayCutout cutout =
+ GeckoView.this.mSurfaceWrapper.getView().getRootWindowInsets().getDisplayCutout();
+ if (cutout != null) {
+ mDisplay.safeAreaInsetsChanged(
+ cutout.getSafeInsetTop(),
+ cutout.getSafeInsetRight(),
+ cutout.getSafeInsetBottom(),
+ cutout.getSafeInsetLeft());
+ }
+ }
+ }
+ }
+
+ public boolean shouldPinOnScreen() {
+ return mDisplay != null ? mDisplay.shouldPinOnScreen() : false;
+ }
+
+ public void setVerticalClipping(final int clippingHeight) {
+ mClippingHeight = clippingHeight;
+
+ if (mDisplay != null) {
+ mDisplay.setVerticalClipping(clippingHeight);
+ }
+ }
+
+ public void setDynamicToolbarMaxHeight(final int height) {
+ mDynamicToolbarMaxHeight = height;
+
+ // Reset the vertical clipping value to zero whenever we change
+ // the dynamic toolbar __max__ height so that it can be properly
+ // propagated to both the main thread and the compositor thread,
+ // thus we will be able to reset the __current__ toolbar height
+ // on the both threads whatever the __current__ toolbar height is.
+ setVerticalClipping(0);
+
+ if (mDisplay != null) {
+ mDisplay.setDynamicToolbarMaxHeight(height);
+ }
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ @NonNull
+ GeckoResult<Bitmap> capturePixels() {
+ if (mDisplay == null) {
+ return GeckoResult.fromException(
+ new IllegalStateException("Display must be created before pixels can be captured"));
+ }
+
+ return mDisplay.capturePixels();
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context) {
+ super(context);
+ init();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private static Activity getActivityFromContext(final Context outerContext) {
+ Context context = outerContext;
+ while (context instanceof ContextWrapper) {
+ if (context instanceof Activity) {
+ return (Activity) context;
+ }
+ context = ((ContextWrapper) context).getBaseContext();
+ }
+ return null;
+ }
+
+ private void init() {
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+
+ // We are adding descendants to this LayerView, but we don't want the
+ // descendants to affect the way LayerView retains its focus.
+ setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
+
+ // This will stop PropertyAnimator from creating a drawing cache (i.e. a
+ // bitmap) from a SurfaceView, which is just not possible (the bitmap will be
+ // transparent).
+ setWillNotCacheDrawing(false);
+
+ mSurfaceWrapper = new SurfaceViewWrapper(getContext());
+ mSurfaceWrapper.setBackgroundColor(Color.WHITE);
+ addView(
+ mSurfaceWrapper.getView(),
+ new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ mSurfaceWrapper.setListener(mDisplay);
+
+ final Activity activity = getActivityFromContext(getContext());
+ if (activity != null) {
+ mSelectionActionDelegate = new BasicSelectionActionDelegate(activity);
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ mAutofillDelegate = new AndroidAutofillDelegate();
+ } else {
+ // We don't support Autofill on SDK < 26
+ mAutofillDelegate = new Autofill.Delegate() {};
+ }
+ }
+
+ /**
+ * Set a color to cover the display surface while a document is being shown. The color is
+ * automatically cleared once the new document starts painting.
+ *
+ * @param color Cover color.
+ */
+ public void coverUntilFirstPaint(final int color) {
+ mLastCoverColor = color;
+ if (mSession != null) {
+ mSession.getCompositorController().setClearColor(color);
+ }
+ coverUntilFirstPaintInternal(color);
+ }
+
+ private void uncover() {
+ coverUntilFirstPaintInternal(Color.TRANSPARENT);
+ }
+
+ private void coverUntilFirstPaintInternal(final int color) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSurfaceWrapper != null) {
+ mSurfaceWrapper.setBackgroundColor(color);
+ }
+ }
+
+ /**
+ * This GeckoView instance will be backed by a {@link SurfaceView}.
+ *
+ * <p>This option offers the best performance at the price of not being able to animate GeckoView.
+ */
+ public static final int BACKEND_SURFACE_VIEW = 1;
+ /**
+ * This GeckoView instance will be backed by a {@link TextureView}.
+ *
+ * <p>This option offers worse performance compared to {@link #BACKEND_SURFACE_VIEW} but allows
+ * you to animate GeckoView or to paint a GeckoView on top of another GeckoView.
+ */
+ public static final int BACKEND_TEXTURE_VIEW = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({BACKEND_SURFACE_VIEW, BACKEND_TEXTURE_VIEW})
+ public @interface ViewBackend {}
+
+ /**
+ * Set which view should be used by this GeckoView instance to display content.
+ *
+ * <p>By default, GeckoView will use a {@link SurfaceView}.
+ *
+ * @param backend Any of {@link #BACKEND_SURFACE_VIEW BACKEND_*}.
+ */
+ public void setViewBackend(final @ViewBackend int backend) {
+ removeView(mSurfaceWrapper.getView());
+
+ if (backend == BACKEND_SURFACE_VIEW) {
+ mSurfaceWrapper.useSurfaceView(getContext());
+ } else if (backend == BACKEND_TEXTURE_VIEW) {
+ mSurfaceWrapper.useTextureView(getContext());
+ }
+
+ addView(mSurfaceWrapper.getView());
+
+ if (mSession != null) {
+ mSession.getMagnifier().setView(mSurfaceWrapper.getView());
+ }
+ }
+
+ /**
+ * Return whether the view should be pinned on the screen. When pinned, the view should not be
+ * moved on the screen due to animation, scrolling, etc. A common reason for the view being pinned
+ * is when the user is dragging a selection caret inside the view; normal user interaction would
+ * be disrupted in that case if the view was moved on screen.
+ *
+ * @return True if view should be pinned on the screen.
+ */
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDisplay.shouldPinOnScreen();
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the view. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ mDisplay.setVerticalClipping(clippingHeight);
+ }
+
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If there are two or more dynamic toolbars, the height value should be the total amount of
+ * the height of each dynamic toolbar.
+ *
+ * @param height The the maximum height of the dynamic toolbar(s).
+ */
+ public void setDynamicToolbarMaxHeight(final int height) {
+ mDisplay.setDynamicToolbarMaxHeight(height);
+ }
+
+ /* package */ void setActive(final boolean active) {
+ if (mSession != null) {
+ mSession.setActive(active);
+ }
+ }
+
+ // TODO: Bug 1670805 this should really be configurable
+ // Default dark color for about:blank, keep it in sync with PresShell.cpp
+ static final int DEFAULT_DARK_COLOR = 0xFF2A2A2E;
+
+ private int defaultColor() {
+ // If the app set a default color, just use that
+ if (mLastCoverColor != null) {
+ return mLastCoverColor;
+ }
+
+ if (mSession == null || !mSession.isOpen()) {
+ return Color.WHITE;
+ }
+
+ // ... otherwise use the prefers-color-scheme color
+ return mSession.getRuntime().usesDarkTheme() ? DEFAULT_DARK_COLOR : Color.WHITE;
+ }
+
+ /**
+ * Unsets the current session from this instance and returns it, if any. You must call this before
+ * {@link #setSession(GeckoSession)} if there is already an open session set for this instance.
+ *
+ * <p>Note: this method does not close the session and the session remains active. The caller is
+ * responsible for calling {@link GeckoSession#close()} when appropriate.
+ *
+ * @return The {@link GeckoSession} that was set for this instance. May be null.
+ */
+ @UiThread
+ public @Nullable GeckoSession releaseSession() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession == null) {
+ return null;
+ }
+
+ final GeckoSession session = mSession;
+ mSession.releaseDisplay(mDisplay.release());
+ mSession.getOverscrollEdgeEffect().setInvalidationCallback(null);
+ mSession.getOverscrollEdgeEffect().setSession(null);
+ mSession.getCompositorController().setFirstPaintCallback(null);
+
+ if (mSession.getAccessibility().getView() == this) {
+ mSession.getAccessibility().setView(null);
+ }
+
+ if (mSession.getTextInput().getView() == this) {
+ mSession.getTextInput().setView(null);
+ }
+
+ if (mSession.getSelectionActionDelegate() == mSelectionActionDelegate) {
+ mSession.setSelectionActionDelegate(null);
+ }
+
+ if (mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ }
+
+ if (mSession.getMagnifier().getView() == mSurfaceWrapper.getView()) {
+ session.getMagnifier().setView(null);
+ }
+
+ if (isFocused()) {
+ mSession.setFocused(false);
+ }
+ mSession = null;
+ mIsSessionPoisoned = false;
+ session.releaseOwner();
+ return session;
+ }
+
+ private final GeckoSession.Owner mSessionOwner =
+ new GeckoSession.Owner() {
+ @Override
+ public void onRelease() {
+ // The session that we own is being owned by some other object so we need to release it
+ // here.
+ releaseSession();
+ // The session associated to this GeckoView is now invalid, but the app is not aware of
+ // it. We cannot display this GeckoView until the app sets a session again (or releases
+ // the poisoned session).
+ mIsSessionPoisoned = true;
+ }
+ };
+
+ /**
+ * Attach a session to this view. If this instance already has an open session, you must use
+ * {@link #releaseSession()} first, otherwise {@link IllegalStateException} will be thrown. This
+ * is to avoid potentially leaking the currently opened session.
+ *
+ * @param session The session to be attached.
+ * @throws IllegalArgumentException if an existing open session is already set.
+ */
+ @UiThread
+ public void setSession(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+
+ if (session == mSession) {
+ // Nothing to do
+ return;
+ }
+
+ releaseSession();
+
+ session.setOwner(mSessionOwner);
+ mSession = session;
+ mIsSessionPoisoned = false;
+
+ // Make sure the clear color is set to the default
+ mSession.getCompositorController().setClearColor(defaultColor());
+
+ if (ViewCompat.isAttachedToWindow(this)) {
+ mDisplay.acquire(session.acquireDisplay());
+ }
+
+ final Context context = getContext();
+ session.getOverscrollEdgeEffect().setTheme(context);
+ session.getOverscrollEdgeEffect().setSession(session);
+ session
+ .getOverscrollEdgeEffect()
+ .setInvalidationCallback(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (Build.VERSION.SDK_INT >= 16) {
+ GeckoView.this.postInvalidateOnAnimation();
+ } else {
+ GeckoView.this.postInvalidateDelayed(10);
+ }
+ }
+ });
+
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ final TypedValue outValue = new TypedValue();
+ if (context
+ .getTheme()
+ .resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) {
+ session.getPanZoomController().setScrollFactor(outValue.getDimension(metrics));
+ } else {
+ session.getPanZoomController().setScrollFactor(0.075f * metrics.densityDpi);
+ }
+
+ session.getCompositorController().setFirstPaintCallback(this::uncover);
+
+ if (session.getTextInput().getView() == null) {
+ session.getTextInput().setView(this);
+ }
+
+ if (session.getAccessibility().getView() == null) {
+ session.getAccessibility().setView(this);
+ }
+
+ if (session.getSelectionActionDelegate() == null && mSelectionActionDelegate != null) {
+ session.setSelectionActionDelegate(mSelectionActionDelegate);
+ }
+
+ if (mAutofillEnabled) {
+ session.setAutofillDelegate(mAutofillDelegate);
+ }
+
+ if (session.getMagnifier().getView() == null) {
+ session.getMagnifier().setView(mSurfaceWrapper.getView());
+ }
+
+ if (isFocused()) {
+ session.setFocused(true);
+ }
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable GeckoSession getSession() {
+ return mSession;
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ EventDispatcher getEventDispatcher() {
+ return mSession.getEventDispatcher();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull PanZoomController getPanZoomController() {
+ ThreadUtils.assertOnUiThread();
+ return mSession.getPanZoomController();
+ }
+
+ @Override
+ public void onAttachedToWindow() {
+ if (mIsSessionPoisoned) {
+ throw new IllegalStateException("Trying to display a view with invalid session.");
+ }
+ if (mSession != null) {
+ final GeckoRuntime runtime = mSession.getRuntime();
+ if (runtime != null) {
+ runtime.orientationChanged();
+ }
+ }
+
+ if (mSession != null) {
+ mDisplay.acquire(mSession.acquireDisplay());
+ }
+
+ super.onAttachedToWindow();
+ }
+
+ @Override
+ public void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+
+ if (mSession == null) {
+ return;
+ }
+
+ // Release the display before we detach from the window.
+ mSession.releaseDisplay(mDisplay.release());
+ }
+
+ @Override
+ protected void onConfigurationChanged(final Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ if (mSession != null) {
+ final GeckoRuntime runtime = mSession.getRuntime();
+ if (runtime != null) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1
+ || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // onConfigurationChanged is not called for 180 degree orientation changes,
+ // we will miss such rotations and the screen orientation will not be
+ // updated.
+ //
+ // If API is 17+, we use DisplayManager API to detect all degree
+ // orientation change. But if API is 31+, DisplayManager API may report previous
+ // information. So we have to report it again.
+ runtime.orientationChanged(newConfig.orientation);
+ }
+
+ runtime.configurationChanged(newConfig);
+ }
+ }
+ }
+
+ @Override
+ public boolean gatherTransparentRegion(final Region region) {
+ // For detecting changes in SurfaceView layout, we take a shortcut here and
+ // override gatherTransparentRegion, instead of registering a layout listener,
+ // which is more expensive.
+ if (mSurfaceWrapper != null) {
+ mDisplay.onGlobalLayout();
+ }
+ return super.gatherTransparentRegion(region);
+ }
+
+ @Override
+ public void onWindowFocusChanged(final boolean hasWindowFocus) {
+ super.onWindowFocusChanged(hasWindowFocus);
+
+ // Only call setFocus(true) when the window gains focus. Any focus loss could be temporary
+ // (e.g. due to auto-fill popups) and we don't want to call setFocus(false) in those cases.
+ // Instead, we call setFocus(false) in onWindowVisibilityChanged.
+ if (mSession != null && hasWindowFocus && isFocused()) {
+ mSession.setFocused(true);
+ }
+ }
+
+ @Override
+ protected void onWindowVisibilityChanged(final int visibility) {
+ super.onWindowVisibilityChanged(visibility);
+
+ // We can be reasonably sure that the focus loss is not temporary, so call setFocus(false).
+ if (mSession != null && visibility != View.VISIBLE && !hasWindowFocus()) {
+ mSession.setFocused(false);
+ }
+ }
+
+ @Override
+ protected void onFocusChanged(
+ final boolean gainFocus, final int direction, final Rect previouslyFocusedRect) {
+ super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+
+ if (mIsResettingFocus) {
+ return;
+ }
+
+ if (mSession != null) {
+ mSession.setFocused(gainFocus);
+ }
+
+ if (!gainFocus) {
+ return;
+ }
+
+ post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (!isFocused()) {
+ return;
+ }
+
+ final InputMethodManager imm = InputMethods.getInputMethodManager(getContext());
+ // Bug 1404111: Through View#onFocusChanged, the InputMethodManager queues
+ // up a checkFocus call for the next spin of the message loop, so by
+ // posting this Runnable after super#onFocusChanged, the IMM should have
+ // completed its focus change handling at this point and we should be the
+ // active view for input handling.
+
+ // If however onViewDetachedFromWindow for the previously active view gets
+ // called *after* onFocusChanged, but *before* the focus change has been
+ // fully processed by the IMM with the help of checkFocus, the IMM will
+ // lose track of the currently active view, which means that we can't
+ // interact with the IME.
+ if (!imm.isActive(GeckoView.this)) {
+ // If that happens, we bring the IMM's internal state back into sync
+ // by clearing and resetting our focus.
+ mIsResettingFocus = true;
+ clearFocus();
+ // After calling clearFocus we might regain focus automatically, but
+ // we explicitly request it again in case this doesn't happen. If
+ // we've already got the focus back, this will then be a no-op anyway.
+ requestFocus();
+ mIsResettingFocus = false;
+ }
+ }
+ });
+ }
+
+ @Override
+ public Handler getHandler() {
+ if (Build.VERSION.SDK_INT >= 24 || mSession == null) {
+ return super.getHandler();
+ }
+ return mSession.getTextInput().getHandler(super.getHandler());
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mSession == null) {
+ return null;
+ }
+ return mSession.getTextInput().onCreateInputConnection(outAttrs);
+ }
+
+ @Override
+ public boolean onKeyPreIme(final int keyCode, final KeyEvent event) {
+ if (super.onKeyPreIme(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyPreIme(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyUp(final int keyCode, final KeyEvent event) {
+ if (super.onKeyUp(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyUp(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyDown(final int keyCode, final KeyEvent event) {
+ if (super.onKeyDown(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyDown(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyLongPress(final int keyCode, final KeyEvent event) {
+ if (super.onKeyLongPress(keyCode, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyLongPress(keyCode, event);
+ }
+
+ @Override
+ public boolean onKeyMultiple(final int keyCode, final int repeatCount, final KeyEvent event) {
+ if (super.onKeyMultiple(keyCode, repeatCount, event)) {
+ return true;
+ }
+ return mSession != null && mSession.getTextInput().onKeyMultiple(keyCode, repeatCount, event);
+ }
+
+ @Override
+ public void dispatchDraw(final @Nullable Canvas canvas) {
+ super.dispatchDraw(canvas);
+
+ if (mSession != null) {
+ mSession.getOverscrollEdgeEffect().draw(canvas);
+ }
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ @Override
+ public boolean onTouchEvent(final MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return false;
+ }
+
+ mSession.getPanZoomController().onTouchEvent(event);
+ return true;
+ }
+
+ /**
+ * Dispatches a {@link MotionEvent} to the {@link PanZoomController}. This is the same as {@link
+ * #onTouchEvent(MotionEvent)}, but instead returns a {@link PanZoomController.InputResult}
+ * indicating how the event was handled.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure.
+ *
+ * @param event A {@link MotionEvent}
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}.
+ */
+ public @NonNull GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return GeckoResult.fromValue(
+ new PanZoomController.InputResultDetail(
+ PanZoomController.INPUT_RESULT_UNHANDLED,
+ PanZoomController.SCROLLABLE_FLAG_NONE,
+ PanZoomController.OVERSCROLL_FLAG_NONE));
+ }
+
+ // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be
+ // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop.
+ return mSession.getPanZoomController().onTouchEventForDetailResult(event);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(final MotionEvent event) {
+ if (AndroidGamepadManager.handleMotionEvent(event)) {
+ return true;
+ }
+
+ if (mSession == null) {
+ return true;
+ }
+
+ if (mSession.getAccessibility().onMotionEvent(event)) {
+ return true;
+ }
+
+ mSession.getPanZoomController().onMotionEvent(event);
+ return true;
+ }
+
+ @Override
+ public void onProvideAutofillVirtualStructure(final ViewStructure structure, final int flags) {
+ if (mSession == null) {
+ return;
+ }
+
+ final Autofill.Session autofillSession = mSession.getAutofillSession();
+
+ // Let's store the session here in case we need to autofill it later
+ mAutofillSession = new WeakReference<>(autofillSession);
+ autofillSession.fillViewStructure(this, structure, flags);
+ }
+
+ @Override
+ @TargetApi(26)
+ public void autofill(@NonNull final SparseArray<AutofillValue> values) {
+ // Note: we can't use mSession.getAutofillSession() because the app might have swapped
+ // the session under us between the onProvideAutofillVirtualStructure and this call
+ // so mSession could refer to a different session or we might not have a session at all.
+ final Autofill.Session session = mAutofillSession.get();
+ if (session == null) {
+ return;
+ }
+ final SparseArray<CharSequence> strValues = new SparseArray<>(values.size());
+ for (int i = 0; i < values.size(); i++) {
+ final AutofillValue value = values.valueAt(i);
+ if (value.isText()) {
+ // Only text is currently supported.
+ strValues.put(values.keyAt(i), value.getTextValue());
+ }
+ }
+ session.autofill(strValues);
+ }
+
+ @Override
+ public boolean isVisibleToUserForAutofill(final int virtualId) {
+ // If autofill service works with compatibility mode,
+ // View.isVisibleToUserForAutofill walks through the accessibility nodes.
+ // This override avoids it.
+ return true;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>See {@link GeckoDisplay#capturePixels} for more details.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return mDisplay.capturePixels();
+ }
+
+ /**
+ * Sets whether or not this View participates in Android autofill.
+ *
+ * <p>When enabled, this will set an {@link Autofill.Delegate} on the {@link GeckoSession} for
+ * this instance.
+ *
+ * @param enabled Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public void setAutofillEnabled(final boolean enabled) {
+ mAutofillEnabled = enabled;
+
+ if (mSession != null) {
+ if (!enabled && mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ } else if (enabled) {
+ mSession.setAutofillDelegate(mAutofillDelegate);
+ }
+ }
+ }
+
+ /**
+ * @return Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public boolean getAutofillEnabled() {
+ return mAutofillEnabled;
+ }
+
+ @TargetApi(26)
+ private class AndroidAutofillDelegate implements Autofill.Delegate {
+ AutofillManager mAutofillManager;
+ boolean mDisabled = false;
+
+ private void ensureAutofillManager() {
+ if (mDisabled || mAutofillManager != null) {
+ // Nothing to do
+ return;
+ }
+
+ mAutofillManager = GeckoView.this.getContext().getSystemService(AutofillManager.class);
+ if (mAutofillManager == null) {
+ // If we can't get a reference to the autofill manager, we cannot run the autofill service
+ mDisabled = true;
+ }
+ }
+
+ private Rect displayRectForId(
+ @NonNull final GeckoSession session, @Nullable final Autofill.Node node) {
+ if (node == null) {
+ return new Rect(0, 0, 0, 0);
+ }
+
+ if (!node.getScreenRect().isEmpty()) {
+ return node.getScreenRect();
+ }
+
+ final Matrix matrix = new Matrix();
+ final RectF rectF = new RectF(node.getDimensions());
+ session.getPageToScreenMatrix(matrix);
+ matrix.mapRect(rectF);
+
+ final Rect screenRect = new Rect();
+ rectF.roundOut(screenRect);
+ return screenRect;
+ }
+
+ @Override
+ public void onNodeBlur(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node prev,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, data.getId());
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewExited: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeAdd(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ if (!mSession.getAutofillSession().isVisible(node)) {
+ return;
+ }
+ final Autofill.Node focused = mSession.getAutofillSession().getFocused();
+ // We must have a focused node because |node| is visible
+ Objects.requireNonNull(focused);
+
+ final Autofill.NodeData focusedData = mSession.getAutofillSession().dataFor(focused);
+ Objects.requireNonNull(focusedData);
+
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, focusedData.getId());
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, focusedData.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(
+ LOGTAG,
+ "Failed to call AutofillManager.notifyViewExited or AutofillManager.notifyViewEntered: ",
+ e);
+ }
+ }
+
+ @Override
+ public void onNodeFocus(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node focused,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, data.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewEntered: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeRemove(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {}
+
+ @Override
+ public void onNodeUpdate(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyValueChanged(
+ GeckoView.this, data.getId(), AutofillValue.forText(data.getValue()));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyValueChanged: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCancel(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCommit(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.commit();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.commit: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionStart(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java
new file mode 100644
index 0000000000..1546451056
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java
@@ -0,0 +1,189 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * GeckoWebExecutor is responsible for fetching a {@link WebRequest} and delivering a {@link
+ * WebResponse} to the caller via {@link #fetch(WebRequest)}. Example:
+ *
+ * <pre>
+ * final GeckoWebExecutor executor = new GeckoWebExecutor();
+ *
+ * final GeckoResult&lt;WebResponse&gt; result = executor.fetch(
+ * new WebRequest.Builder("https://example.org/json")
+ * .header("Accept", "application/json")
+ * .build());
+ *
+ * result.then(response -&gt; {
+ * // Do something with response
+ * });
+ * </pre>
+ */
+@AnyThread
+public class GeckoWebExecutor {
+ // We don't use this right now because we access GeckoThread directly, but
+ // it's future-proofing for a world where we allow multiple GeckoRuntimes.
+ private final GeckoRuntime mRuntime;
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Fetch")
+ private static native void nativeFetch(
+ WebRequest request, int flags, GeckoResult<WebResponse> result);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve")
+ private static native void nativeResolve(String host, GeckoResult<InetAddress[]> result);
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult")
+ private static ByteBuffer createByteBuffer(final int capacity) {
+ return ByteBuffer.allocateDirect(capacity);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ FETCH_FLAGS_NONE,
+ FETCH_FLAGS_ANONYMOUS,
+ FETCH_FLAGS_NO_REDIRECTS,
+ FETCH_FLAGS_PRIVATE,
+ FETCH_FLAGS_STREAM_FAILURE_TEST,
+ })
+ public @interface FetchFlags {}
+
+ /** No special treatment. */
+ public static final int FETCH_FLAGS_NONE = 0;
+
+ /** Don't send cookies or other user data along with the request. */
+ @WrapForJNI public static final int FETCH_FLAGS_ANONYMOUS = 1;
+
+ /** Don't automatically follow redirects. */
+ @WrapForJNI public static final int FETCH_FLAGS_NO_REDIRECTS = 1 << 1;
+
+ // There was supposed to be another flag, which we then decided not to implement.
+ // That's the reason there's no value 1 << 2, and it can absolutely be used :)
+
+ /** Associates this download with the current private browsing session */
+ @WrapForJNI public static final int FETCH_FLAGS_PRIVATE = 1 << 3;
+
+ /** This flag causes a read error in the {@link WebResponse} body. Useful for testing. */
+ @WrapForJNI public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1 << 10;
+
+ /**
+ * Create a new GeckoWebExecutor instance.
+ *
+ * @param runtime A GeckoRuntime instance
+ */
+ public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ }
+
+ /**
+ * Send the given {@link WebRequest}.
+ *
+ * @param request A {@link WebRequest} instance
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(final @NonNull WebRequest request) {
+ return fetch(request, FETCH_FLAGS_NONE);
+ }
+
+ /**
+ * Send the given {@link WebRequest} with specified flags.
+ *
+ * @param request A {@link WebRequest} instance
+ * @param flags The specified flags. One or more of the {@link #FETCH_FLAGS_NONE FETCH_*} flags.
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(
+ final @NonNull WebRequest request, final @FetchFlags int flags) {
+ if (request.body != null && !request.body.isDirect()) {
+ throw new IllegalArgumentException("Request body must be a direct ByteBuffer");
+ }
+
+ if (request.cacheMode < WebRequest.CACHE_MODE_FIRST
+ || request.cacheMode > WebRequest.CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+
+ final String uri = request.uri.toLowerCase(Locale.ROOT);
+ // We don't need to fully validate the URI here, just a sanity check
+ if (!uri.startsWith("http") && !uri.startsWith("blob")) {
+ throw new IllegalArgumentException(
+ "Unsupported URI scheme: " + (uri.length() > 10 ? uri.substring(0, 10) : uri));
+ }
+
+ final GeckoResult<WebResponse> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeFetch(request, flags, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeFetch",
+ WebRequest.class,
+ request,
+ flags,
+ GeckoResult.class,
+ result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Resolves the specified host name.
+ *
+ * @param host An Internet host name, e.g. mozilla.org.
+ * @return A {@link GeckoResult} which will be fulfilled with a {@link List} of {@link
+ * InetAddress}. In case of failure, the {@link GeckoResult} will be completed exceptionally
+ * with a {@link java.net.UnknownHostException}.
+ */
+ public @NonNull GeckoResult<InetAddress[]> resolve(final @NonNull String host) {
+ final GeckoResult<InetAddress[]> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeResolve(host, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeResolve",
+ String.class,
+ host,
+ GeckoResult.class,
+ result);
+ }
+ return result;
+ }
+
+ /**
+ * This causes a speculative connection to be made to the host in the specified URI. This is
+ * useful if an app thinks it may be making a request to that host in the near future. If no
+ * request is made, the connection will be cleaned up after an unspecified amount of time.
+ *
+ * @param uri A URI String.
+ */
+ public void speculativeConnect(final @NonNull String uri) {
+ GeckoThread.speculativeConnect(uri);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
new file mode 100644
index 0000000000..34bf6b0161
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
@@ -0,0 +1,54 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Bitmap;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/** Represents an Web API image resource as used in web app manifests and media session metadata. */
+@AnyThread
+public class Image {
+ private final ImageResource.Collection mCollection;
+
+ /* package */ Image(final ImageResource.Collection collection) {
+ mCollection = collection;
+ }
+
+ /* package */ static Image fromSizeSrcBundle(final GeckoBundle bundle) {
+ return new Image(ImageResource.Collection.fromSizeSrcBundle(bundle));
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready. Will resolve
+ * exceptionally to {@link ImageProcessingException} if the image cannot be processed.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return mCollection.getBitmap(size);
+ }
+
+ /** Thrown whenever an image cannot be processed by {@link #getBitmap} */
+ @WrapForJNI
+ public static class ImageProcessingException extends RuntimeException {
+ /**
+ * Build an instance of this class.
+ *
+ * @param message description of the error.
+ */
+ public ImageProcessingException(final String message) {
+ super(message);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
new file mode 100644
index 0000000000..49d713d4f3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
@@ -0,0 +1,639 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/**
+ * The MediaSession API provides media controls and events for a GeckoSession. This includes support
+ * for the DOM Media Session API and regular HTML media content.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session
+ * API</a>
+ */
+@UiThread
+public class MediaSession {
+ private static final String LOGTAG = "MediaSession";
+ private static final boolean DEBUG = false;
+
+ private final GeckoSession mSession;
+ private boolean mIsActive;
+
+ protected MediaSession(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Get whether the media session is active. Only active media sessions can be controlled.
+ *
+ * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link
+ * Delegate#onDeactivated} respectively.
+ *
+ * @see MediaSession.Delegate#onActivated
+ * @see MediaSession.Delegate#onDeactivated
+ * @return True if this media session is active, false otherwise.
+ */
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ /* package */ void setActive(final boolean active) {
+ mIsActive = active;
+ }
+
+ /** Pause playback for the media session. */
+ public void pause() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "pause");
+ }
+ mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null);
+ }
+
+ /** Stop playback for the media session. */
+ public void stop() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop");
+ }
+ mSession.getEventDispatcher().dispatch(STOP_EVENT, null);
+ }
+
+ /** Start playback for the media session. */
+ public void play() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "play");
+ }
+ mSession.getEventDispatcher().dispatch(PLAY_EVENT, null);
+ }
+
+ /**
+ * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use
+ * fast seeking for the last or only call in a sequence.
+ *
+ * @param time The time in seconds to move the playback time to.
+ * @param fast Whether fast seeking should be used.
+ */
+ public void seekTo(final double time, final boolean fast) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast);
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putDouble("time", time);
+ bundle.putBoolean("fast", fast);
+ mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle);
+ }
+
+ /** Seek forward by a sensible number of seconds. */
+ public void seekForward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekForward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle);
+ }
+
+ /** Seek backward by a sensible number of seconds. */
+ public void seekBackward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekBackward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle);
+ }
+
+ /**
+ * Select and play the next track. Move playback to the next item in the playlist when supported.
+ */
+ public void nextTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "nextTrack");
+ }
+ mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null);
+ }
+
+ /**
+ * Select and play the previous track. Move playback to the previous item in the playlist when
+ * supported.
+ */
+ public void previousTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "previousTrack");
+ }
+ mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null);
+ }
+
+ /** Skip the advertisement that is currently playing. */
+ public void skipAd() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "skipAd");
+ }
+ mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null);
+ }
+
+ /**
+ * Set whether audio should be muted. Muting audio is supported by default and does not require
+ * the media session to be active.
+ *
+ * @param mute True if audio for this media session should be muted.
+ */
+ public void muteAudio(final boolean mute) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "muteAudio=" + mute);
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("mute", mute);
+ mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle);
+ }
+
+ /** Implement this delegate to receive media session events. */
+ @UiThread
+ public interface Delegate {
+ /**
+ * Notify that the given media session has become active. It is always the first event
+ * dispatched for a new or previously deactivated media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onActivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that the given media session has become inactive. Inactive media sessions can not be
+ * controlled.
+ *
+ * <p>TODO: Add settings links to control behavior.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onDeactivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated metadata. Metadata may be provided by content via the DOM API or by
+ * GeckoView when not availble.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param meta The updated metadata.
+ */
+ default void onMetadata(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final Metadata meta) {}
+
+ /**
+ * Notify on updated supported features. Unsupported actions will have no effect.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param features A combination of {@link Feature}.
+ */
+ default void onFeatures(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @MSFeature final long features) {}
+
+ /**
+ * Notify that playback has started for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPlay(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has paused for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPause(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has stopped for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onStop(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated position state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param state An instance of {@link PositionState}.
+ */
+ default void onPositionState(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final PositionState state) {}
+
+ /**
+ * Notify on changed fullscreen state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param enabled True when this media session in in fullscreen mode.
+ * @param meta An instance of {@link ElementMetadata}, if enabled.
+ */
+ default void onFullscreen(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ final boolean enabled,
+ @Nullable final ElementMetadata meta) {}
+ }
+
+ /** The representation of a media element's metadata. */
+ public static class ElementMetadata {
+ /** The media source URI. */
+ public final @Nullable String source;
+
+ /** The duration of the media in seconds. 0.0 if unknown. */
+ public final double duration;
+
+ /** The width of the video in device pixels. 0 if unknown. */
+ public final long width;
+
+ /** The height of the video in device pixels. 0 if unknown. */
+ public final long height;
+
+ /** The number of audio tracks contained in this element. */
+ public final int audioTrackCount;
+
+ /** The number of video tracks contained in this element. */
+ public final int videoTrackCount;
+
+ /**
+ * ElementMetadata constructor.
+ *
+ * @param source The media URI.
+ * @param duration The media duration in seconds.
+ * @param width The video width in device pixels.
+ * @param height The video height in device pixels.
+ * @param audioTrackCount The audio track count.
+ * @param videoTrackCount The video track count.
+ */
+ public ElementMetadata(
+ @Nullable final String source,
+ final double duration,
+ final long width,
+ final long height,
+ final int audioTrackCount,
+ final int videoTrackCount) {
+ this.source = source;
+ this.duration = duration;
+ this.width = width;
+ this.height = height;
+ this.audioTrackCount = audioTrackCount;
+ this.videoTrackCount = videoTrackCount;
+ }
+
+ /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaUtils.jsm.
+ return new ElementMetadata(
+ bundle.getString("src"),
+ bundle.getDouble("duration", 0.0),
+ bundle.getLong("width", 0),
+ bundle.getLong("height", 0),
+ bundle.getInt("audioTrackCount", 0),
+ bundle.getInt("videoTrackCount", 0));
+ }
+ }
+
+ /** The representation of a media session's metadata. */
+ public static class Metadata {
+ /** The media title. May be backfilled based on the document's title. May be null or empty. */
+ public final @Nullable String title;
+
+ /** The media artist name. May be null or empty. */
+ public final @Nullable String artist;
+
+ /** The media album title. May be null or empty. */
+ public final @Nullable String album;
+
+ /** The media artwork image. May be null. */
+ public final @Nullable Image artwork;
+
+ /**
+ * Metadata constructor.
+ *
+ * @param title The media title string.
+ * @param artist The media artist string.
+ * @param album The media album string.
+ * @param artwork The media artwork {@link Image}.
+ */
+ protected Metadata(
+ final @Nullable String title,
+ final @Nullable String artist,
+ final @Nullable String album,
+ final @Nullable Image artwork) {
+ this.title = title;
+ this.artist = artist;
+ this.album = album;
+ this.artwork = artwork;
+ }
+
+ @AnyThread
+ /* package */ static final class Builder {
+ private final GeckoBundle mBundle;
+
+ public Builder(final GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ public Builder(final Metadata meta) {
+ mBundle = meta.toBundle();
+ }
+
+ @NonNull
+ Builder title(final @Nullable String title) {
+ mBundle.putString("title", title);
+ return this;
+ }
+
+ @NonNull
+ Builder artist(final @Nullable String artist) {
+ mBundle.putString("artist", artist);
+ return this;
+ }
+
+ @NonNull
+ Builder album(final @Nullable String album) {
+ mBundle.putString("album", album);
+ return this;
+ }
+ }
+
+ /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) {
+ final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork");
+
+ final ImageResource.Collection.Builder artworkBuilder =
+ new ImageResource.Collection.Builder();
+
+ for (final GeckoBundle artworkBundle : artworkBundles) {
+ artworkBuilder.add(ImageResource.fromBundle(artworkBundle));
+ }
+
+ return new Metadata(
+ bundle.getString("title"),
+ bundle.getString("artist"),
+ bundle.getString("album"),
+ new Image(artworkBuilder.build()));
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putString("title", title);
+ bundle.putString("artist", artist);
+ bundle.putString("album", album);
+ return bundle;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Metadata {");
+ builder
+ .append(", title=")
+ .append(title)
+ .append(", artist=")
+ .append(artist)
+ .append(", album=")
+ .append(album)
+ .append(", artwork=")
+ .append(artwork)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds the details of the media session's playback state. */
+ public static class PositionState {
+ /** The duration of the media in seconds. */
+ public final double duration;
+
+ /** The last reported media playback position in seconds. */
+ public final double position;
+
+ /**
+ * The media playback rate coefficient. The rate is positive for forward and negative for
+ * backward playback.
+ */
+ public final double playbackRate;
+
+ /**
+ * PositionState constructor.
+ *
+ * @param duration The media duration in seconds.
+ * @param position The current media playback position in seconds.
+ * @param playbackRate The playback rate coefficient.
+ */
+ protected PositionState(
+ final double duration, final double position, final double playbackRate) {
+ this.duration = duration;
+ this.position = position;
+ this.playbackRate = playbackRate;
+ }
+
+ /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) {
+ return new PositionState(
+ bundle.getDouble("duration"),
+ bundle.getDouble("position"),
+ bundle.getDouble("playbackRate"));
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("PositionState {");
+ builder
+ .append("duration=")
+ .append(duration)
+ .append(", position=")
+ .append(position)
+ .append(", playbackRate=")
+ .append(playbackRate)
+ .append("}");
+ return builder.toString();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ Feature.NONE,
+ Feature.PLAY,
+ Feature.PAUSE,
+ Feature.STOP,
+ Feature.SEEK_TO,
+ Feature.SEEK_FORWARD,
+ Feature.SEEK_BACKWARD,
+ Feature.SKIP_AD,
+ Feature.NEXT_TRACK,
+ Feature.PREVIOUS_TRACK,
+ // Feature.SET_VIDEO_SURFACE
+ })
+ public @interface MSFeature {}
+
+ /** Flags for supported media session features. */
+ public static class Feature {
+ public static final long NONE = 0;
+
+ /** Playback supported. */
+ public static final long PLAY = 1 << 0;
+
+ /** Pausing supported. */
+ public static final long PAUSE = 1 << 1;
+
+ /** Stopping supported. */
+ public static final long STOP = 1 << 2;
+
+ /** Absolute seeking supported. */
+ public static final long SEEK_TO = 1 << 3;
+
+ /** Relative seeking supported (forward). */
+ public static final long SEEK_FORWARD = 1 << 4;
+
+ /** Relative seeking supported (backward). */
+ public static final long SEEK_BACKWARD = 1 << 5;
+
+ /** Skipping advertisements supported. */
+ public static final long SKIP_AD = 1 << 6;
+
+ /** Next track selection supported. */
+ public static final long NEXT_TRACK = 1 << 7;
+
+ /** Previous track selection supported. */
+ public static final long PREVIOUS_TRACK = 1 << 8;
+
+ /** Focusing supported. */
+ public static final long FOCUS = 1 << 9;
+
+ // /**
+ // * Custom video surface supported.
+ // */
+ // public static final long SET_VIDEO_SURFACE = 1 << 10;
+
+ /* package */ static long fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaController.webidl.
+ final long features =
+ NONE
+ | (bundle.getBoolean("play") ? PLAY : NONE)
+ | (bundle.getBoolean("pause") ? PAUSE : NONE)
+ | (bundle.getBoolean("stop") ? STOP : NONE)
+ | (bundle.getBoolean("seekto") ? SEEK_TO : NONE)
+ | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE)
+ | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE)
+ | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE)
+ | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE)
+ | (bundle.getBoolean("skipad") ? SKIP_AD : NONE)
+ | (bundle.getBoolean("focus") ? FOCUS : NONE);
+ return features;
+ }
+ }
+
+ private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated";
+ private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated";
+ private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata";
+ private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState";
+ private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features";
+ private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen";
+ private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None";
+ private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused";
+ private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing";
+
+ private static final String PLAY_EVENT = "GeckoView:MediaSession:Play";
+ private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause";
+ private static final String STOP_EVENT = "GeckoView:MediaSession:Stop";
+ private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack";
+ private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack";
+ private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward";
+ private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward";
+ private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd";
+ private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo";
+ private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio";
+
+ /* package */ static class Handler extends GeckoSessionHandler<MediaSession.Delegate> {
+
+ private final GeckoSession mSession;
+ private final MediaSession mMediaSession;
+
+ public Handler(final GeckoSession session) {
+ super(
+ "GeckoViewMediaControl",
+ session,
+ new String[] {
+ ACTIVATED_EVENT,
+ DEACTIVATED_EVENT,
+ METADATA_EVENT,
+ FULLSCREEN_EVENT,
+ POSITION_STATE_EVENT,
+ PLAYBACK_NONE_EVENT,
+ PLAYBACK_PAUSED_EVENT,
+ PLAYBACK_PLAYING_EVENT,
+ FEATURES_EVENT,
+ });
+ mSession = session;
+ mMediaSession = new MediaSession(session);
+ }
+
+ @Override
+ public void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (ACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(true);
+ delegate.onActivated(mSession, mMediaSession);
+ } else if (DEACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(false);
+ delegate.onDeactivated(mSession, mMediaSession);
+ } else if (METADATA_EVENT.equals(event)) {
+ final Metadata meta = Metadata.fromBundle(message.getBundle("metadata"));
+ delegate.onMetadata(mSession, mMediaSession, meta);
+ } else if (POSITION_STATE_EVENT.equals(event)) {
+ final PositionState state = PositionState.fromBundle(message.getBundle("state"));
+ delegate.onPositionState(mSession, mMediaSession, state);
+ } else if (PLAYBACK_NONE_EVENT.equals(event)) {
+ delegate.onStop(mSession, mMediaSession);
+ } else if (PLAYBACK_PAUSED_EVENT.equals(event)) {
+ delegate.onPause(mSession, mMediaSession);
+ } else if (PLAYBACK_PLAYING_EVENT.equals(event)) {
+ delegate.onPlay(mSession, mMediaSession);
+ } else if (FEATURES_EVENT.equals(event)) {
+ final long features = Feature.fromBundle(message.getBundle("features"));
+ delegate.onFeatures(mSession, mMediaSession, features);
+ } else if (FULLSCREEN_EVENT.equals(event) && mMediaSession.isActive()) {
+ final boolean enabled = message.getBoolean("enabled");
+ final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata"));
+ delegate.onFullscreen(mSession, mMediaSession, enabled, meta);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java
new file mode 100644
index 0000000000..e2a4c236b5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java
@@ -0,0 +1,60 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class OrientationController {
+ private OrientationDelegate mDelegate;
+
+ OrientationController() {}
+
+ /**
+ * Sets the {@link OrientationDelegate} for this instance.
+ *
+ * @param delegate The {@link OrientationDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable OrientationDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Gets the {@link OrientationDelegate} for this instance.
+ *
+ * @return delegate The {@link OrientationDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public OrientationDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mDelegate;
+ }
+
+ /** This delegate will be called whenever an orientation lock is called. */
+ @UiThread
+ public interface OrientationDelegate {
+ /**
+ * Called whenever the orientation should be locked.
+ *
+ * @param aOrientation The desired orientation such as ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
+ * @return A {@link GeckoResult} which resolves to a {@link AllowOrDeny}
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOrientationLock(@NonNull final int aOrientation) {
+ return null;
+ }
+
+ /** Called whenever the orientation should be unlocked. */
+ @Nullable
+ default void onOrientationUnlock() {}
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
new file mode 100644
index 0000000000..248bfa065a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
@@ -0,0 +1,244 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.graphics.BlendMode;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.graphics.Rect;
+import android.os.Build;
+import android.widget.EdgeEffect;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.reflect.Field;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public final class OverscrollEdgeEffect {
+ // Used to index particular edges in the edges array
+ private static final int TOP = 0;
+ private static final int BOTTOM = 1;
+ private static final int LEFT = 2;
+ private static final int RIGHT = 3;
+
+ /* package */ static final int AXIS_X = 0;
+ /* package */ static final int AXIS_Y = 1;
+
+ // All four edges of the screen
+ private final EdgeEffect[] mEdges = new EdgeEffect[4];
+
+ private GeckoSession mSession;
+ private Runnable mInvalidationCallback;
+ private int mWidth;
+ private int mHeight;
+
+ /* package */ OverscrollEdgeEffect() {}
+
+ private static Field sPaintField;
+
+ private void setBlendMode(final EdgeEffect edgeEffect) {
+ if (Build.VERSION.SDK_INT < 29) {
+ // setBlendMode is only supported on SDK_INT >= 29 and above.
+
+ if (sPaintField == null) {
+ try {
+ sPaintField = EdgeEffect.class.getDeclaredField("mPaint");
+ sPaintField.setAccessible(true);
+ } catch (final NoSuchFieldException e) {
+ // Cannot get the field, nothing we can do here
+ return;
+ }
+ }
+
+ try {
+ final Paint paint = (Paint) sPaintField.get(edgeEffect);
+ final PorterDuffXfermode mode = new PorterDuffXfermode(PorterDuff.Mode.SRC);
+ paint.setXfermode(mode);
+ } catch (final IllegalAccessException ex) {
+ // Nothing we can do
+ }
+
+ return;
+ }
+
+ edgeEffect.setBlendMode(BlendMode.SRC);
+ }
+
+ /**
+ * Set the theme to use for overscroll from a given Context.
+ *
+ * @param context Context to use for the overscroll theme.
+ */
+ public void setTheme(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+
+ for (int i = 0; i < mEdges.length; i++) {
+ final EdgeEffect edgeEffect = new EdgeEffect(context);
+ if (mWidth != 0 || mHeight != 0) {
+ edgeEffect.setSize(mWidth, mHeight);
+ }
+ setBlendMode(edgeEffect);
+ mEdges[i] = edgeEffect;
+ }
+ }
+
+ /* package */ void setSession(final @Nullable GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Set a Runnable that acts as a callback to invalidate the overscroll effect (for example, as a
+ * response to user fling for example). The Runnbale should schedule a future call to {@link
+ * #draw(Canvas)} as a result of the invalidation.
+ *
+ * @param runnable Invalidation Runnable.
+ * @see #getInvalidationCallback()
+ */
+ public void setInvalidationCallback(final @Nullable Runnable runnable) {
+ ThreadUtils.assertOnUiThread();
+ mInvalidationCallback = runnable;
+ }
+
+ /**
+ * Get the current invalidatation Runnable.
+ *
+ * @return Invalidation Runnable.
+ * @see #setInvalidationCallback(Runnable)
+ */
+ public @Nullable Runnable getInvalidationCallback() {
+ ThreadUtils.assertOnUiThread();
+ return mInvalidationCallback;
+ }
+
+ /* package */ void setSize(final int width, final int height) {
+ mEdges[LEFT].setSize(height, width);
+ mEdges[RIGHT].setSize(height, width);
+ mEdges[TOP].setSize(width, height);
+ mEdges[BOTTOM].setSize(width, height);
+
+ mWidth = width;
+ mHeight = height;
+ }
+
+ private EdgeEffect getEdgeForAxisAndSide(final int axis, final float side) {
+ if (axis == AXIS_Y) {
+ if (side < 0) {
+ return mEdges[TOP];
+ } else {
+ return mEdges[BOTTOM];
+ }
+ } else {
+ if (side < 0) {
+ return mEdges[LEFT];
+ } else {
+ return mEdges[RIGHT];
+ }
+ }
+ }
+
+ /* package */ void setVelocity(final float velocity, final int axis) {
+ if (velocity == 0.0f) {
+ if (axis == AXIS_Y) {
+ mEdges[TOP].onRelease();
+ mEdges[BOTTOM].onRelease();
+ } else {
+ mEdges[LEFT].onRelease();
+ mEdges[RIGHT].onRelease();
+ }
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ return;
+ }
+
+ final EdgeEffect edge = getEdgeForAxisAndSide(axis, velocity);
+
+ // If we're showing overscroll already, start fading it out.
+ if (!edge.isFinished()) {
+ edge.onRelease();
+ } else {
+ // Otherwise, show an absorb effect
+ edge.onAbsorb((int) velocity);
+ }
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ /* package */ void setDistance(final float distance, final int axis) {
+ // The first overscroll event often has zero distance. Throw it out
+ if (distance == 0.0f) {
+ return;
+ }
+
+ final EdgeEffect edge = getEdgeForAxisAndSide(axis, (int) distance);
+ edge.onPull(distance / (axis == AXIS_X ? mWidth : mHeight));
+
+ if (mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ /**
+ * Draw the overscroll effect on a Canvas.
+ *
+ * @param canvas Canvas to draw on.
+ */
+ public void draw(final @NonNull Canvas canvas) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession == null) {
+ return;
+ }
+
+ final Rect pageRect = new Rect();
+ mSession.getSurfaceBounds(pageRect);
+
+ // If we're pulling an edge, or fading it out, draw!
+ boolean invalidate = false;
+ if (!mEdges[TOP].isFinished()) {
+ invalidate |= draw(mEdges[TOP], canvas, pageRect.left, pageRect.top, 0);
+ }
+
+ if (!mEdges[BOTTOM].isFinished()) {
+ invalidate |= draw(mEdges[BOTTOM], canvas, pageRect.right, pageRect.bottom, 180);
+ }
+
+ if (!mEdges[LEFT].isFinished()) {
+ invalidate |= draw(mEdges[LEFT], canvas, pageRect.left, pageRect.bottom, 270);
+ }
+
+ if (!mEdges[RIGHT].isFinished()) {
+ invalidate |= draw(mEdges[RIGHT], canvas, pageRect.right, pageRect.top, 90);
+ }
+
+ // If the edge effect is animating off screen, invalidate.
+ if (invalidate && mInvalidationCallback != null) {
+ mInvalidationCallback.run();
+ }
+ }
+
+ private static boolean draw(
+ final EdgeEffect edge,
+ final Canvas canvas,
+ final float translateX,
+ final float translateY,
+ final float rotation) {
+ final int state = canvas.save();
+ canvas.translate(translateX, translateY);
+ canvas.rotate(rotation);
+ final boolean invalidate = edge.draw(canvas);
+ canvas.restoreToCount(state);
+
+ return invalidate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
new file mode 100644
index 0000000000..6b2d8c9fc3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
@@ -0,0 +1,942 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.SystemClock;
+import android.util.Log;
+import android.util.Pair;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class PanZoomController {
+ private static final String LOGTAG = "GeckoNPZC";
+ private static final int EVENT_SOURCE_SCROLL = 0;
+ private static final int EVENT_SOURCE_MOTION = 1;
+ private static final int EVENT_SOURCE_MOUSE = 2;
+ private static Boolean sTreatMouseAsTouch = null;
+
+ private final GeckoSession mSession;
+ private final Rect mTempRect = new Rect();
+ private boolean mAttached;
+ private float mPointerScrollFactor = 64.0f;
+ private long mLastDownTime;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SCROLL_BEHAVIOR_SMOOTH, SCROLL_BEHAVIOR_AUTO})
+ public @interface ScrollBehaviorType {}
+
+ /** Specifies smooth scrolling which animates content to the desired scroll position. */
+ public static final int SCROLL_BEHAVIOR_SMOOTH = 0;
+ /** Specifies auto scrolling which jumps content to the desired scroll position. */
+ public static final int SCROLL_BEHAVIOR_AUTO = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ INPUT_RESULT_UNHANDLED,
+ INPUT_RESULT_HANDLED,
+ INPUT_RESULT_HANDLED_CONTENT,
+ INPUT_RESULT_IGNORED
+ })
+ public @interface InputResult {}
+
+ /**
+ * Specifies that an input event was not handled by the PanZoomController for a panning or zooming
+ * operation. The event may have been handled by Web content or internally (e.g. text selection).
+ */
+ @WrapForJNI public static final int INPUT_RESULT_UNHANDLED = 0;
+
+ /**
+ * Specifies that an input event was handled by the PanZoomController for a panning or zooming
+ * operation, but likely not by any touch event listeners in Web content.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_HANDLED = 1;
+
+ /**
+ * Specifies that an input event was handled by the PanZoomController and passed on to touch event
+ * listeners in Web content.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_HANDLED_CONTENT = 2;
+
+ /**
+ * Specifies that an input event was consumed by a PanZoomController internally and browsers
+ * should do nothing in response to the event.
+ */
+ @WrapForJNI public static final int INPUT_RESULT_IGNORED = 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SCROLLABLE_FLAG_NONE,
+ SCROLLABLE_FLAG_TOP,
+ SCROLLABLE_FLAG_RIGHT,
+ SCROLLABLE_FLAG_BOTTOM,
+ SCROLLABLE_FLAG_LEFT
+ })
+ public @interface ScrollableDirections {}
+ /**
+ * Represents which directions can be scrolled in the scroll container where an input event was
+ * handled. This value is only useful in the case of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED}.
+ */
+ /* The container cannot be scrolled. */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_NONE = 0;
+ /* The container cannot be scrolled to top */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_TOP = 1 << 0;
+ /* The container cannot be scrolled to right */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_RIGHT = 1 << 1;
+ /* The container cannot be scrolled to bottom */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_BOTTOM = 1 << 2;
+ /* The container cannot be scrolled to left */
+ @WrapForJNI public static final int SCROLLABLE_FLAG_LEFT = 1 << 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {OVERSCROLL_FLAG_NONE, OVERSCROLL_FLAG_HORIZONTAL, OVERSCROLL_FLAG_VERTICAL})
+ public @interface OverscrollDirections {}
+ /**
+ * Represents which directions can be over-scrolled in the scroll container where an input event
+ * was handled. This value is only useful in the case of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED}.
+ */
+ /* the container cannot be over-scrolled. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_NONE = 0;
+ /* the container can be over-scrolled horizontally. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_HORIZONTAL = 1 << 0;
+ /* the container can be over-scrolled vertically. */
+ @WrapForJNI public static final int OVERSCROLL_FLAG_VERTICAL = 1 << 1;
+
+ /**
+ * Represents how a {@link MotionEvent} was handled in Gecko. This value can be used by browser
+ * apps to implement features like pull-to-refresh. Failing to account this value might break some
+ * websites expectations about touch events.
+ *
+ * <p>For example, a {@link PanZoomController.InputResultDetail#handledResult} value of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED} and {@link
+ * PanZoomController.InputResultDetail#overscrollDirections} of {@link
+ * PanZoomController#OVERSCROLL_FLAG_NONE} indicates that the event was consumed for a panning or
+ * zooming operation and that the website does not expect the browser to react to the touch event
+ * (say, by triggering the pull-to-refresh feature) even though the scroll container reached to
+ * the edge.
+ */
+ @WrapForJNI
+ public static class InputResultDetail {
+ protected InputResultDetail(
+ final @InputResult int handledResult,
+ final @ScrollableDirections int scrollableDirections,
+ final @OverscrollDirections int overscrollDirections) {
+ mHandledResult = handledResult;
+ mScrollableDirections = scrollableDirections;
+ mOverscrollDirections = overscrollDirections;
+ }
+
+ /**
+ * @return One of the {@link #INPUT_RESULT_UNHANDLED INPUT_RESULT_*} indicating how the event
+ * was handled.
+ */
+ @AnyThread
+ public @InputResult int handledResult() {
+ return mHandledResult;
+ }
+ /**
+ * @return an OR-ed value of {@link #SCROLLABLE_FLAG_NONE SCROLLABLE_FLAG_*} indicating which
+ * directions can be scrollable.
+ */
+ @AnyThread
+ public @ScrollableDirections int scrollableDirections() {
+ return mScrollableDirections;
+ }
+ /**
+ * @return an OR-ed value of {@link #OVERSCROLL_FLAG_NONE OVERSCROLL_FLAG_*} indicating which
+ * directions can be over-scrollable.
+ */
+ @AnyThread
+ public @OverscrollDirections int overscrollDirections() {
+ return mOverscrollDirections;
+ }
+
+ private final @InputResult int mHandledResult;
+ private final @ScrollableDirections int mScrollableDirections;
+ private final @OverscrollDirections int mOverscrollDirections;
+ }
+
+ private SynthesizedEventState mPointerState;
+
+ private ArrayList<Pair<Integer, MotionEvent>> mQueuedEvents;
+
+ private boolean mSynthesizedEvent = false;
+
+ @WrapForJNI
+ private static class MotionEventData {
+ public final int action;
+ public final int actionIndex;
+ public final long time;
+ public final int metaState;
+ public final int pointerId[];
+ public final int historySize;
+ public final long historicalTime[];
+ public final float historicalX[];
+ public final float historicalY[];
+ public final float historicalOrientation[];
+ public final float historicalPressure[];
+ public final float historicalToolMajor[];
+ public final float historicalToolMinor[];
+ public final float x[];
+ public final float y[];
+ public final float orientation[];
+ public final float pressure[];
+ public final float toolMajor[];
+ public final float toolMinor[];
+
+ public MotionEventData(final MotionEvent event) {
+ final int count = event.getPointerCount();
+ action = event.getActionMasked();
+ actionIndex = event.getActionIndex();
+ time = event.getEventTime();
+ metaState = event.getMetaState();
+ historySize = event.getHistorySize();
+ historicalTime = new long[historySize];
+ historicalX = new float[historySize * count];
+ historicalY = new float[historySize * count];
+ historicalOrientation = new float[historySize * count];
+ historicalPressure = new float[historySize * count];
+ historicalToolMajor = new float[historySize * count];
+ historicalToolMinor = new float[historySize * count];
+ pointerId = new int[count];
+ x = new float[count];
+ y = new float[count];
+ orientation = new float[count];
+ pressure = new float[count];
+ toolMajor = new float[count];
+ toolMinor = new float[count];
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ historicalTime[historyIndex] = event.getHistoricalEventTime(historyIndex);
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ for (int i = 0; i < count; i++) {
+ pointerId[i] = event.getPointerId(i);
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ event.getHistoricalPointerCoords(i, historyIndex, coords);
+
+ final int historicalI = historyIndex * count + i;
+ historicalX[historicalI] = coords.x;
+ historicalY[historicalI] = coords.y;
+
+ historicalOrientation[historicalI] = coords.orientation;
+ historicalPressure[historicalI] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ historicalToolMajor[historicalI] = coords.toolMajor;
+ historicalToolMinor[historicalI] = coords.toolMinor;
+ }
+
+ event.getPointerCoords(i, coords);
+
+ x[i] = coords.x;
+ y[i] = coords.y;
+
+ orientation[i] = coords.orientation;
+ pressure[i] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ toolMajor[i] = coords.toolMajor;
+ toolMinor[i] = coords.toolMinor;
+ }
+ }
+ }
+
+ /* package */ final class NativeProvider extends JNIObject {
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private native void handleMotionEvent(
+ MotionEventData eventData,
+ float screenX,
+ float screenY,
+ GeckoResult<InputResultDetail> result);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleScrollEvent(
+ long time, int metaState, float x, float y, float hScroll, float vScroll);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleMouseEvent(
+ int action, long time, int metaState, float x, float y, int buttons);
+
+ @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread.
+ private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled);
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeTouchPoint(
+ final int pointerId,
+ final int eventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation) {
+ if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) {
+ throw new IllegalArgumentException("Pointer ID reserved for mouse");
+ }
+ synthesizeNativePointer(
+ InputDevice.SOURCE_TOUCHSCREEN,
+ pointerId,
+ eventType,
+ clientX,
+ clientY,
+ pressure,
+ orientation,
+ 0);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeMouseEvent(
+ final int eventType, final int clientX, final int clientY, final int button) {
+ synthesizeNativePointer(
+ InputDevice.SOURCE_MOUSE,
+ PointerInfo.RESERVED_MOUSE_POINTER_ID,
+ eventType,
+ clientX,
+ clientY,
+ 0,
+ 0,
+ button);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void setAttached(final boolean attached) {
+ if (attached) {
+ mAttached = true;
+ flushEventQueue();
+ } else if (mAttached) {
+ mAttached = false;
+ enableEventQueue();
+ }
+ }
+ }
+
+ /* package */ final NativeProvider mNative = new NativeProvider();
+
+ private void handleMotionEvent(final MotionEvent event) {
+ handleMotionEvent(event, null);
+ }
+
+ private void handleMotionEvent(
+ final MotionEvent event, final GeckoResult<InputResultDetail> result) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event));
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_HANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final int action = event.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ mLastDownTime = event.getDownTime();
+ } else if (mLastDownTime != event.getDownTime()) {
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_UNHANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final float screenX = event.getRawX() - event.getX();
+ final float screenY = event.getRawY() - event.getY();
+
+ // Take this opportunity to update screen origin of session. This gets
+ // dispatched to the gecko thread, so we also pass the new screen x/y directly to apz.
+ // If this is a synthesized touch, the screen offset is bogus so ignore it.
+ if (!mSynthesizedEvent) {
+ mSession.onScreenOriginChanged((int) screenX, (int) screenY);
+ }
+
+ final MotionEventData data = new MotionEventData(event);
+ mNative.handleMotionEvent(data, screenX, screenY, result);
+ }
+
+ private @InputResult int handleScrollEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_SCROLL, event));
+ return INPUT_RESULT_HANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for scroll events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * mPointerScrollFactor;
+ final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * mPointerScrollFactor;
+
+ return mNative.handleScrollEvent(
+ event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll);
+ }
+
+ private @InputResult int handleMouseEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOUSE, event));
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for mouse events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ return mNative.handleMouseEvent(
+ event.getActionMasked(),
+ event.getEventTime(),
+ event.getMetaState(),
+ x,
+ y,
+ event.getButtonState());
+ }
+
+ protected PanZoomController(final GeckoSession session) {
+ mSession = session;
+ enableEventQueue();
+ }
+
+ private boolean treatMouseAsTouch() {
+ if (sTreatMouseAsTouch == null) {
+ final Context c = GeckoAppShell.getApplicationContext();
+ if (c == null) {
+ // This might happen if the GeckoRuntime has not been initialized yet.
+ return false;
+ }
+ final UiModeManager m = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE);
+ // on TV devices, treat mouse as touch. everywhere else, don't
+ sTreatMouseAsTouch = (m.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION);
+ }
+
+ return sTreatMouseAsTouch;
+ }
+
+ /**
+ * Set the current scroll factor. The scroll factor is the maximum scroll amount that one scroll
+ * event may generate, in device pixels.
+ *
+ * @param factor Scroll factor.
+ */
+ public void setScrollFactor(final float factor) {
+ ThreadUtils.assertOnUiThread();
+ mPointerScrollFactor = factor;
+ }
+
+ /**
+ * Get the current scroll factor.
+ *
+ * @return Scroll factor.
+ */
+ public float getScrollFactor() {
+ ThreadUtils.assertOnUiThread();
+ return mPointerScrollFactor;
+ }
+
+ /**
+ * This is a workaround for touch pad on Android app by Chrome OS. Android app on Chrome OS fires
+ * weird motion event by two finger scroll. See https://crbug.com/704051
+ */
+ private boolean mayTouchpadScroll(final @NonNull MotionEvent event) {
+ final int action = event.getActionMasked();
+ return event.getButtonState() == 0
+ && (action == MotionEvent.ACTION_DOWN
+ || (mLastDownTime == event.getDownTime()
+ && (action == MotionEvent.ACTION_MOVE
+ || action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_CANCEL)));
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onTouchEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ handleMouseEvent(event);
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure. Instead, prefer to call {@link #onTouchEvent(MotionEvent)}.
+ *
+ * @param event MotionEvent to process.
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}).
+ */
+ public @NonNull GeckoResult<InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ return GeckoResult.fromValue(
+ new InputResultDetail(
+ handleMouseEvent(event), SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+
+ final GeckoResult<InputResultDetail> result = new GeckoResult<>();
+ handleMotionEvent(event, result);
+ return result;
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "mouse" rather
+ * than as "touch". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMouseEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ mNative.setAttached(false);
+ }
+
+ /**
+ * Process a non-touch motion event through the pan-zoom controller. Currently, hover and scroll
+ * events are supported. Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMotionEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ final int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_SCROLL) {
+ if (event.getDownTime() >= mLastDownTime) {
+ mLastDownTime = event.getDownTime();
+ } else if ((InputDevice.getDevice(event.getDeviceId()) != null)
+ && (InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD)
+ == InputDevice.SOURCE_TOUCHPAD) {
+ return;
+ }
+ handleScrollEvent(event);
+ } else if ((action == MotionEvent.ACTION_HOVER_MOVE)
+ || (action == MotionEvent.ACTION_HOVER_ENTER)
+ || (action == MotionEvent.ACTION_HOVER_EXIT)) {
+ handleMouseEvent(event);
+ }
+ }
+
+ private void enableEventQueue() {
+ if (mQueuedEvents != null) {
+ throw new IllegalStateException("Already have an event queue");
+ }
+ mQueuedEvents = new ArrayList<>();
+ }
+
+ private void flushEventQueue() {
+ if (mQueuedEvents == null) {
+ return;
+ }
+
+ final ArrayList<Pair<Integer, MotionEvent>> events = mQueuedEvents;
+ mQueuedEvents = null;
+ for (final Pair<Integer, MotionEvent> pair : events) {
+ switch (pair.first) {
+ case EVENT_SOURCE_MOTION:
+ handleMotionEvent(pair.second);
+ break;
+ case EVENT_SOURCE_SCROLL:
+ handleScrollEvent(pair.second);
+ break;
+ case EVENT_SOURCE_MOUSE:
+ handleMouseEvent(pair.second);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set whether Gecko should generate long-press events.
+ *
+ * @param isLongpressEnabled True if Gecko should generate long-press events.
+ */
+ public void setIsLongpressEnabled(final boolean isLongpressEnabled) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ mNative.nativeSetIsLongpressEnabled(isLongpressEnabled);
+ }
+ }
+
+ private static class PointerInfo {
+ // We reserve one pointer ID for the mouse, so that tests don't have
+ // to worry about tracking pointer IDs if they just want to test mouse
+ // event synthesization. If somebody tries to use this ID for a
+ // synthesized touch event we'll throw an exception.
+ public static final int RESERVED_MOUSE_POINTER_ID = 100000;
+
+ public int pointerId;
+ public int source;
+ public int surfaceX;
+ public int surfaceY;
+ public double pressure;
+ public int orientation;
+ public int buttonState;
+
+ public MotionEvent.PointerCoords getCoords() {
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.orientation = orientation;
+ coords.pressure = (float) pressure;
+ coords.x = surfaceX;
+ coords.y = surfaceY;
+ return coords;
+ }
+ }
+
+ private static class SynthesizedEventState {
+ public final ArrayList<PointerInfo> pointers;
+ public long downTime;
+
+ SynthesizedEventState() {
+ pointers = new ArrayList<PointerInfo>();
+ }
+
+ int getPointerIndex(final int pointerId) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).pointerId == pointerId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ int addPointer(final int pointerId, final int source) {
+ final PointerInfo info = new PointerInfo();
+ info.pointerId = pointerId;
+ info.source = source;
+ pointers.add(info);
+ return pointers.size() - 1;
+ }
+
+ int getPointerCount(final int source) {
+ int count = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ int getPointerButtonState(final int source) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ return pointers.get(i).buttonState;
+ }
+ }
+ return 0;
+ }
+
+ MotionEvent.PointerProperties[] getPointerProperties(final int source) {
+ final MotionEvent.PointerProperties[] props =
+ new MotionEvent.PointerProperties[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ final MotionEvent.PointerProperties p = new MotionEvent.PointerProperties();
+ p.id = pointers.get(i).pointerId;
+ switch (source) {
+ case InputDevice.SOURCE_TOUCHSCREEN:
+ p.toolType = MotionEvent.TOOL_TYPE_FINGER;
+ break;
+ case InputDevice.SOURCE_MOUSE:
+ p.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+ break;
+ }
+ props[index++] = p;
+ }
+ }
+ return props;
+ }
+
+ MotionEvent.PointerCoords[] getPointerCoords(final int source) {
+ final MotionEvent.PointerCoords[] coords =
+ new MotionEvent.PointerCoords[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ coords[index++] = pointers.get(i).getCoords();
+ }
+ }
+ return coords;
+ }
+ }
+
+ private void synthesizeNativePointer(
+ final int source,
+ final int pointerId,
+ final int originalEventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation,
+ final int button) {
+ if (mPointerState == null) {
+ mPointerState = new SynthesizedEventState();
+ }
+
+ // Find the pointer if it already exists
+ int pointerIndex = mPointerState.getPointerIndex(pointerId);
+
+ // Event-specific handling
+ int eventType = originalEventType;
+ switch (originalEventType) {
+ case MotionEvent.ACTION_POINTER_UP:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-up for invalid pointer");
+ return;
+ }
+ if (mPointerState.pointers.size() == 1) {
+ // Last pointer is going up
+ eventType = MotionEvent.ACTION_UP;
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-cancel for invalid pointer");
+ return;
+ }
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (pointerIndex < 0) {
+ // Adding a new pointer
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ if (pointerIndex == 0) {
+ // first pointer
+ eventType = MotionEvent.ACTION_DOWN;
+ mPointerState.downTime = SystemClock.uptimeMillis();
+ }
+ } else {
+ // We're moving an existing pointer
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ case MotionEvent.ACTION_HOVER_MOVE:
+ if (pointerIndex < 0) {
+ // Mouse-move a pointer without it going "down". However
+ // in order to send the right MotionEvent without a lot of
+ // duplicated code, we add the pointer to mPointerState,
+ // and then remove it at the bottom of this function.
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ } else {
+ // We're moving an existing mouse pointer that went down.
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ }
+
+ // Translate client origin to surface origin.
+ mSession.getSurfaceBounds(mTempRect);
+ final int surfaceX = clientX + mTempRect.left;
+ final int surfaceY = clientY + mTempRect.top;
+
+ // Update the pointer with the new info
+ final PointerInfo info = mPointerState.pointers.get(pointerIndex);
+ info.surfaceX = surfaceX;
+ info.surfaceY = surfaceY;
+ info.pressure = pressure;
+ info.orientation = orientation;
+ if (source == InputDevice.SOURCE_MOUSE) {
+ if (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE) {
+ info.buttonState |= button;
+ } else if (eventType == MotionEvent.ACTION_UP) {
+ info.buttonState &= button;
+ }
+ }
+
+ // Dispatch the event
+ int action = 0;
+ if (eventType == MotionEvent.ACTION_POINTER_DOWN
+ || eventType == MotionEvent.ACTION_POINTER_UP) {
+ // for pointer-down and pointer-up events we need to add the
+ // index of the relevant pointer.
+ action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
+ action &= MotionEvent.ACTION_POINTER_INDEX_MASK;
+ }
+ action |= (eventType & MotionEvent.ACTION_MASK);
+ final MotionEvent event =
+ MotionEvent.obtain(
+ /*downTime*/ mPointerState.downTime,
+ /*eventTime*/ SystemClock.uptimeMillis(),
+ /*action*/ action,
+ /*pointerCount*/ mPointerState.getPointerCount(source),
+ /*pointerProperties*/ mPointerState.getPointerProperties(source),
+ /*pointerCoords*/ mPointerState.getPointerCoords(source),
+ /*metaState*/ 0,
+ /*buttonState*/ mPointerState.getPointerButtonState(source),
+ /*xPrecision*/ 0,
+ /*yPrecision*/ 0,
+ /*deviceId*/ 0,
+ /*edgeFlags*/ 0,
+ /*source*/ source,
+ /*flags*/ 0);
+
+ mSynthesizedEvent = true;
+ onTouchEvent(event);
+ mSynthesizedEvent = false;
+
+ // Forget about removed pointers
+ if (eventType == MotionEvent.ACTION_POINTER_UP
+ || eventType == MotionEvent.ACTION_UP
+ || eventType == MotionEvent.ACTION_CANCEL
+ || eventType == MotionEvent.ACTION_HOVER_MOVE) {
+ mPointerState.pointers.remove(pointerIndex);
+ }
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position. Uses {@link
+ * #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollBy(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollBy(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg);
+ }
+
+ /**
+ * Scroll the document body to an absolute position. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollTo(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body to an absolute position.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollTo(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg);
+ }
+
+ /** Scroll to the top left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToTop() {
+ scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /** Scroll to the bottom left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToBottom() {
+ scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ private GeckoBundle buildScrollMessage(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putDouble("widthValue", width.getValue());
+ msg.putInt("widthType", width.getType());
+ msg.putDouble("heightValue", height.getValue());
+ msg.putInt("heightType", height.getType());
+ msg.putInt("behavior", behavior);
+ return msg;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
new file mode 100644
index 0000000000..7feb7d88ae
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+
+class ParcelableUtils {
+ public static void writeBoolean(final Parcel out, final boolean val) {
+ out.writeByte((byte) (val ? 1 : 0));
+ }
+
+ public static boolean readBoolean(final Parcel source) {
+ return source.readByte() == 1;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
new file mode 100644
index 0000000000..9e655c5eb7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
@@ -0,0 +1,182 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoJavaSampler;
+
+/**
+ * ProfilerController is used to manage GeckoProfiler related features.
+ *
+ * <p>If you want to add a profiler marker to mark a point in time (without a duration) you can
+ * directly use <code>profilerController.addMarker("marker name")</code>. Or if you want to provide
+ * more information, you can use <code>
+ * profilerController.addMarker("marker name", "extra information")</code> If you want to add a
+ * profiler marker with a duration (with start and end time) you can use it like this: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code> Or you can capture start and end time in somewhere, then add the marker in somewhere
+ * else: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure (or end time can be collected in a callback)...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime);
+ * </code> Here's an <code>addMarker</code> example with all the possible parameters: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime, "extra information");
+ * </code> <code>isProfilerActive</code> method is handy when you want to get more information to
+ * add inside the marker, but you think it's going to be computationally heavy (and useless) when
+ * profiler is not running:
+ *
+ * <pre>
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * if (profilerController.isProfilerActive()) {
+ * String info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive();
+ * profilerController.addMarker("name", startTime, info);
+ * }
+ * </code>
+ * </pre>
+ *
+ * FIXME(bug 1618560): Currently only works in the main thread.
+ */
+@UiThread
+public class ProfilerController {
+ private static final String LOGTAG = "ProfilerController";
+
+ /**
+ * Returns true if profiler is active and it's allowed the add markers. It's useful when it's
+ * computationally heavy to get startTime or the additional text for the marker. That code can be
+ * wrapped with isProfilerActive if check to reduce the overhead of it.
+ *
+ * @return true if profiler is active and safe to add a new marker.
+ */
+ public boolean isProfilerActive() {
+ return GeckoJavaSampler.isProfilerActive();
+ }
+
+ /**
+ * Get the profiler time to be able to mark the start of the marker events. can be used like this:
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code>
+ *
+ * @return profiler time as double or null if the profiler is not active.
+ */
+ public @Nullable Double getProfilerTime() {
+ return GeckoJavaSampler.tryToGetProfilerTime();
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. No-op if profiler is not
+ * active.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, aEndTime, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final Double aStartTime) {
+ addMarker(aMarkerName, aStartTime, null, null);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final String aText) {
+ addMarker(aMarkerName, null, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ */
+ public void addMarker(@NonNull final String aMarkerName) {
+ addMarker(aMarkerName, null, null, null);
+ }
+
+ /**
+ * Start the Gecko profiler with the given settings. This is used by embedders which want to
+ * control the profiler from the embedding app. This allows them to provide an easier access point
+ * to profiling, as an alternative to the traditional way of using a desktop Firefox instance
+ * connected via USB + adb.
+ *
+ * @param aFilters The list of threads to profile, as an array of string of thread names filters.
+ * Each filter is used as a case-insensitive substring match against the actual thread names.
+ * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array.
+ */
+ public void startProfiler(
+ @NonNull final String[] aFilters, @NonNull final String[] aFeaturesArr) {
+ GeckoJavaSampler.startProfiler(aFilters, aFeaturesArr);
+ }
+
+ /**
+ * Stop the profiler and capture the recorded profile. This method is asynchronous.
+ *
+ * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer
+ * containing a gzip-compressed payload (with gzip header) of the profile JSON.
+ */
+ public @NonNull GeckoResult<byte[]> stopProfiler() {
+ return GeckoJavaSampler.stopProfiler();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
new file mode 100644
index 0000000000..72a07c218e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
@@ -0,0 +1,646 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONException;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.Autocomplete.AddressSaveOption;
+import org.mozilla.geckoview.Autocomplete.AddressSelectOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption;
+import org.mozilla.geckoview.Autocomplete.LoginSaveOption;
+import org.mozilla.geckoview.Autocomplete.LoginSelectOption;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt.AuthOptions;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt.Observer;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ButtonPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ColorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PopupPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.RepostConfirmPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.TextPrompt;
+
+/* package */ class PromptController {
+ private static final String LOGTAG = "Prompts";
+
+ private static class PromptStorage implements BasePrompt.Observer {
+ private final Map<String, BasePrompt> mPrompts = new HashMap<>();
+
+ public void addPrompt(final String id, final BasePrompt prompt) {
+ if (mPrompts.containsKey(id)) {
+ Log.e(LOGTAG, "Prompt already exists! id=" + id);
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Prompt already exists! id=" + id);
+ }
+ }
+ mPrompts.put(id, prompt);
+ }
+
+ @Override
+ public void onPromptCompleted(final BasePrompt prompt) {
+ // No need to notify this delegate since the prompt has been completed already.
+ mPrompts.remove(prompt.id);
+ }
+
+ public void dismiss(final String id) {
+ final BasePrompt prompt = mPrompts.get(id);
+ if (prompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = prompt.getDelegate();
+ if (delegate != null) {
+ delegate.onPromptDismiss(prompt);
+ }
+ mPrompts.remove(prompt.id);
+ }
+
+ public boolean contains(final String id) {
+ return mPrompts.containsKey(id);
+ }
+
+ public void update(final BasePrompt prompt) {
+ final BasePrompt previousPrompt = mPrompts.get(prompt.id);
+ if (previousPrompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = previousPrompt.getDelegate();
+ if (delegate == null) {
+ return;
+ }
+ prompt.setDelegate(delegate);
+ delegate.onPromptUpdate(prompt);
+ mPrompts.put(prompt.id, prompt);
+ }
+ }
+
+ final PromptStorage mStorage = new PromptStorage();
+
+ public void dismissPrompt(final String id) {
+ mStorage.dismiss(id);
+ }
+
+ public void updatePrompt(final GeckoBundle message) {
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ // Invalid prompt message type to update the prompt.
+ return;
+ }
+ final BasePrompt prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ // Invalid prompt message to update the prompt.
+ return;
+ }
+ if (!mStorage.contains(prompt.id)) {
+ // Invalid prompt id to update the prompt. Dismissed?
+ return;
+ }
+
+ mStorage.update(prompt);
+ }
+
+ public void handleEvent(
+ final GeckoSession session, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleEvent " + message.getString("type"));
+ final PromptDelegate delegate = session.getPromptDelegate();
+ if (delegate == null) {
+ // Default behavior is same as calling dismiss() on callback.
+ callback.sendSuccess(null);
+ return;
+ }
+
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ callback.sendError("Invalid type: " + type);
+ return;
+ }
+ final GeckoResult<PromptResponse> res = getResponse(message, session, delegate, handler);
+
+ if (res == null) {
+ // Adhere to default behavior if the delegate returns null.
+ callback.sendSuccess(null);
+ } else {
+ res.accept(
+ value -> value.dispatch(callback),
+ exception -> callback.sendError("Failed to get prompt response."));
+ }
+ }
+
+ private <PromptType extends BasePrompt> GeckoResult<PromptResponse> getResponse(
+ final GeckoBundle message,
+ final GeckoSession session,
+ final PromptDelegate delegate,
+ final PromptHandler<PromptType> handler) {
+ final PromptType prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ try {
+ Log.e(LOGTAG, "Invalid prompt: " + message.toJSONObject().toString());
+ } catch (final JSONException ex) {
+ Log.e(LOGTAG, "Invalid prompt, invalid data", ex);
+ }
+
+ return GeckoResult.fromException(new IllegalArgumentException("Invalid prompt data."));
+ }
+
+ mStorage.addPrompt(prompt.id, prompt);
+ return handler.callDelegate(prompt, session, delegate);
+ }
+
+ private interface PromptHandler<PromptType extends BasePrompt> {
+ PromptType newPrompt(GeckoBundle info, Observer observer);
+
+ GeckoResult<PromptResponse> callDelegate(
+ PromptType prompt, GeckoSession session, PromptDelegate delegate);
+ }
+
+ private static final class AlertHandler implements PromptHandler<AlertPrompt> {
+ @Override
+ public AlertPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AlertPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AlertPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAlertPrompt(session, prompt);
+ }
+ }
+
+ private static final class BeforeUnloadHandler implements PromptHandler<BeforeUnloadPrompt> {
+ @Override
+ public BeforeUnloadPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new BeforeUnloadPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final BeforeUnloadPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onBeforeUnloadPrompt(session, prompt);
+ }
+ }
+
+ private static final class ButtonHandler implements PromptHandler<ButtonPrompt> {
+ @Override
+ public ButtonPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ButtonPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ButtonPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onButtonPrompt(session, prompt);
+ }
+ }
+
+ private static final class TextHandler implements PromptHandler<TextPrompt> {
+ @Override
+ public TextPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new TextPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ info.getString("value"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final TextPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onTextPrompt(session, prompt);
+ }
+ }
+
+ private static final class AuthHandler implements PromptHandler<AuthPrompt> {
+ @Override
+ public AuthPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AuthPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ new AuthOptions(info.getBundle("options")),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AuthPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAuthPrompt(session, prompt);
+ }
+ }
+
+ private static final class ChoiceHandler implements PromptHandler<ChoicePrompt> {
+ @Override
+ public ChoicePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final int intMode;
+ final String mode = info.getString("mode");
+ if ("menu".equals(mode)) {
+ intMode = ChoicePrompt.Type.MENU;
+ } else if ("single".equals(mode)) {
+ intMode = ChoicePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = ChoicePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final GeckoBundle[] choiceBundles = info.getBundleArray("choices");
+ final ChoicePrompt.Choice[] choices;
+ if (choiceBundles == null || choiceBundles.length == 0) {
+ choices = new ChoicePrompt.Choice[0];
+ } else {
+ choices = new ChoicePrompt.Choice[choiceBundles.length];
+ for (int i = 0; i < choiceBundles.length; i++) {
+ choices[i] = new ChoicePrompt.Choice(choiceBundles[i]);
+ }
+ }
+
+ return new ChoicePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ intMode,
+ choices,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ChoicePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onChoicePrompt(session, prompt);
+ }
+ }
+
+ private static final class ColorHandler implements PromptHandler<ColorPrompt> {
+ @Override
+ public ColorPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ColorPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("value"),
+ info.getStringArray("predefinedValues"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ColorPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onColorPrompt(session, prompt);
+ }
+ }
+
+ private static final class DateTimeHandler implements PromptHandler<DateTimePrompt> {
+ @Override
+ public DateTimePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("date".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATE;
+ } else if ("month".equals(mode)) {
+ intMode = DateTimePrompt.Type.MONTH;
+ } else if ("week".equals(mode)) {
+ intMode = DateTimePrompt.Type.WEEK;
+ } else if ("time".equals(mode)) {
+ intMode = DateTimePrompt.Type.TIME;
+ } else if ("datetime-local".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATETIME_LOCAL;
+ } else {
+ return null;
+ }
+
+ final String defaultValue = info.getString("value");
+ final String minValue = info.getString("min");
+ final String maxValue = info.getString("max");
+ final String stepValue = info.getString("step");
+ return new DateTimePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ intMode,
+ defaultValue,
+ minValue,
+ maxValue,
+ stepValue,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final DateTimePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onDateTimePrompt(session, prompt);
+ }
+ }
+
+ private static final class FileHandler implements PromptHandler<FilePrompt> {
+ @Override
+ public FilePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("single".equals(mode)) {
+ intMode = FilePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = FilePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final String[] mimeTypes = info.getStringArray("mimeTypes");
+ final int capture = info.getInt("capture");
+ return new FilePrompt(
+ info.getString("id"), info.getString("title"), intMode, capture, mimeTypes, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final FilePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onFilePrompt(session, prompt);
+ }
+ }
+
+ private static final class PopupHandler implements PromptHandler<PopupPrompt> {
+ @Override
+ public PopupPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new PopupPrompt(info.getString("id"), info.getString("targetUri"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final PopupPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onPopupPrompt(session, prompt);
+ }
+ }
+
+ private static final class RepostHandler implements PromptHandler<RepostConfirmPrompt> {
+ @Override
+ public RepostConfirmPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new RepostConfirmPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final RepostConfirmPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onRepostConfirmPrompt(session, prompt);
+ }
+ }
+
+ private static final class ShareHandler implements PromptHandler<SharePrompt> {
+ @Override
+ public SharePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new SharePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("text"),
+ info.getString("uri"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final SharePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onSharePrompt(session, prompt);
+ }
+ }
+
+ private static final class LoginSaveHandler
+ implements PromptHandler<AutocompleteRequest<LoginSaveOption>> {
+ @Override
+ public AutocompleteRequest<LoginSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] loginBundles = info.getBundleArray("logins");
+
+ if (loginBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSaveOption[] options =
+ new Autocomplete.LoginSaveOption[loginBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.LoginSaveOption(new Autocomplete.LoginEntry(loginBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSave(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSaveHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSaveOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] creditCardBundles = info.getBundleArray("creditCards");
+
+ if (creditCardBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSaveOption[] options =
+ new Autocomplete.CreditCardSaveOption[creditCardBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.CreditCardSaveOption(
+ new Autocomplete.CreditCard(creditCardBundles[i]), hint);
+ }
+
+ return new PromptDelegate.AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSave(session, prompt);
+ }
+ }
+
+ private static final class AddressSaveHandler
+ implements PromptHandler<AutocompleteRequest<AddressSaveOption>> {
+ @Override
+ public AutocompleteRequest<AddressSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] addressBundles = info.getBundleArray("addresses");
+
+ if (addressBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSaveOption[] options =
+ new Autocomplete.AddressSaveOption[addressBundles.length];
+
+ final int hint = info.getInt("hint");
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.AddressSaveOption(new Autocomplete.Address(addressBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSave(session, prompt);
+ }
+ }
+
+ private static final class LoginSelectHandler
+ implements PromptHandler<AutocompleteRequest<LoginSelectOption>> {
+ @Override
+ public AutocompleteRequest<LoginSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSelectOption[] options =
+ new Autocomplete.LoginSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.LoginSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSelect(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSelectHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSelectOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSelectOption[] options =
+ new Autocomplete.CreditCardSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.CreditCardSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSelect(session, prompt);
+ }
+ }
+
+ private static final class AddressSelectHandler
+ implements PromptHandler<AutocompleteRequest<AddressSelectOption>> {
+ @Override
+ public AutocompleteRequest<AddressSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSelectOption[] options =
+ new Autocomplete.AddressSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.AddressSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSelect(session, prompt);
+ }
+ }
+
+ private static class PromptHandlers {
+ final Map<String, PromptHandler<?>> mPromptHandlers = new HashMap<>();
+
+ public void register(final PromptHandler<?> handler, final String type) {
+ mPromptHandlers.put(type, handler);
+ }
+
+ public PromptHandler<?> handlerFor(final String type) {
+ return mPromptHandlers.get(type);
+ }
+ }
+
+ private static final PromptHandlers sPromptHandlers = new PromptHandlers();
+
+ static {
+ sPromptHandlers.register(new AlertHandler(), "alert");
+ sPromptHandlers.register(new BeforeUnloadHandler(), "beforeUnload");
+ sPromptHandlers.register(new ButtonHandler(), "button");
+ sPromptHandlers.register(new TextHandler(), "text");
+ sPromptHandlers.register(new AuthHandler(), "auth");
+ sPromptHandlers.register(new ChoiceHandler(), "choice");
+ sPromptHandlers.register(new ColorHandler(), "color");
+ sPromptHandlers.register(new DateTimeHandler(), "datetime");
+ sPromptHandlers.register(new FileHandler(), "file");
+ sPromptHandlers.register(new PopupHandler(), "popup");
+ sPromptHandlers.register(new RepostHandler(), "repost");
+ sPromptHandlers.register(new ShareHandler(), "share");
+ sPromptHandlers.register(new LoginSaveHandler(), "Autocomplete:Save:Login");
+ sPromptHandlers.register(new CreditCardSaveHandler(), "Autocomplete:Save:CreditCard");
+ sPromptHandlers.register(new AddressSaveHandler(), "Autocomplete:Save:Address");
+ sPromptHandlers.register(new LoginSelectHandler(), "Autocomplete:Select:Login");
+ sPromptHandlers.register(new CreditCardSelectHandler(), "Autocomplete:Select:CreditCard");
+ sPromptHandlers.register(new AddressSelectHandler(), "Autocomplete:Select:Address");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
new file mode 100644
index 0000000000..299dec95f1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
@@ -0,0 +1,266 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.ArrayMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * Base class for (nested) runtime settings.
+ *
+ * <p>Handles pref-based settings. Please extend this class when adding nested settings for
+ * GeckoRuntimeSettings.
+ */
+public abstract class RuntimeSettings implements Parcelable {
+ /**
+ * Base class for (nested) runtime settings builders.
+ *
+ * <p>Please extend this class when adding nested settings builders for GeckoRuntimeSettings.
+ */
+ public abstract static class Builder<Settings extends RuntimeSettings> {
+ private final Settings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mSettings = newSettings(null);
+ }
+
+ /**
+ * Finalize and return the settings.
+ *
+ * @return The constructed settings.
+ */
+ @AnyThread
+ public @NonNull Settings build() {
+ return newSettings(mSettings);
+ }
+
+ @AnyThread
+ protected @NonNull Settings getSettings() {
+ return mSettings;
+ }
+
+ /**
+ * Create a default or copy settings object.
+ *
+ * @param settings Settings object to copy, null for default settings.
+ * @return The constructed settings object.
+ */
+ @AnyThread
+ protected abstract @NonNull Settings newSettings(final @Nullable Settings settings);
+ }
+
+ /** Used to handle pref-based settings. */
+ /* package */ class Pref<T> {
+ public final String name;
+ public final T defaultValue;
+ private T mValue;
+ private boolean mIsSet;
+
+ public Pref(@NonNull final String name, final T defaultValue) {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ mValue = defaultValue;
+
+ RuntimeSettings.this.addPref(this);
+ }
+
+ public void set(final T newValue) {
+ mValue = newValue;
+ mIsSet = true;
+ }
+
+ public void commit(final T newValue) {
+ if (newValue.equals(mValue)) {
+ return;
+ }
+ set(newValue);
+ commit();
+ }
+
+ public void commit() {
+ final GeckoRuntime runtime = RuntimeSettings.this.getRuntime();
+ if (runtime == null) {
+ return;
+ }
+ final GeckoBundle prefs = new GeckoBundle(1);
+ addToBundle(prefs);
+ runtime.setDefaultPrefs(prefs);
+ }
+
+ public T get() {
+ return mValue;
+ }
+
+ public boolean isSet() {
+ return mIsSet;
+ }
+
+ public void reset() {
+ mValue = defaultValue;
+ mIsSet = false;
+ }
+
+ private void addToBundle(final GeckoBundle bundle) {
+ final T value = mIsSet ? mValue : defaultValue;
+ if (value instanceof String) {
+ bundle.putString(name, (String) value);
+ } else if (value instanceof Integer) {
+ bundle.putInt(name, (Integer) value);
+ } else if (value instanceof Boolean) {
+ bundle.putBoolean(name, (Boolean) value);
+ } else {
+ throw new UnsupportedOperationException("Unhandled pref type for " + name);
+ }
+ }
+ }
+
+ private RuntimeSettings mParent;
+ private final ArrayList<RuntimeSettings> mChildren;
+ private final ArrayList<Pref<?>> mPrefs;
+
+ protected RuntimeSettings() {
+ this(null /* parent */);
+ }
+
+ /**
+ * Create settings object.
+ *
+ * @param parent The parent settings, specify in case of nested settings.
+ */
+ protected RuntimeSettings(final @Nullable RuntimeSettings parent) {
+ mPrefs = new ArrayList<Pref<?>>();
+ mChildren = new ArrayList<RuntimeSettings>();
+
+ setParent(parent);
+ }
+
+ /**
+ * Update the prefs based on the provided settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ @AnyThread
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ if (mPrefs.size() != settings.mPrefs.size()) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+
+ for (int i = 0; i < mPrefs.size(); ++i) {
+ if (!mPrefs.get(i).name.equals(settings.mPrefs.get(i).name)) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+ if (!settings.mPrefs.get(i).isSet()) {
+ continue;
+ }
+ // We know it is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) mPrefs.get(i);
+ uncheckedPref.commit(settings.mPrefs.get(i).get());
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mParent != null) {
+ return mParent.getRuntime();
+ }
+ return null;
+ }
+
+ private void setParent(final @Nullable RuntimeSettings parent) {
+ mParent = parent;
+ if (mParent != null) {
+ mParent.addChild(this);
+ }
+ }
+
+ private void addChild(final @NonNull RuntimeSettings child) {
+ mChildren.add(child);
+ }
+
+ /* pacakge */ void addPref(final Pref<?> pref) {
+ mPrefs.add(pref);
+ }
+
+ /**
+ * Return a mapping of the prefs managed in this settings, including child settings.
+ *
+ * @return A key-value mapping of the prefs.
+ */
+ /* package */ @NonNull
+ Map<String, Object> getPrefsMap() {
+ final ArrayMap<String, Object> prefs = new ArrayMap<>();
+ forAllPrefs(pref -> prefs.put(pref.name, pref.get()));
+
+ return Collections.unmodifiableMap(prefs);
+ }
+
+ /**
+ * Iterates through all prefs in this RuntimeSettings instance and in all children, grandchildren,
+ * etc.
+ */
+ private void forAllPrefs(final GeckoResult.Consumer<Pref<?>> visitor) {
+ for (final RuntimeSettings child : mChildren) {
+ child.forAllPrefs(visitor);
+ }
+
+ for (final Pref<?> pref : mPrefs) {
+ visitor.accept(pref);
+ }
+ }
+
+ /**
+ * Reset the prefs managed by this settings and its children.
+ *
+ * <p>The actual prefs values are set via {@link #getPrefsMap} during initialization and via
+ * {@link Pref#commit} during runtime for individual prefs.
+ */
+ /* package */ void commitResetPrefs() {
+ final ArrayList<String> names = new ArrayList<String>();
+ forAllPrefs(pref -> names.add(pref.name));
+
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("names", names);
+ EventDispatcher.getInstance().dispatch("GeckoView:ResetUserPrefs", data);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ for (final Pref<?> pref : mPrefs) {
+ out.writeValue(pref.get());
+ }
+ }
+
+ @AnyThread
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ for (final Pref<?> pref : mPrefs) {
+ // We know this is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) pref;
+ uncheckedPref.commit(source.readValue(getClass().getClassLoader()));
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
new file mode 100644
index 0000000000..1fad0cb17e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
@@ -0,0 +1,171 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/** The telemetry API gives access to telemetry data of the Gecko runtime. */
+public final class RuntimeTelemetry {
+ protected RuntimeTelemetry() {}
+
+ /**
+ * The runtime telemetry metric object.
+ *
+ * @param <T> type of the underlying metric sample
+ */
+ public static class Metric<T> {
+ /** The runtime metric name. */
+ public final @NonNull String name;
+
+ /** The metric values. */
+ public final @NonNull T value;
+
+ /* package */ Metric(final String name, final T value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return "name: " + name + ", value: " + value;
+ }
+
+ // For testing
+ protected Metric() {
+ name = null;
+ value = null;
+ }
+ }
+
+ /** The Histogram telemetry metric object. */
+ public static class Histogram extends Metric<long[]> {
+ /** Whether or not this is a Categorical Histogram. */
+ public final boolean isCategorical;
+
+ /* package */ Histogram(final boolean isCategorical, final String name, final long[] value) {
+ super(name, value);
+ this.isCategorical = isCategorical;
+ }
+
+ // For testing
+ protected Histogram() {
+ super(null, null);
+ isCategorical = false;
+ }
+ }
+
+ /**
+ * The runtime telemetry delegate. Implement this if you want to receive runtime (Gecko) telemetry
+ * and attach it via {@link GeckoRuntimeSettings.Builder#telemetryDelegate}.
+ */
+ public interface Delegate {
+ /**
+ * A runtime telemetry histogram metric has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onHistogram(final @NonNull Histogram metric) {}
+
+ /**
+ * A runtime telemetry boolean scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onBooleanScalar(final @NonNull Metric<Boolean> metric) {}
+
+ /**
+ * A runtime telemetry long scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onLongScalar(final @NonNull Metric<Long> metric) {}
+
+ /**
+ * A runtime telemetry string scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onStringScalar(final @NonNull Metric<String> metric) {}
+ }
+
+ // The proxy connects to telemetry core and forwards telemetry events
+ // to the attached delegate.
+ /* package */ static final class Proxy extends JNIObject {
+ private final Delegate mDelegate;
+
+ public Proxy(final @NonNull Delegate delegate) {
+ mDelegate = delegate;
+ }
+
+ // Attach to current runtime.
+ // We might have different mechanics of attaching to specific runtimes
+ // in future, for which case we should split the delegate assignment in
+ // the setup phase from the attaching.
+ public void attach() {
+ if (GeckoThread.isRunning()) {
+ registerDelegateProxy(this);
+ } else {
+ GeckoThread.queueNativeCall(Proxy.class, "registerDelegateProxy", Proxy.class, this);
+ }
+ }
+
+ public @NonNull Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void registerDelegateProxy(Proxy proxy);
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchHistogram(
+ final boolean isCategorical, final String name, final long[] values) {
+ if (mDelegate == null) {
+ // TODO throw?
+ return;
+ }
+ mDelegate.onHistogram(new Histogram(isCategorical, name, values));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchStringScalar(final String name, final String value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onStringScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchBooleanScalar(final String name, final boolean value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onBooleanScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchLongScalar(final String name, final long value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onLongScalar(new Metric<>(name, value));
+ }
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // We don't hold native references.
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
new file mode 100644
index 0000000000..b508fd13f9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
@@ -0,0 +1,160 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * ScreenLength is a class that represents a length on the screen using different units. The default
+ * unit is a pixel. However lengths may be also represented by a dimension of the visual viewport or
+ * of the full scroll size of the root document.
+ */
+public class ScreenLength {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PIXEL, VISUAL_VIEWPORT_WIDTH, VISUAL_VIEWPORT_HEIGHT, DOCUMENT_WIDTH, DOCUMENT_HEIGHT})
+ public @interface ScreenLengthType {}
+
+ /** Pixel units. */
+ public static final int PIXEL = 0;
+ /**
+ * Units are in visual viewport width. If the visual viewport is 100 pixels wide, then a value of
+ * 2.0 would represent a length of 200 pixels.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_WIDTH = 1;
+ /**
+ * Units are in visual viewport height. If the visual viewport is 100 pixels high, then a value of
+ * 2.0 would represent a length of 200 pixels.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_HEIGHT = 2;
+ /**
+ * Units represent the entire scrollable documents width. If the document is 1000 pixels wide then
+ * a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_WIDTH = 3;
+ /**
+ * Units represent the entire scrollable documents height. If the document is 1000 pixels tall
+ * then a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_HEIGHT = 4;
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}.
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength zero() {
+ return new ScreenLength(0.0, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}. Can be used to scroll to
+ * the top of a page when used with PanZoomController.scrollTo()
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength top() {
+ return zero();
+ }
+
+ /**
+ * Create a ScreenLength of the documents height. Type is {@link #DOCUMENT_HEIGHT}. Can be used to
+ * scroll to the bottom of a page when used with {@link PanZoomController#scrollTo(ScreenLength,
+ * ScreenLength)}
+ *
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength bottom() {
+ return new ScreenLength(1.0, DOCUMENT_HEIGHT);
+ }
+
+ /**
+ * Create a ScreenLength of a specific length. Type is {@link #PIXEL}.
+ *
+ * @param value Pixel length.
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromPixels(final double value) {
+ return new ScreenLength(value, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_WIDTH}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the width of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports width.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportWidth(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_WIDTH);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_HEIGHT}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the height of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports height.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportHeight(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_HEIGHT);
+ }
+
+ private final double mValue;
+ @ScreenLengthType private final int mType;
+
+ /* package */ ScreenLength(final double value, @ScreenLengthType final int type) {
+ mValue = value;
+ mType = type;
+ }
+
+ /**
+ * Returns the scalar value used to calculate length. The units of the returned valued are defined
+ * by what is returned by {@link #getType()}
+ *
+ * @return Scalar value of the length.
+ */
+ @AnyThread
+ public double getValue() {
+ return mValue;
+ }
+
+ /**
+ * Returns the unit type of the length The length can be one of the following: {@link #PIXEL},
+ * {@link #VISUAL_VIEWPORT_WIDTH}, {@link #VISUAL_VIEWPORT_HEIGHT}, {@link #DOCUMENT_WIDTH},
+ * {@link #DOCUMENT_HEIGHT}
+ *
+ * @return Unit type of the length.
+ */
+ @AnyThread
+ @ScreenLengthType
+ public int getType() {
+ return mType;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
new file mode 100644
index 0000000000..6e1439bb0a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
@@ -0,0 +1,1331 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import android.text.InputType;
+import android.text.TextUtils;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewParent;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityNodeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo;
+import android.view.accessibility.AccessibilityNodeInfo.RangeInfo;
+import android.view.accessibility.AccessibilityNodeProvider;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.Iterator;
+import java.util.LinkedList;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public class SessionAccessibility {
+ private static final String LOGTAG = "GeckoAccessibility";
+
+ // This is the number BrailleBack uses to start indexing routing keys.
+ private static final int BRAILLE_CLICK_BASE_INDEX = -275000000;
+ private static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE =
+ "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE";
+
+ @WrapForJNI static final int FLAG_ACCESSIBILITY_FOCUSED = 0;
+ @WrapForJNI static final int FLAG_CHECKABLE = 1 << 1;
+ @WrapForJNI static final int FLAG_CHECKED = 1 << 2;
+ @WrapForJNI static final int FLAG_CLICKABLE = 1 << 3;
+ @WrapForJNI static final int FLAG_CONTENT_INVALID = 1 << 4;
+ @WrapForJNI static final int FLAG_CONTEXT_CLICKABLE = 1 << 5;
+ @WrapForJNI static final int FLAG_EDITABLE = 1 << 6;
+ @WrapForJNI static final int FLAG_ENABLED = 1 << 7;
+ @WrapForJNI static final int FLAG_FOCUSABLE = 1 << 8;
+ @WrapForJNI static final int FLAG_FOCUSED = 1 << 9;
+ @WrapForJNI static final int FLAG_LONG_CLICKABLE = 1 << 10;
+ @WrapForJNI static final int FLAG_MULTI_LINE = 1 << 11;
+ @WrapForJNI static final int FLAG_PASSWORD = 1 << 12;
+ @WrapForJNI static final int FLAG_SCROLLABLE = 1 << 13;
+ @WrapForJNI static final int FLAG_SELECTED = 1 << 14;
+ @WrapForJNI static final int FLAG_VISIBLE_TO_USER = 1 << 15;
+ @WrapForJNI static final int FLAG_SELECTABLE = 1 << 16;
+ @WrapForJNI static final int FLAG_EXPANDABLE = 1 << 17;
+ @WrapForJNI static final int FLAG_EXPANDED = 1 << 18;
+
+ static final int CLASSNAME_UNKNOWN = -1;
+ @WrapForJNI static final int CLASSNAME_VIEW = 0;
+ @WrapForJNI static final int CLASSNAME_BUTTON = 1;
+ @WrapForJNI static final int CLASSNAME_CHECKBOX = 2;
+ @WrapForJNI static final int CLASSNAME_DIALOG = 3;
+ @WrapForJNI static final int CLASSNAME_EDITTEXT = 4;
+ @WrapForJNI static final int CLASSNAME_GRIDVIEW = 5;
+ @WrapForJNI static final int CLASSNAME_IMAGE = 6;
+ @WrapForJNI static final int CLASSNAME_LISTVIEW = 7;
+ @WrapForJNI static final int CLASSNAME_MENUITEM = 8;
+ @WrapForJNI static final int CLASSNAME_PROGRESSBAR = 9;
+ @WrapForJNI static final int CLASSNAME_RADIOBUTTON = 10;
+ @WrapForJNI static final int CLASSNAME_SEEKBAR = 11;
+ @WrapForJNI static final int CLASSNAME_SPINNER = 12;
+ @WrapForJNI static final int CLASSNAME_TABWIDGET = 13;
+ @WrapForJNI static final int CLASSNAME_TOGGLEBUTTON = 14;
+ @WrapForJNI static final int CLASSNAME_WEBVIEW = 15;
+
+ private static final String[] CLASSNAMES = {
+ "android.view.View",
+ "android.widget.Button",
+ "android.widget.CheckBox",
+ "android.app.Dialog",
+ "android.widget.EditText",
+ "android.widget.GridView",
+ "android.widget.Image",
+ "android.widget.ListView",
+ "android.view.MenuItem",
+ "android.widget.ProgressBar",
+ "android.widget.RadioButton",
+ "android.widget.SeekBar",
+ "android.widget.Spinner",
+ "android.widget.TabWidget",
+ "android.widget.ToggleButton",
+ "android.webkit.WebView"
+ };
+
+ @WrapForJNI static final int HTML_GRANULARITY_DEFAULT = -1;
+ @WrapForJNI static final int HTML_GRANULARITY_ARTICLE = 0;
+ @WrapForJNI static final int HTML_GRANULARITY_BUTTON = 1;
+ @WrapForJNI static final int HTML_GRANULARITY_CHECKBOX = 2;
+ @WrapForJNI static final int HTML_GRANULARITY_COMBOBOX = 3;
+ @WrapForJNI static final int HTML_GRANULARITY_CONTROL = 4;
+ @WrapForJNI static final int HTML_GRANULARITY_FOCUSABLE = 5;
+ @WrapForJNI static final int HTML_GRANULARITY_FRAME = 6;
+ @WrapForJNI static final int HTML_GRANULARITY_GRAPHIC = 7;
+ @WrapForJNI static final int HTML_GRANULARITY_H1 = 8;
+ @WrapForJNI static final int HTML_GRANULARITY_H2 = 9;
+ @WrapForJNI static final int HTML_GRANULARITY_H3 = 10;
+ @WrapForJNI static final int HTML_GRANULARITY_H4 = 11;
+ @WrapForJNI static final int HTML_GRANULARITY_H5 = 12;
+ @WrapForJNI static final int HTML_GRANULARITY_H6 = 13;
+ @WrapForJNI static final int HTML_GRANULARITY_HEADING = 14;
+ @WrapForJNI static final int HTML_GRANULARITY_LANDMARK = 15;
+ @WrapForJNI static final int HTML_GRANULARITY_LINK = 16;
+ @WrapForJNI static final int HTML_GRANULARITY_LIST = 17;
+ @WrapForJNI static final int HTML_GRANULARITY_LIST_ITEM = 18;
+ @WrapForJNI static final int HTML_GRANULARITY_MAIN = 19;
+ @WrapForJNI static final int HTML_GRANULARITY_MEDIA = 20;
+ @WrapForJNI static final int HTML_GRANULARITY_RADIO = 21;
+ @WrapForJNI static final int HTML_GRANULARITY_SECTION = 22;
+ @WrapForJNI static final int HTML_GRANULARITY_TABLE = 23;
+ @WrapForJNI static final int HTML_GRANULARITY_TEXT_FIELD = 24;
+ @WrapForJNI static final int HTML_GRANULARITY_UNVISITED_LINK = 25;
+ @WrapForJNI static final int HTML_GRANULARITY_VISITED_LINK = 26;
+
+ private static String[] sHtmlGranularities = {
+ "ARTICLE",
+ "BUTTON",
+ "CHECKBOX",
+ "COMBOBOX",
+ "CONTROL",
+ "FOCUSABLE",
+ "FRAME",
+ "GRAPHIC",
+ "H1",
+ "H2",
+ "H3",
+ "H4",
+ "H5",
+ "H6",
+ "HEADING",
+ "LANDMARK",
+ "LINK",
+ "LIST",
+ "LIST_ITEM",
+ "MAIN",
+ "MEDIA",
+ "RADIO",
+ "SECTION",
+ "TABLE",
+ "TEXT_FIELD",
+ "UNVISITED_LINK",
+ "VISITED_LINK"
+ };
+
+ private static String getClassName(final int index) {
+ if (index >= 0 && index < CLASSNAMES.length) {
+ return CLASSNAMES[index];
+ }
+
+ Log.e(LOGTAG, "Index " + index + " our of CLASSNAME bounds.");
+ return "android.view.View"; // Fallback class is View
+ }
+
+ /* package */ final class NodeProvider extends AccessibilityNodeProvider {
+ @Override
+ public AccessibilityNodeInfo createAccessibilityNodeInfo(final int virtualDescendantId) {
+ AccessibilityNodeInfo node = null;
+ if (mAttached) {
+ node =
+ mSession.getSettings().getFullAccessibilityTree() || isCacheEnabled()
+ ? getNodeFromGecko(virtualDescendantId)
+ : getNodeFromCache(virtualDescendantId);
+ }
+
+ if (node == null) {
+ Log.w(
+ LOGTAG,
+ "Failed to retrieve accessible node virtualDescendantId="
+ + virtualDescendantId
+ + " mAttached="
+ + mAttached);
+ node = AccessibilityNodeInfo.obtain(mView, View.NO_ID);
+ if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) {
+ // When running junit tests we don't have a display
+ mView.onInitializeAccessibilityNodeInfo(node);
+ }
+ node.setClassName("android.webkit.WebView");
+ }
+
+ return node;
+ }
+
+ @Override
+ public boolean performAction(
+ final int virtualViewId, final int action, final Bundle arguments) {
+ final GeckoBundle data;
+
+ switch (action) {
+ case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED,
+ virtualViewId,
+ CLASSNAME_UNKNOWN,
+ null);
+ return true;
+ case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS:
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
+ virtualViewId,
+ virtualViewId == View.NO_ID ? CLASSNAME_WEBVIEW : CLASSNAME_UNKNOWN,
+ null);
+ return true;
+ case AccessibilityNodeInfo.ACTION_CLICK:
+ case AccessibilityNodeInfo.ACTION_EXPAND:
+ case AccessibilityNodeInfo.ACTION_COLLAPSE:
+ nativeProvider.click(virtualViewId);
+ final GeckoBundle nodeInfo = getMostRecentBundle(virtualViewId);
+ if (nodeInfo != null) {
+ if ((nodeInfo.getInt("flags") & (FLAG_SELECTABLE | FLAG_CHECKABLE | FLAG_EXPANDABLE))
+ == 0) {
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_CLICKED,
+ virtualViewId,
+ nodeInfo.getInt("className"),
+ null);
+ }
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_LONG_CLICK:
+ // XXX: Implement long press.
+ return true;
+ case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD:
+ if (virtualViewId == View.NO_ID) {
+ // Scroll the viewport forwards by approximately 80%.
+ mSession
+ .getPanZoomController()
+ .scrollBy(
+ ScreenLength.zero(),
+ ScreenLength.fromVisualViewportHeight(0.8),
+ PanZoomController.SCROLL_BEHAVIOR_AUTO);
+ } else {
+ // XXX: It looks like we never call scroll on virtual views.
+ // If we did, we should synthesize a wheel event on it's center coordinate.
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD:
+ if (virtualViewId == View.NO_ID) {
+ // Scroll the viewport backwards by approximately 80%.
+ mSession
+ .getPanZoomController()
+ .scrollBy(
+ ScreenLength.zero(),
+ ScreenLength.fromVisualViewportHeight(-0.8),
+ PanZoomController.SCROLL_BEHAVIOR_AUTO);
+ } else {
+ // XXX: It looks like we never call scroll on virtual views.
+ // If we did, we should synthesize a wheel event on it's center coordinate.
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SELECT:
+ nativeProvider.click(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT:
+ requestViewFocus();
+ return pivot(
+ virtualViewId,
+ arguments != null
+ ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
+ : "",
+ true,
+ false);
+ case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT:
+ requestViewFocus();
+ return pivot(
+ virtualViewId,
+ arguments != null
+ ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING)
+ : "",
+ false,
+ false);
+ case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY:
+ case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY:
+ // XXX: Self brailling gives this action with a bogus argument instead of an actual click
+ // action;
+ // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that
+ // was hit.
+ // Other negative values are used by ChromeVox, but we don't support them.
+ // FAKE_GRANULARITY_READ_CURRENT = -1
+ // FAKE_GRANULARITY_READ_TITLE = -2
+ // FAKE_GRANULARITY_STOP_SPEECH = -3
+ // FAKE_GRANULARITY_CHANGE_SHIFTER = -4
+ if (arguments == null) {
+ return false;
+ }
+ final int granularity =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT);
+ if (granularity <= BRAILLE_CLICK_BASE_INDEX) {
+ // XXX: Use click offset to update caret position in editables (BRAILLE_CLICK_BASE_INDEX
+ // - granularity).
+ nativeProvider.click(virtualViewId);
+ } else if (granularity > 0) {
+ final boolean extendSelection =
+ arguments.getBoolean(
+ AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN);
+ final boolean next =
+ action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY;
+ // We must return false if we're already at the edge.
+ if (next) {
+ if (mAtEndOfText) {
+ return false;
+ }
+ if (granularity == AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD && mAtLastWord) {
+ return false;
+ }
+ } else if (mAtStartOfText) {
+ return false;
+ }
+ nativeProvider.navigateText(
+ virtualViewId, granularity, mStartOffset, mEndOffset, next, extendSelection);
+ }
+ return true;
+ case AccessibilityNodeInfo.ACTION_SET_SELECTION:
+ if (arguments == null) {
+ return false;
+ }
+ final int selectionStart =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT);
+ final int selectionEnd =
+ arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT);
+ nativeProvider.setSelection(virtualViewId, selectionStart, selectionEnd);
+ return true;
+ case AccessibilityNodeInfo.ACTION_CUT:
+ nativeProvider.cut(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_COPY:
+ nativeProvider.copy(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_PASTE:
+ nativeProvider.paste(virtualViewId);
+ return true;
+ case AccessibilityNodeInfo.ACTION_SET_TEXT:
+ if (arguments == null) {
+ return false;
+ }
+ final String value =
+ arguments.getString(
+ Build.VERSION.SDK_INT >= 21
+ ? AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
+ : ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
+ if (mAttached) {
+ nativeProvider.setText(virtualViewId, value);
+ }
+ return true;
+ }
+
+ return mView.performAccessibilityAction(action, arguments);
+ }
+
+ @Override
+ public AccessibilityNodeInfo findFocus(final int focus) {
+ switch (focus) {
+ case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY:
+ if (mAccessibilityFocusedNode != 0) {
+ return createAccessibilityNodeInfo(mAccessibilityFocusedNode);
+ }
+ break;
+ case AccessibilityNodeInfo.FOCUS_INPUT:
+ if (mFocusedNode != 0) {
+ return createAccessibilityNodeInfo(mFocusedNode);
+ }
+ break;
+ }
+
+ return super.findFocus(focus);
+ }
+
+ private AccessibilityNodeInfo getNodeFromGecko(final int virtualViewId) {
+ ThreadUtils.assertOnUiThread();
+ final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId);
+ nativeProvider.getNodeInfo(virtualViewId, node);
+
+ // We set the bounds in parent here because we need to use the client-to-screen matrix
+ // and it is only available in the UI thread.
+ final Rect bounds = new Rect();
+ node.getBoundsInParent(bounds);
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenMatrix(matrix);
+ final float[] origin = new float[2];
+ matrix.mapPoints(origin);
+ bounds.offset((int) origin[0], (int) origin[1]);
+ node.setBoundsInScreen(bounds);
+
+ return node;
+ }
+
+ private AccessibilityNodeInfo getNodeFromCache(final int virtualViewId) {
+ synchronized (SessionAccessibility.this) {
+ AccessibilityNodeInfo node = null;
+ for (final SparseArray<GeckoBundle> cache : mCaches) {
+ final GeckoBundle bundle = cache.get(virtualViewId);
+ if (bundle == null) {
+ continue;
+ }
+
+ if (node == null) {
+ node = AccessibilityNodeInfo.obtain(mView, virtualViewId);
+ }
+ populateNodeFromBundle(node, bundle, true);
+ }
+
+ if (node == null) {
+ Log.e(LOGTAG, "No cached node for " + virtualViewId);
+ }
+
+ return node;
+ }
+ }
+
+ private void populateNodeFromBundle(
+ final AccessibilityNodeInfo node, final GeckoBundle nodeInfo, final boolean fromCache) {
+ if (mView == null || nodeInfo == null) {
+ return;
+ }
+
+ final int id = nodeInfo.getInt("id");
+ final boolean isRoot = id == View.NO_ID;
+ if (isRoot) {
+ if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) {
+ // When running junit tests we don't have a display
+ mView.onInitializeAccessibilityNodeInfo(node);
+ }
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ } else {
+ node.setParent(mView, nodeInfo.getInt("parentId", View.NO_ID));
+ }
+
+ final int flags = nodeInfo.getInt("flags");
+
+ // The basics
+ node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
+ node.setClassName(getClassName(nodeInfo.getInt("className")));
+
+ if (nodeInfo.containsKey("text")) {
+ node.setText(nodeInfo.getString("text"));
+ }
+
+ if (nodeInfo.containsKey("description")) {
+ node.setContentDescription(nodeInfo.getString("description"));
+ }
+
+ // Add actions
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
+ node.setMovementGranularities(
+ AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH);
+ if ((flags & FLAG_CLICKABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+ }
+
+ // Set boolean properties
+ node.setCheckable((flags & FLAG_CHECKABLE) != 0);
+ node.setChecked((flags & FLAG_CHECKED) != 0);
+ node.setClickable((flags & FLAG_CLICKABLE) != 0);
+ node.setEnabled((flags & FLAG_ENABLED) != 0);
+ node.setFocusable((flags & FLAG_FOCUSABLE) != 0);
+ node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0);
+ node.setPassword((flags & FLAG_PASSWORD) != 0);
+ node.setScrollable((flags & FLAG_SCROLLABLE) != 0);
+ node.setSelected((flags & FLAG_SELECTED) != 0);
+ node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0);
+ // Other boolean properties to consider later:
+ // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText,
+ // setDismissable
+
+ if (mAccessibilityFocusedNode == id) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+ node.setAccessibilityFocused(true);
+ } else {
+ node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
+ }
+ node.setFocused(mFocusedNode == id);
+
+ // Bounds
+ final int[] b = nodeInfo.getIntArray("bounds");
+ if (b != null) {
+ final Rect screenBounds = new Rect(b[0], b[1], b[2], b[3]);
+ node.setBoundsInScreen(screenBounds);
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenMatrix(matrix);
+ final float[] origin = new float[2];
+ matrix.mapPoints(origin);
+ final Rect parentBounds =
+ new Rect(b[0] - (int) origin[0], b[1] - (int) origin[1], b[2], b[3]);
+ node.setBoundsInParent(parentBounds);
+ }
+
+ // Children
+ final int[] children = nodeInfo.getIntArray("children");
+ if (node.getChildCount() == 0 && children != null) {
+ for (final int childId : children) {
+ final GeckoBundle childBundle = getMostRecentBundle(childId);
+ if (!fromCache || (childBundle != null && childBundle.getInt("parentId") == id)) {
+ // If this node is from cache, only populate with children that are cached as well.
+ node.addChild(mView, childId);
+ }
+ }
+ }
+
+ // SDK 18 and above
+ if (Build.VERSION.SDK_INT >= 18) {
+ node.setViewIdResourceName(nodeInfo.getString("viewIdResourceName"));
+
+ if ((flags & FLAG_EDITABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
+ node.addAction(AccessibilityNodeInfo.ACTION_CUT);
+ node.addAction(AccessibilityNodeInfo.ACTION_COPY);
+ node.addAction(AccessibilityNodeInfo.ACTION_PASTE);
+ node.setEditable(true);
+ }
+ }
+
+ // SDK 19 and above
+ if (Build.VERSION.SDK_INT >= 19) {
+ node.setMultiLine((flags & FLAG_MULTI_LINE) != 0);
+ node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0);
+
+ // Set bundle keys like role and hint
+ final Bundle bundle = node.getExtras();
+ if (nodeInfo.containsKey("hint")) {
+ final String hint = nodeInfo.getString("hint");
+ bundle.putCharSequence("AccessibilityNodeInfo.hint", hint);
+ if (Build.VERSION.SDK_INT >= 26) {
+ node.setHintText(hint);
+ }
+ }
+ if (nodeInfo.containsKey("geckoRole")) {
+ bundle.putCharSequence(
+ "AccessibilityNodeInfo.geckoRole", nodeInfo.getString("geckoRole"));
+ }
+ if (nodeInfo.containsKey("roleDescription")) {
+ bundle.putCharSequence(
+ "AccessibilityNodeInfo.roleDescription", nodeInfo.getString("roleDescription"));
+ }
+ if (isRoot) {
+ // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT.
+ // This is mostly here to let TalkBack know we are a legit "WebView".
+ bundle.putCharSequence(
+ "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES",
+ TextUtils.join(",", sHtmlGranularities));
+ }
+
+ // Set RangeInfo
+ final GeckoBundle rangeBundle = nodeInfo.getBundle("rangeInfo");
+ if (rangeBundle != null) {
+ final RangeInfo rangeInfo =
+ RangeInfo.obtain(
+ rangeBundle.getInt("type"),
+ (float) rangeBundle.getDouble("min", Float.NEGATIVE_INFINITY),
+ (float) rangeBundle.getDouble("max", Float.POSITIVE_INFINITY),
+ (float) rangeBundle.getDouble("current", 0));
+ node.setRangeInfo(rangeInfo);
+ }
+
+ // Set CollectionItemInfo
+ final GeckoBundle collectionItemBundle = nodeInfo.getBundle("collectionItemInfo");
+ if (collectionItemBundle != null) {
+ final CollectionItemInfo collectionItemInfo =
+ CollectionItemInfo.obtain(
+ collectionItemBundle.getInt("rowIndex"),
+ collectionItemBundle.getInt("rowSpan"),
+ collectionItemBundle.getInt("columnIndex"),
+ collectionItemBundle.getInt("columnSpan"),
+ false);
+ node.setCollectionItemInfo(collectionItemInfo);
+ }
+
+ // Set CollectionInfo
+ final GeckoBundle collectionBundle = nodeInfo.getBundle("collectionInfo");
+ if (collectionBundle != null) {
+ // selectionMode is only supported in SDK >= 21.
+ final CollectionInfo collectionInfo =
+ Build.VERSION.SDK_INT >= 21
+ ? CollectionInfo.obtain(
+ collectionBundle.getInt("rowCount"),
+ collectionBundle.getInt("columnCount"),
+ collectionBundle.getBoolean("isHierarchical", false),
+ collectionBundle.getInt("selectionMode", 0))
+ : CollectionInfo.obtain(
+ collectionBundle.getInt("rowCount"),
+ collectionBundle.getInt("columnCount"),
+ collectionBundle.getBoolean("isHierarchical", false));
+ node.setCollectionInfo(collectionInfo);
+ }
+
+ node.setInputType(nodeInfo.getInt("inputType"));
+ }
+
+ // SDK 21 and above
+ if (Build.VERSION.SDK_INT >= 21) {
+ if ((flags & FLAG_EXPANDABLE) != 0) {
+ if ((flags & FLAG_EXPANDED) != 0) {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ } else {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ }
+ }
+ }
+
+ // SDK 23 and above
+ if (Build.VERSION.SDK_INT >= 23) {
+ node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0);
+ }
+ }
+ }
+
+ // Gecko session we are proxying
+ /* package */ final GeckoSession mSession;
+ // This is the view that delegates accessibility to us. We also sends event through it.
+ private View mView;
+ // The native portion of the node provider.
+ /* package */ final NativeProvider nativeProvider = new NativeProvider();
+ private boolean mAttached = false;
+ // The current node with accessibility focus
+ private int mAccessibilityFocusedNode = 0;
+ // The first accessibility focusable node
+ private int mFirstAccessibilityFocusable = 0;
+ // The last accessibility focusable node
+ private int mLastAccessibilityFocusable = 0;
+ // The current node with focus
+ private int mFocusedNode = 0;
+ private int mStartOffset = -1;
+ private int mEndOffset = -1;
+ private boolean mAtStartOfText = false;
+ private boolean mAtEndOfText = false;
+ private boolean mAtLastWord = false;
+ // Viewport cache
+ final SparseArray<GeckoBundle> mViewportCache = new SparseArray<>();
+ // Focus cache
+ final SparseArray<GeckoBundle> mFocusPathCache = new SparseArray<>();
+ // List of caches in descending order from last updated.
+ LinkedList<SparseArray<GeckoBundle>> mCaches = new LinkedList<>();
+ private boolean mViewFocusRequested = false;
+
+ /* package */ SessionAccessibility(final GeckoSession session) {
+ mSession = session;
+ Settings.updateAccessibilitySettings();
+ }
+
+ /* package */ static void setForceEnabled(final boolean forceEnabled) {
+ Settings.setForceEnabled(forceEnabled);
+ }
+
+ /**
+ * Get the View instance that delegates accessibility to this session.
+ *
+ * @return View instance.
+ */
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+
+ return mView;
+ }
+
+ /**
+ * Set the View instance that should delegate accessibility to this session.
+ *
+ * @param view View instance.
+ */
+ @UiThread
+ public void setView(final @Nullable View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mView != null) {
+ mView.setAccessibilityDelegate(null);
+ }
+
+ mView = view;
+
+ if (mView == null) {
+ return;
+ }
+
+ mView.setAccessibilityDelegate(
+ new View.AccessibilityDelegate() {
+ private NodeProvider mProvider;
+
+ @Override
+ public AccessibilityNodeProvider getAccessibilityNodeProvider(final View hostView) {
+ if (hostView != mView) {
+ return null;
+ }
+ if (mProvider == null) {
+ mProvider = new NodeProvider();
+ }
+ return mProvider;
+ }
+
+ @Override
+ public void sendAccessibilityEvent(final View host, final int eventType) {
+ if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) {
+ // We rely on the focus events sent from Gecko.
+ return;
+ }
+
+ super.sendAccessibilityEvent(host, eventType);
+ }
+ });
+ }
+
+ private boolean isInTest() {
+ return Build.VERSION.SDK_INT >= 17 && mView != null && mView.getDisplay() == null;
+ }
+
+ private void requestViewFocus() {
+ if (!mView.isFocused() && !isInTest()) {
+ mViewFocusRequested = true;
+ mView.requestFocus();
+ }
+ }
+
+ private static class Settings {
+ private static volatile boolean sEnabled;
+ private static volatile boolean sTouchExplorationEnabled;
+ private static volatile boolean sForceEnabled;
+
+ public static void setForceEnabled(final boolean forceEnabled) {
+ sForceEnabled = forceEnabled;
+ dispatch();
+ }
+
+ static {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final AccessibilityManager accessibilityManager =
+ (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+ accessibilityManager.addAccessibilityStateChangeListener(
+ enabled -> updateAccessibilitySettings());
+
+ if (Build.VERSION.SDK_INT >= 19) {
+ accessibilityManager.addTouchExplorationStateChangeListener(
+ enabled -> updateAccessibilitySettings());
+ }
+ }
+
+ public static boolean isEnabled() {
+ return sEnabled || sForceEnabled;
+ }
+
+ public static boolean isTouchExplorationEnabled() {
+ return sTouchExplorationEnabled || sForceEnabled;
+ }
+
+ public static void updateAccessibilitySettings() {
+ final AccessibilityManager accessibilityManager =
+ (AccessibilityManager)
+ GeckoAppShell.getApplicationContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
+ sEnabled = accessibilityManager.isEnabled();
+ sTouchExplorationEnabled = sEnabled && accessibilityManager.isTouchExplorationEnabled();
+ dispatch();
+ }
+
+ /* package */ static void dispatch() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ toggleNativeAccessibility(isEnabled());
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ Settings.class,
+ "toggleNativeAccessibility",
+ isEnabled());
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void toggleNativeAccessibility(boolean enable);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public boolean onMotionEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!Settings.isTouchExplorationEnabled()) {
+ return false;
+ }
+
+ if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) {
+ return false;
+ }
+
+ final int action = event.getActionMasked();
+ if ((action != MotionEvent.ACTION_HOVER_MOVE)
+ && (action != MotionEvent.ACTION_HOVER_ENTER)
+ && (action != MotionEvent.ACTION_HOVER_EXIT)) {
+ return false;
+ }
+
+ requestViewFocus();
+
+ nativeProvider.exploreByTouch(
+ mAccessibilityFocusedNode != 0 ? mAccessibilityFocusedNode : View.NO_ID,
+ event.getX(),
+ event.getY());
+
+ return true;
+ }
+
+ /* package */ void sendEvent(
+ final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
+ ThreadUtils.assertOnUiThread();
+ if (mView == null) {
+ return;
+ }
+
+ if (mViewFocusRequested && className == CLASSNAME_WEBVIEW) {
+ // If the view was focused from an accessiblity action or
+ // explore-by-touch, we supress this focus event to avoid noise.
+ mViewFocusRequested = false;
+ return;
+ }
+
+ final GeckoBundle cachedBundle = getMostRecentBundle(sourceId);
+ if (cachedBundle == null && sourceId != View.NO_ID && !isCacheEnabled()) {
+ // Suppress events from non cached nodes.
+ return;
+ }
+
+ final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+ event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
+ event.setSource(mView, sourceId);
+ event.setEnabled(true);
+
+ int eventClassName = className;
+ if (eventClassName == CLASSNAME_UNKNOWN) {
+ if (cachedBundle != null) {
+ eventClassName = cachedBundle.getInt("className");
+ } else if (isCacheEnabled()) {
+ eventClassName = nativeProvider.getNodeClassName(sourceId);
+ }
+ }
+ event.setClassName(getClassName(eventClassName));
+
+ if (eventData != null) {
+ if (eventData.containsKey("text")) {
+ event.getText().add(eventData.getString("text"));
+ }
+ event.setContentDescription(eventData.getString("description", ""));
+ event.setAddedCount(eventData.getInt("addedCount", -1));
+ event.setRemovedCount(eventData.getInt("removedCount", -1));
+ event.setFromIndex(eventData.getInt("fromIndex", -1));
+ event.setItemCount(eventData.getInt("itemCount", -1));
+ event.setCurrentItemIndex(eventData.getInt("currentItemIndex", -1));
+ event.setBeforeText(eventData.getString("beforeText", ""));
+ event.setToIndex(eventData.getInt("toIndex", -1));
+ event.setScrollX(eventData.getInt("scrollX", -1));
+ event.setScrollY(eventData.getInt("scrollY", -1));
+ event.setMaxScrollX(eventData.getInt("maxScrollX", -1));
+ event.setMaxScrollY(eventData.getInt("maxScrollY", -1));
+ event.setChecked((eventData.getInt("flags") & FLAG_CHECKED) != 0);
+ }
+
+ // Update cache and stored state from this event.
+ switch (eventType) {
+ case AccessibilityEvent.TYPE_VIEW_CLICKED:
+ if (cachedBundle != null && eventData != null && eventData.containsKey("flags")) {
+ final int flags = eventData.getInt("flags");
+ if ((flags & FLAG_CHECKABLE) != 0) {
+ if ((flags & FLAG_CHECKED) != 0) {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_CHECKED);
+ } else {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_CHECKED);
+ }
+ }
+
+ if ((flags & FLAG_EXPANDABLE) != 0) {
+ if ((flags & FLAG_EXPANDED) != 0) {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_EXPANDED);
+ } else {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_EXPANDED);
+ }
+ }
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_SELECTED:
+ if (cachedBundle != null && eventData != null && eventData.containsKey("selected")) {
+ if (eventData.getInt("selected") != 0) {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_SELECTED);
+ } else {
+ cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_SELECTED);
+ }
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED:
+ if (mAccessibilityFocusedNode == sourceId) {
+ mAccessibilityFocusedNode = 0;
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED:
+ mStartOffset = -1;
+ mEndOffset = -1;
+ mAtStartOfText = false;
+ mAtEndOfText = false;
+ mAtLastWord = false;
+ mAccessibilityFocusedNode = sourceId;
+ break;
+ case AccessibilityEvent.TYPE_VIEW_FOCUSED:
+ mFocusedNode = sourceId;
+ if (!mView.isFocused() && !isInTest()) {
+ // Don't dispatch a focus event if the parent view is not focused
+ return;
+ }
+ break;
+ case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY:
+ mStartOffset = event.getFromIndex();
+ mEndOffset = event.getToIndex();
+ // We must synchronously return false for text navigation
+ // actions if the user attempts to navigate past the edge.
+ // Because we do navigation async, we can't query this
+ // on demand when the action is performed. Therefore, we cache
+ // whether we're at either edge here.
+ mAtStartOfText = mStartOffset == 0;
+ final CharSequence text = event.getText().get(0);
+ mAtEndOfText = mEndOffset >= text.length();
+ mAtLastWord = mAtEndOfText;
+ if (!mAtLastWord) {
+ // Words exclude trailing spaces. To figure out whether
+ // we're at the last word, we need to get the text after
+ // our end offset and check if it's just spaces.
+ final CharSequence afterText = text.subSequence(mEndOffset, text.length());
+ if (TextUtils.getTrimmedLength(afterText) == 0) {
+ mAtLastWord = true;
+ }
+ }
+ break;
+ }
+
+ try {
+ ((ViewParent) mView).requestSendAccessibilityEvent(mView, event);
+ } catch (final IllegalStateException ex) {
+ // Accessibility could be activated in Gecko via xpcom, for example when using a11y
+ // devtools. Events that are forwarded to the platform will throw an exception.
+ }
+ }
+
+ private synchronized GeckoBundle getMostRecentBundle(final int virtualViewId) {
+ final Iterator<SparseArray<GeckoBundle>> iter = mCaches.descendingIterator();
+ while (iter.hasNext()) {
+ final GeckoBundle bundle = iter.next().get(virtualViewId);
+ if (bundle != null) {
+ return bundle;
+ }
+ }
+
+ return null;
+ }
+
+ private boolean pivot(
+ final int id, final String granularity, final boolean forward, final boolean inclusive) {
+ if (!forward && id == View.NO_ID) {
+ // If attempting to pivot backwards from the root view, return false.
+ return false;
+ }
+
+ if (isCacheEnabled()) {
+ return cachedPivot(id, granularity, forward, inclusive);
+ }
+
+ final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity);
+ if (forward && id == mLastAccessibilityFocusable) {
+ return false;
+ }
+
+ if (!forward) {
+ if (id == mFirstAccessibilityFocusable) {
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED,
+ View.NO_ID,
+ CLASSNAME_WEBVIEW,
+ null);
+ return true;
+ }
+ }
+
+ nativeProvider.pivotNative(id, gran, forward, inclusive);
+ return true;
+ }
+
+ private boolean cachedPivot(
+ final int id, final String granularity, final boolean forward, final boolean inclusive) {
+ final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity);
+ final boolean success = nativeProvider.cachedPivotNative(id, gran, forward, inclusive);
+ if (!success && !forward) {
+ // If we failed to pivot backwards set the root view as the a11y focus.
+ sendEvent(
+ AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, View.NO_ID, CLASSNAME_WEBVIEW, null);
+ return true;
+ }
+
+ return success;
+ }
+
+ private boolean isCacheEnabled() {
+ return mAttached && nativeProvider.isCacheEnabled();
+ }
+
+ /* package */ final class NativeProvider extends JNIObject {
+ @WrapForJNI(calledFrom = "ui")
+ private void setAttached(final boolean attached) {
+ mAttached = attached;
+ }
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(dispatchTo = "current")
+ public native boolean isCacheEnabled();
+
+ @WrapForJNI(dispatchTo = "current")
+ public native void getNodeInfo(int id, AccessibilityNodeInfo nodeInfo);
+
+ @WrapForJNI(dispatchTo = "current")
+ public native int getNodeClassName(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void setText(int id, String text);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void click(int id);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Pivot")
+ public native void pivotNative(int id, int granularity, boolean forward, boolean inclusive);
+
+ @WrapForJNI(dispatchTo = "current", stubName = "CachedPivot")
+ public native boolean cachedPivotNative(
+ int id, int granularity, boolean forward, boolean inclusive);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void exploreByTouch(int id, float x, float y);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void navigateText(
+ int id, int granularity, int startOffset, int endOffset, boolean forward, boolean select);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void setSelection(int id, int start, int end);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void cut(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void copy(int id);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void paste(int id);
+
+ @WrapForJNI(calledFrom = "gecko", stubName = "SendEvent")
+ private void sendEventNative(
+ final int eventType, final int sourceId, final int className, final GeckoBundle eventData) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendEvent(eventType, sourceId, className, eventData);
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void replaceViewportCache(final GeckoBundle[] bundles) {
+ synchronized (SessionAccessibility.this) {
+ mViewportCache.clear();
+ for (final GeckoBundle bundle : bundles) {
+ if (bundle == null) {
+ continue;
+ }
+ mViewportCache.append(bundle.getInt("id"), bundle);
+ }
+ mCaches.remove(mViewportCache);
+ mCaches.add(mViewportCache);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void replaceFocusPathCache(final GeckoBundle[] bundles) {
+ synchronized (SessionAccessibility.this) {
+ mFocusPathCache.clear();
+ for (final GeckoBundle bundle : bundles) {
+ if (bundle == null) {
+ continue;
+ }
+ mFocusPathCache.append(bundle.getInt("id"), bundle);
+ }
+ mCaches.remove(mFocusPathCache);
+ mCaches.add(mFocusPathCache);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void updateCachedBounds(final GeckoBundle[] bundles) {
+ synchronized (SessionAccessibility.this) {
+ for (final GeckoBundle bundle : bundles) {
+ final GeckoBundle cachedBundle = getMostRecentBundle(bundle.getInt("id"));
+ if (cachedBundle == null) {
+ Log.e(LOGTAG, "Can't update bounds of uncached node " + bundle.getInt("id"));
+ continue;
+ }
+ cachedBundle.putIntArray("bounds", bundle.getIntArray("bounds"));
+ }
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void updateAccessibleFocusBoundaries(final int firstNode, final int lastNode) {
+ synchronized (SessionAccessibility.this) {
+ mFirstAccessibilityFocusable = firstNode;
+ mLastAccessibilityFocusable = lastNode;
+ }
+ }
+
+ @WrapForJNI
+ private void populateNodeInfo(
+ final AccessibilityNodeInfo node,
+ final int id,
+ final int parentId,
+ final int[] children,
+ final int flags,
+ final int className,
+ final int[] bounds,
+ @Nullable final String text,
+ @Nullable final String description,
+ @Nullable final String hint,
+ @Nullable final String geckoRole,
+ @Nullable final String roleDescription,
+ @Nullable final String viewIdResourceName,
+ final int inputType) {
+ if (mView == null) {
+ return;
+ }
+
+ final boolean isRoot = id == View.NO_ID;
+ if (isRoot) {
+ if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) {
+ // When running junit tests we don't have a display
+ mView.onInitializeAccessibilityNodeInfo(node);
+ }
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
+ node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
+ } else {
+ node.setParent(mView, parentId);
+ }
+
+ // The basics
+ node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName());
+ node.setClassName(getClassName(className));
+
+ if (text != null) {
+ node.setText(text);
+ }
+
+ if (description != null) {
+ node.setContentDescription(description);
+ }
+
+ // Add actions
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT);
+ node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY);
+ node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY);
+ node.setMovementGranularities(
+ AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE
+ | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH);
+ if ((flags & FLAG_CLICKABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+ }
+
+ // Set boolean properties
+ node.setCheckable((flags & FLAG_CHECKABLE) != 0);
+ node.setChecked((flags & FLAG_CHECKED) != 0);
+ node.setClickable((flags & FLAG_CLICKABLE) != 0);
+ node.setEnabled((flags & FLAG_ENABLED) != 0);
+ node.setFocusable((flags & FLAG_FOCUSABLE) != 0);
+ node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0);
+ node.setPassword((flags & FLAG_PASSWORD) != 0);
+ node.setScrollable((flags & FLAG_SCROLLABLE) != 0);
+ node.setSelected((flags & FLAG_SELECTED) != 0);
+ node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0);
+ // Other boolean properties to consider later:
+ // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText,
+ // setDismissable
+
+ if (mAccessibilityFocusedNode == id) {
+ node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+ node.setAccessibilityFocused(true);
+ } else {
+ node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS);
+ }
+ node.setFocused(mFocusedNode == id);
+
+ final Rect parentBounds = new Rect(bounds[0], bounds[1], bounds[2], bounds[3]);
+ node.setBoundsInParent(parentBounds);
+
+ for (final int childId : children) {
+ node.addChild(mView, childId);
+ }
+
+ // SDK 18 and above
+ if (Build.VERSION.SDK_INT >= 18) {
+ node.setViewIdResourceName(viewIdResourceName);
+
+ if ((flags & FLAG_EDITABLE) != 0) {
+ node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION);
+ node.addAction(AccessibilityNodeInfo.ACTION_CUT);
+ node.addAction(AccessibilityNodeInfo.ACTION_COPY);
+ node.addAction(AccessibilityNodeInfo.ACTION_PASTE);
+ node.setEditable(true);
+ }
+ }
+
+ // SDK 19 and above
+ if (Build.VERSION.SDK_INT >= 19) {
+ node.setMultiLine((flags & FLAG_MULTI_LINE) != 0);
+ node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0);
+
+ // Set bundle keys like role and hint
+ final Bundle bundle = node.getExtras();
+ if (hint != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.hint", hint);
+ if (Build.VERSION.SDK_INT >= 26) {
+ node.setHintText(hint);
+ }
+ }
+ if (geckoRole != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.geckoRole", geckoRole);
+ }
+ if (roleDescription != null) {
+ bundle.putCharSequence("AccessibilityNodeInfo.roleDescription", roleDescription);
+ }
+ if (isRoot) {
+ // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT.
+ // This is mostly here to let TalkBack know we are a legit "WebView".
+ bundle.putCharSequence(
+ "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES",
+ TextUtils.join(",", sHtmlGranularities));
+ }
+
+ if (inputType != InputType.TYPE_NULL) {
+ node.setInputType(inputType);
+ }
+ }
+
+ // SDK 21 and above
+ if (Build.VERSION.SDK_INT >= 21) {
+ if ((flags & FLAG_EXPANDABLE) != 0) {
+ if ((flags & FLAG_EXPANDED) != 0) {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ } else {
+ node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE);
+ node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND);
+ }
+ }
+ }
+
+ // SDK 23 and above
+ if (Build.VERSION.SDK_INT >= 23) {
+ node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0);
+ }
+ }
+
+ @WrapForJNI
+ private void populateNodeCollectionItemInfo(
+ final AccessibilityNodeInfo node,
+ final int rowIndex,
+ final int rowSpan,
+ final int columnIndex,
+ final int columnSpan) {
+ final CollectionItemInfo collectionItemInfo =
+ CollectionItemInfo.obtain(rowIndex, rowSpan, columnIndex, columnSpan, false);
+ node.setCollectionItemInfo(collectionItemInfo);
+ }
+
+ @WrapForJNI
+ private void populateNodeCollectionInfo(
+ final AccessibilityNodeInfo node,
+ final int rowCount,
+ final int columnCount,
+ final int selectionMode,
+ final boolean isHierarchical) {
+ final CollectionInfo collectionInfo =
+ Build.VERSION.SDK_INT >= 21
+ ? CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode)
+ : CollectionInfo.obtain(rowCount, columnCount, isHierarchical);
+ node.setCollectionInfo(collectionInfo);
+ }
+
+ @WrapForJNI
+ private void populateNodeRangeInfo(
+ final AccessibilityNodeInfo node,
+ final int rangeType,
+ final float min,
+ final float max,
+ final float current) {
+ final RangeInfo rangeInfo = RangeInfo.obtain(rangeType, min, max, current);
+ node.setRangeInfo(rangeInfo);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
new file mode 100644
index 0000000000..2ed0b1a6c3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
@@ -0,0 +1,131 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Pair;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import java.util.List;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags;
+import org.mozilla.geckoview.GeckoSession.FinderFindFlags;
+import org.mozilla.geckoview.GeckoSession.FinderResult;
+
+/**
+ * {@code SessionFinder} instances returned by {@link GeckoSession#getFinder()} performs
+ * find-in-page operations.
+ */
+@AnyThread
+public final class SessionFinder {
+ private static final String LOGTAG = "GeckoSessionFinder";
+
+ private static final List<Pair<Integer, String>> sFlagNames =
+ Arrays.asList(
+ new Pair<>(GeckoSession.FINDER_FIND_BACKWARDS, "backwards"),
+ new Pair<>(GeckoSession.FINDER_FIND_LINKS_ONLY, "linksOnly"),
+ new Pair<>(GeckoSession.FINDER_FIND_MATCH_CASE, "matchCase"),
+ new Pair<>(GeckoSession.FINDER_FIND_WHOLE_WORD, "wholeWord"));
+
+ private static void addFlagsToBundle(
+ @FinderFindFlags final int flags, @NonNull final GeckoBundle bundle) {
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if ((flags & name.first) != 0) {
+ bundle.putBoolean(name.second, true);
+ }
+ }
+ }
+
+ /* package */ static int getFlagsFromBundle(@Nullable final GeckoBundle bundle) {
+ if (bundle == null) {
+ return 0;
+ }
+
+ int flags = 0;
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if (bundle.getBoolean(name.second)) {
+ flags |= name.first;
+ }
+ }
+ return flags;
+ }
+
+ private final EventDispatcher mDispatcher;
+ @FinderDisplayFlags private int mDisplayFlags;
+
+ /* package */ SessionFinder(@NonNull final EventDispatcher dispatcher) {
+ mDispatcher = dispatcher;
+ setDisplayFlags(0);
+ }
+
+ /**
+ * Find and select a string on the current page, starting from the current selection or the start
+ * of the page if there is no selection. Optionally return results related to the search in a
+ * {@link FinderResult} object. If {@code searchString} is null, search is performed using the
+ * previous search string.
+ *
+ * @param searchString String to search, or null to find again using the previous string.
+ * @param flags Flags for performing the search; either 0 or a combination of {@link
+ * GeckoSession#FINDER_FIND_BACKWARDS FINDER_FIND_*} constants.
+ * @return Result of the search operation as a {@link GeckoResult} object.
+ * @see #clear
+ * @see #setDisplayFlags
+ */
+ @NonNull
+ public GeckoResult<FinderResult> find(
+ @Nullable final String searchString, @FinderFindFlags final int flags) {
+ final GeckoBundle bundle = new GeckoBundle(sFlagNames.size() + 1);
+ bundle.putString("searchString", searchString);
+ addFlagsToBundle(flags, bundle);
+
+ return mDispatcher
+ .queryBundle("GeckoView:FindInPage", bundle)
+ .map(response -> new FinderResult(response));
+ }
+
+ /**
+ * Clear any highlighted find-in-page matches.
+ *
+ * @see #find
+ * @see #setDisplayFlags
+ */
+ public void clear() {
+ mDispatcher.dispatch("GeckoView:ClearMatches", null);
+ }
+
+ /**
+ * Return flags for displaying find-in-page matches.
+ *
+ * @return Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #setDisplayFlags
+ * @see #find
+ */
+ @FinderDisplayFlags
+ public int getDisplayFlags() {
+ return mDisplayFlags;
+ }
+
+ /**
+ * Set flags for displaying find-in-page matches.
+ *
+ * @param flags Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #getDisplayFlags
+ * @see #find
+ */
+ public void setDisplayFlags(@FinderDisplayFlags final int flags) {
+ mDisplayFlags = flags;
+
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putBoolean("highlightAll", (flags & GeckoSession.FINDER_DISPLAY_HIGHLIGHT_ALL) != 0);
+ bundle.putBoolean("dimPage", (flags & GeckoSession.FINDER_DISPLAY_DIM_PAGE) != 0);
+ bundle.putBoolean("drawOutline", (flags & GeckoSession.FINDER_DISPLAY_DRAW_LINK_OUTLINE) != 0);
+ mDispatcher.dispatch("GeckoView:DisplayMatches", bundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java
new file mode 100644
index 0000000000..f5e6c6976c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java
@@ -0,0 +1,463 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.graphics.RectF;
+import android.os.Handler;
+import android.text.Editable;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.NativeQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * {@code SessionTextInput} handles text input for {@code GeckoSession} through key events or input
+ * methods. It is typically used to implement certain methods in {@link android.view.View} such as
+ * {@link android.view.View#onCreateInputConnection}, by forwarding such calls to corresponding
+ * methods in {@code SessionTextInput}.
+ *
+ * <p>For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be
+ * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null,
+ * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link
+ * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in
+ * behavior in this viewless mode.
+ */
+public final class SessionTextInput {
+ /* package */ static final String LOGTAG = "GeckoSessionTextInput";
+ private static final boolean DEBUG = false;
+
+ // Interface to access GeckoInputConnection from SessionTextInput.
+ /* package */ interface InputConnectionClient {
+ View getView();
+
+ Handler getHandler(Handler defHandler);
+
+ InputConnection onCreateInputConnection(EditorInfo attrs);
+ }
+
+ // Interface to access GeckoEditable from GeckoInputConnection.
+ /* package */ interface EditableClient {
+ // The following value is used by requestCursorUpdates
+ // ONE_SHOT calls updateCompositionRects() after getting current composing
+ // character rects.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR})
+ /* package */ @interface CursorMonitorMode {}
+
+ @WrapForJNI static final int ONE_SHOT = 1;
+ // START_MONITOR start the monitor for composing character rects. If is is
+ // updaed, call updateCompositionRects()
+ @WrapForJNI static final int START_MONITOR = 2;
+ // ENDT_MONITOR stops the monitor for composing character rects.
+ @WrapForJNI static final int END_MONITOR = 3;
+
+ void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event);
+
+ Editable getEditable();
+
+ void setBatchMode(boolean isBatchMode);
+
+ Handler setInputConnectionHandler(@NonNull Handler handler);
+
+ void postToInputConnection(@NonNull Runnable runnable);
+
+ void requestCursorUpdates(@CursorMonitorMode int requestMode);
+
+ void insertImage(@NonNull byte[] data, @NonNull String mimeType);
+ }
+
+ // Interface to access GeckoInputConnection from GeckoEditable.
+ /* package */ interface EditableListener {
+ // IME notification type for notifyIME(), corresponding to NotificationToIME enum.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NOTIFY_IME_OF_TOKEN,
+ NOTIFY_IME_OPEN_VKB,
+ NOTIFY_IME_REPLY_EVENT,
+ NOTIFY_IME_OF_FOCUS,
+ NOTIFY_IME_OF_BLUR,
+ NOTIFY_IME_TO_COMMIT_COMPOSITION,
+ NOTIFY_IME_TO_CANCEL_COMPOSITION
+ })
+ /* package */ @interface IMENotificationType {}
+
+ @WrapForJNI static final int NOTIFY_IME_OF_TOKEN = -3;
+ @WrapForJNI static final int NOTIFY_IME_OPEN_VKB = -2;
+ @WrapForJNI static final int NOTIFY_IME_REPLY_EVENT = -1;
+ @WrapForJNI static final int NOTIFY_IME_OF_FOCUS = 1;
+ @WrapForJNI static final int NOTIFY_IME_OF_BLUR = 2;
+ @WrapForJNI static final int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8;
+ @WrapForJNI static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;
+
+ // IME enabled state for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD})
+ /* package */ @interface IMEState {}
+
+ static final int IME_STATE_UNKNOWN = -1;
+ static final int IME_STATE_DISABLED = 0;
+ static final int IME_STATE_ENABLED = 1;
+ static final int IME_STATE_PASSWORD = 2;
+
+ // Flags for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED})
+ /* package */ @interface IMEContextFlags {}
+
+ @WrapForJNI static final int IME_FLAG_PRIVATE_BROWSING = 1 << 0;
+ @WrapForJNI static final int IME_FLAG_USER_ACTION = 1 << 1;
+ @WrapForJNI static final int IME_FOCUS_NOT_CHANGED = 1 << 2;
+
+ void notifyIME(@IMENotificationType int type);
+
+ void notifyIMEContext(
+ @IMEState int state,
+ String typeHint,
+ String modeHint,
+ String actionHint,
+ @IMEContextFlags int flag);
+
+ void onSelectionChange();
+
+ void onTextChange();
+
+ void onDiscardComposition();
+
+ void onDefaultKeyEvent(KeyEvent event);
+
+ void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect);
+ }
+
+ private static final class DefaultDelegate implements GeckoSession.TextInputDelegate {
+ public static final DefaultDelegate INSTANCE = new DefaultDelegate();
+
+ private InputMethodManager getInputMethodManager(@Nullable final View view) {
+ if (view == null) {
+ return null;
+ }
+ return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ @Override
+ public void restartInput(@NonNull final GeckoSession session, final int reason) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm == null) {
+ return;
+ }
+
+ // InputMethodManager has internal logic to detect if we are restarting input
+ // in an already focused View, which is the case here because all content text
+ // fields are inside one LayerView. When this happens, InputMethodManager will
+ // tell the input method to soft reset instead of hard reset. Stock latin IME
+ // on Android 4.2+ has a quirk that when it soft resets, it does not clear the
+ // composition. The following workaround tricks the IME into clearing the
+ // composition when soft resetting.
+ if (InputMethods.needsSoftResetWorkaround(
+ InputMethods.getCurrentInputMethod(view.getContext()))) {
+ // Fake a selection change, because the IME clears the composition when
+ // the selection changes, even if soft-resetting. Offsets here must be
+ // different from the previous selection offsets, and -1 seems to be a
+ // reasonable, deterministic value
+ imm.updateSelection(view, -1, -1, -1, -1);
+ }
+
+ try {
+ imm.restartInput(view);
+ } catch (final RuntimeException e) {
+ Log.e(LOGTAG, "Error restarting input", e);
+ }
+ }
+
+ @Override
+ public void showSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ if (view.hasFocus() && !imm.isActive(view)) {
+ // Marshmallow workaround: The view has focus but it is not the active
+ // view for the input method. (Bug 1211848)
+ view.clearFocus();
+ view.requestFocus();
+ }
+ imm.showSoftInput(view, 0);
+ }
+ }
+
+ @Override
+ public void hideSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
+ }
+ }
+
+ @Override
+ public void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ // When composition start and end is -1,
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. If not working, we have to add a workaround
+ // to EditableListener.onDiscardComposition.
+ imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd);
+ }
+ }
+
+ @Override
+ public void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateExtractedText(view, request.token, text);
+ }
+ }
+
+ @TargetApi(21)
+ @Override
+ public void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateCursorAnchorInfo(view, info);
+ }
+ }
+ }
+
+ private final GeckoSession mSession;
+ private final NativeQueue mQueue;
+ private final GeckoEditable mEditable;
+ private InputConnectionClient mInputConnection;
+ private GeckoSession.TextInputDelegate mDelegate;
+
+ /* package */ SessionTextInput(
+ final @NonNull GeckoSession session, final @NonNull NativeQueue queue) {
+ mSession = session;
+ mQueue = queue;
+ mEditable = new GeckoEditable(session);
+ }
+
+ /* package */ void onWindowChanged(final GeckoSession.Window window) {
+ if (mQueue.isReady()) {
+ window.attachEditable(mEditable);
+ } else {
+ mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable);
+ }
+ }
+
+ /**
+ * Get a Handler for the background input method thread. In order to use a background thread for
+ * input method operations on systems prior to Nougat, first override {@code View.getHandler()}
+ * for the View returning the InputConnection instance, and then call this method from the
+ * overridden method.
+ *
+ * <p>For example:
+ *
+ * <pre>
+ * &#64;Override
+ * public Handler getHandler() {
+ * if (Build.VERSION.SDK_INT &gt;= 24) {
+ * return super.getHandler();
+ * }
+ * return getSession().getTextInput().getHandler(super.getHandler());
+ * }</pre>
+ *
+ * @param defHandler Handler returned by the system {@code getHandler} implementation.
+ * @return Handler to return to the system through {@code getHandler}.
+ */
+ @AnyThread
+ public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) {
+ // May be called on any thread.
+ if (mInputConnection != null) {
+ return mInputConnection.getHandler(defHandler);
+ }
+ return defHandler;
+ }
+
+ /**
+ * Get the current {@link android.view.View} for text input.
+ *
+ * @return Current text input View or null if not set.
+ * @see #setView(View)
+ */
+ @UiThread
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+ return mInputConnection != null ? mInputConnection.getView() : null;
+ }
+
+ /**
+ * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used
+ * to interact with the system input method manager and to display certain text input UI elements.
+ * See the {@code SessionTextInput} class documentation for information on viewless mode, when the
+ * current {@link android.view.View} is not set or set to null.
+ *
+ * @param view Text input View or null to clear current View.
+ * @see #getView()
+ */
+ @UiThread
+ public synchronized void setView(final @Nullable View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (view == null) {
+ mInputConnection = null;
+ } else if (mInputConnection == null || mInputConnection.getView() != view) {
+ mInputConnection = GeckoInputConnection.create(mSession, view, mEditable);
+ }
+ mEditable.setListener((EditableListener) mInputConnection);
+ }
+
+ /**
+ * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method
+ * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value
+ * will always be null.
+ *
+ * @param attrs EditorInfo instance to be filled on return.
+ * @return InputConnection instance, or null if there is no active input (or if in viewless mode).
+ */
+ @AnyThread
+ public synchronized @Nullable InputConnection onCreateInputConnection(
+ final @NonNull EditorInfo attrs) {
+ // May be called on any thread.
+ mEditable.onCreateInputConnection(attrs);
+
+ if (!mQueue.isReady() || mInputConnection == null) {
+ return null;
+ }
+ return mInputConnection.onCreateInputConnection(attrs);
+ }
+
+ /**
+ * Process a KeyEvent as a pre-IME event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyPreIme(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-down event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyDown(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-up event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyUp(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a long-press event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyLongPress(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a multiple-press event.
+ *
+ * @param keyCode Key code.
+ * @param repeatCount Key repeat count.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyMultiple(
+ final int keyCode, final int repeatCount, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event);
+ }
+
+ /**
+ * Set the current text input delegate.
+ *
+ * @param delegate TextInputDelegate instance or null to restore to default.
+ */
+ @UiThread
+ public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the current text input delegate.
+ *
+ * @return TextInputDelegate instance or a default instance if no delegate has been set.
+ */
+ @UiThread
+ public @NonNull GeckoSession.TextInputDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ if (mDelegate == null) {
+ mDelegate = DefaultDelegate.INSTANCE;
+ }
+ return mDelegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
new file mode 100644
index 0000000000..d25c51ef9a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
@@ -0,0 +1,20 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/**
+ * Used by a ContentDelegate to indicate what action to take on a slow script event.
+ *
+ * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String)
+ */
+@AnyThread
+public enum SlowScriptResponse {
+ STOP,
+ CONTINUE;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
new file mode 100644
index 0000000000..a49cdf26a5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
@@ -0,0 +1,405 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission;
+
+/**
+ * Manage runtime storage data.
+ *
+ * <p>Retrieve an instance via {@link GeckoRuntime#getStorageController}.
+ */
+public final class StorageController {
+ private static final String LOGTAG = "StorageController";
+
+ // Keep in sync with GeckoViewStorageController.ClearFlags.
+ /** Flags used for data clearing operations. */
+ public static class ClearFlags {
+ /** Cookies. */
+ public static final long COOKIES = 1 << 0;
+
+ /** Network cache. */
+ public static final long NETWORK_CACHE = 1 << 1;
+
+ /** Image cache. */
+ public static final long IMAGE_CACHE = 1 << 2;
+
+ /** DOM storages. */
+ public static final long DOM_STORAGES = 1 << 4;
+
+ /** Auth tokens and caches. */
+ public static final long AUTH_SESSIONS = 1 << 5;
+
+ /** Site permissions. */
+ public static final long PERMISSIONS = 1 << 6;
+
+ /** All caches. */
+ public static final long ALL_CACHES = NETWORK_CACHE | IMAGE_CACHE;
+
+ /** All site settings (permissions, content preferences, security settings, etc.). */
+ public static final long SITE_SETTINGS = 1 << 7 | PERMISSIONS;
+
+ /** All site-related data (cookies, storages, caches, permissions, etc.). */
+ public static final long SITE_DATA =
+ 1 << 8 | COOKIES | DOM_STORAGES | ALL_CACHES | PERMISSIONS | SITE_SETTINGS;
+
+ /** All data. */
+ public static final long ALL = 1 << 9;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ ClearFlags.COOKIES,
+ ClearFlags.NETWORK_CACHE,
+ ClearFlags.IMAGE_CACHE,
+ ClearFlags.DOM_STORAGES,
+ ClearFlags.AUTH_SESSIONS,
+ ClearFlags.PERMISSIONS,
+ ClearFlags.ALL_CACHES,
+ ClearFlags.SITE_SETTINGS,
+ ClearFlags.SITE_DATA,
+ ClearFlags.ALL
+ })
+ public @interface StorageControllerClearFlags {}
+
+ /**
+ * Clear data for all hosts.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearData(final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given host. Clearing data for a host will not clear data created by its
+ * third-party origins.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param host The host to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromHost(
+ final @NonNull String host, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("host", host);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearHostData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given base domain (eTLD+1). Clearing data for a base domain will also
+ * clear any associated third-party storage. This includes clearing for third-parties embedded by
+ * the domain and for the given domain embedded under other sites.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param baseDomain The base domain to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromBaseDomain(
+ final @NonNull String baseDomain, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("baseDomain", baseDomain);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearBaseDomainData", bundle);
+ }
+
+ /**
+ * Clear data for the given context ID. Use {@link GeckoSessionSettings.Builder#contextId}.to set
+ * a context ID for a session.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions for the given context prior to
+ * clearing data.
+ *
+ * @param contextId The context ID for the storage data to be deleted.
+ */
+ @AnyThread
+ public void clearDataForSessionContext(final @NonNull String contextId) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("contextId", createSafeSessionContextId(contextId));
+
+ EventDispatcher.getInstance().dispatch("GeckoView:ClearSessionContextData", bundle);
+ }
+
+ /* package */ static @Nullable String createSafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null) {
+ return null;
+ }
+ if (contextId.isEmpty()) {
+ // Let's avoid empty strings for Gecko.
+ return "gvctxempty";
+ }
+ // We don't want to restrict the session context ID string options, so to
+ // ensure that the string is safe for Gecko processing, we translate it to
+ // its hex representation.
+ return String.format("gvctx%x", new BigInteger(contextId.getBytes())).toLowerCase(Locale.ROOT);
+ }
+
+ /* package */ static @Nullable String retrieveUnsafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null || contextId.isEmpty()) {
+ return null;
+ }
+ if ("gvctxempty".equals(contextId)) {
+ return "";
+ }
+ final byte[] bytes = new BigInteger(contextId.substring(5), 16).toByteArray();
+ return new String(bytes, Charset.forName("UTF-8"));
+ }
+
+ /**
+ * Get all currently stored permissions.
+ *
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getAllPermissions() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetAllPermissions")
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID, in normal
+ * mode This API will be deprecated in the future
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1797379
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(final @NonNull String uri) {
+ return getPermissions(uri, null, false);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final boolean privateMode) {
+ return getPermissions(uri, null, privateMode);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param contextId A String specifying the context ID.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final @Nullable String contextId, final boolean privateMode) {
+ final GeckoBundle msg = new GeckoBundle(2);
+ final int privateBrowsingId = (privateMode) ? 1 : 0;
+ msg.putString("uri", uri);
+ msg.putString("contextId", createSafeSessionContextId(contextId));
+ msg.putInt("privateBrowsingId", privateBrowsingId);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetPermissionsByURI", msg)
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Set a new value for an existing permission.
+ *
+ * <p>Note: in private browsing, this value will only be cleared at the end of the session to add
+ * permanent permissions in private browsing, you can use {@link
+ * #setPrivateBrowsingPermanentPermission}.
+ *
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ false);
+ }
+
+ /**
+ * Set a permanent value for a permission in a private browsing session.
+ *
+ * <p>Normally permissions in private browsing are cleared at the end of the session. This method
+ * allows you to set a permanent permission bypassing this behavior.
+ *
+ * <p>Note: permanent permissions in private browsing are web discoverable and might make the user
+ * more easily trackable.
+ *
+ * @see #setPermission
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPrivateBrowsingPermanentPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ true);
+ }
+
+ private void setPermissionInternal(
+ final @NonNull ContentPermission perm,
+ final @ContentPermission.Value int value,
+ final boolean allowPermanentPrivateBrowsing) {
+ if (perm.permission == GeckoSession.PermissionDelegate.PERMISSION_TRACKING
+ && value == ContentPermission.VALUE_PROMPT) {
+ Log.w(LOGTAG, "Cannot set a tracking permission to VALUE_PROMPT, aborting.");
+ return;
+ }
+ final GeckoBundle msg = perm.toGeckoBundle();
+ msg.putInt("newValue", value);
+ msg.putBoolean("allowPermanentPrivateBrowsing", allowPermanentPrivateBrowsing);
+ EventDispatcher.getInstance().dispatch("GeckoView:SetPermission", msg);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given {@link
+ * ContentBlocking.CBCookieBannerMode} should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeForDomain(
+ final @NonNull String uri,
+ final @ContentBlocking.CBCookieBannerMode int mode,
+ final boolean isPrivateBrowsing) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", false);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri in private mode.
+ *
+ * @param uri for which you want to change the {@link ContentBlocking.CBCookieBannerMode} value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeAndPersistInPrivateBrowsingForDomain(
+ final @NonNull String uri, final @ContentBlocking.CBCookieBannerMode int mode) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", true);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Removes a {@link ContentBlocking.CBCookieBannerMode} for the given uri and and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param isPrivateBrowsing Indicates in which mode the given mode should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been removed.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> removeCookieBannerModeForDomain(
+ final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryVoid("GeckoView:RemoveCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Gets the actual {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want get the {@link ContentBlocking.CBCookieBannerMode}.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given uri should be.
+ * @return A {@link GeckoResult} that resolves to a {@link ContentBlocking.CBCookieBannerMode} for
+ * the given uri and browsing mode.
+ */
+ @AnyThread
+ public @NonNull @ContentBlocking.CBCookieBannerMode GeckoResult<Integer>
+ getCookieBannerModeForDomain(final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(2);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetCookieBannerModeForDomain", data)
+ .map(StorageController::cookieBannerModeFromBundle, StorageController::fromQueryException);
+ }
+
+ private static @ContentBlocking.CBCookieBannerMode int cookieBannerModeFromBundle(
+ final GeckoBundle bundle) throws Exception {
+ if (bundle == null) {
+ throw new Exception("Unable to parse cookie banner mode");
+ }
+ return bundle.getInt("mode");
+ }
+
+ private static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ return new Exception(response.toString());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
new file mode 100644
index 0000000000..59324ddf91
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
@@ -0,0 +1,572 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.app.PendingIntent;
+import android.content.Intent;
+import android.net.Uri;
+import android.util.Base64;
+import android.util.Log;
+import com.google.android.gms.fido.Fido;
+import com.google.android.gms.fido.common.Transport;
+import com.google.android.gms.fido.fido2.Fido2ApiClient;
+import com.google.android.gms.fido.fido2.Fido2PrivilegedApiClient;
+import com.google.android.gms.fido.fido2.api.common.Algorithm;
+import com.google.android.gms.fido.fido2.api.common.Attachment;
+import com.google.android.gms.fido.fido2.api.common.AttestationConveyancePreference;
+import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensions;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse;
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria;
+import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialCreationOptions;
+import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialRequestOptions;
+import com.google.android.gms.fido.fido2.api.common.EC2Algorithm;
+import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameters;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType;
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity;
+import com.google.android.gms.fido.fido2.api.common.RSAAlgorithm;
+import com.google.android.gms.tasks.Task;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/* package */ class WebAuthnTokenManager {
+ private static final String LOGTAG = "WebAuthnTokenManager";
+
+ // from u2fhid-capi.h
+ private static final byte AUTHENTICATOR_TRANSPORT_USB = 1;
+ private static final byte AUTHENTICATOR_TRANSPORT_NFC = 2;
+ private static final byte AUTHENTICATOR_TRANSPORT_BLE = 4;
+
+ private static final Algorithm[] SUPPORTED_ALGORITHMS = {
+ EC2Algorithm.ES256,
+ EC2Algorithm.ES384,
+ EC2Algorithm.ES512,
+ EC2Algorithm.ED256, /* no ED384 */
+ EC2Algorithm.ED512,
+ RSAAlgorithm.PS256,
+ RSAAlgorithm.PS384,
+ RSAAlgorithm.PS512,
+ RSAAlgorithm.RS256,
+ RSAAlgorithm.RS384,
+ RSAAlgorithm.RS512
+ };
+
+ private static List<Transport> getTransportsForByte(final byte transports) {
+ final ArrayList<Transport> result = new ArrayList<Transport>();
+ if ((transports & AUTHENTICATOR_TRANSPORT_USB) == AUTHENTICATOR_TRANSPORT_USB) {
+ result.add(Transport.USB);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_NFC) == AUTHENTICATOR_TRANSPORT_NFC) {
+ result.add(Transport.NFC);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_BLE) == AUTHENTICATOR_TRANSPORT_BLE) {
+ result.add(Transport.BLUETOOTH_LOW_ENERGY);
+ }
+
+ return result;
+ }
+
+ public static class WebAuthnPublicCredential {
+ public final byte[] id;
+ public final byte transports;
+
+ public WebAuthnPublicCredential(final byte[] aId, final byte aTransports) {
+ this.id = aId;
+ this.transports = aTransports;
+ }
+
+ static ArrayList<WebAuthnPublicCredential> CombineBuffers(
+ final Object[] idObjectList, final ByteBuffer transportList) {
+ if (idObjectList.length != transportList.remaining()) {
+ throw new RuntimeException("Couldn't extract allowed list!");
+ }
+
+ final ArrayList<WebAuthnPublicCredential> credList =
+ new ArrayList<WebAuthnPublicCredential>();
+
+ final byte[] transportBytes = new byte[transportList.remaining()];
+ transportList.get(transportBytes);
+
+ for (int i = 0; i < idObjectList.length; i++) {
+ final ByteBuffer id = (ByteBuffer) idObjectList[i];
+ final byte[] idBytes = new byte[id.remaining()];
+ id.get(idBytes);
+
+ credList.add(new WebAuthnPublicCredential(idBytes, transportBytes[i]));
+ }
+ return credList;
+ }
+ }
+
+ // From WebAuthentication.webidl
+ public enum AttestationPreference {
+ NONE,
+ INDIRECT,
+ DIRECT,
+ }
+
+ @WrapForJNI
+ public static class MakeCredentialResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] attestationObject;
+
+ public MakeCredentialResponse(
+ final byte[] clientDataJson, final byte[] keyHandle, final byte[] attestationObject) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.attestationObject = attestationObject;
+ }
+ }
+
+ public static class Exception extends RuntimeException {
+ public Exception(final String error) {
+ super(error);
+ }
+ }
+
+ public static GeckoResult<MakeCredentialResponse> makeCredential(
+ final GeckoBundle credentialBundle,
+ final byte[] userId,
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] excludeList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ if (!credentialBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final PublicKeyCredentialCreationOptions.Builder requestBuilder =
+ new PublicKeyCredentialCreationOptions.Builder();
+
+ final List<PublicKeyCredentialParameters> params =
+ new ArrayList<PublicKeyCredentialParameters>();
+
+ // WebAuthn supports more algorithms
+ for (final Algorithm algo : SUPPORTED_ALGORITHMS) {
+ params.add(
+ new PublicKeyCredentialParameters(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(), algo.getAlgoValue()));
+ }
+
+ final PublicKeyCredentialUserEntity user =
+ new PublicKeyCredentialUserEntity(
+ userId,
+ credentialBundle.getString("userName", ""),
+ credentialBundle.getString("userIcon", ""),
+ credentialBundle.getString("userDisplayName", ""));
+
+ AttestationConveyancePreference pref = AttestationConveyancePreference.NONE;
+ final String attestationPreference =
+ authenticatorSelection.getString("attestationPreference", "NONE");
+ if (attestationPreference.equalsIgnoreCase(AttestationConveyancePreference.DIRECT.name())) {
+ pref = AttestationConveyancePreference.DIRECT;
+ } else if (attestationPreference.equalsIgnoreCase(
+ AttestationConveyancePreference.INDIRECT.name())) {
+ pref = AttestationConveyancePreference.INDIRECT;
+ }
+
+ final AuthenticatorSelectionCriteria.Builder selBuild =
+ new AuthenticatorSelectionCriteria.Builder();
+ if (authenticatorSelection.getInt("requirePlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.PLATFORM);
+ }
+ if (authenticatorSelection.getInt("requireCrossPlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.CROSS_PLATFORM);
+ }
+ final AuthenticatorSelectionCriteria sel = selBuild.build();
+
+ final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ final AuthenticationExtensions ext = extBuilder.build();
+
+ // requireResidentKey andrequireUserVerification are not yet
+ // consumed by Android's API
+
+ final List<PublicKeyCredentialDescriptor> excludedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) {
+ excludedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final PublicKeyCredentialRpEntity rp =
+ new PublicKeyCredentialRpEntity(
+ credentialBundle.getString("rpId"),
+ credentialBundle.getString("rpName", ""),
+ credentialBundle.getString("rpIcon", ""));
+
+ final PublicKeyCredentialCreationOptions requestOptions =
+ requestBuilder
+ .setUser(user)
+ .setAttestationConveyancePreference(pref)
+ .setAuthenticatorSelection(sel)
+ .setAuthenticationExtensions(ext)
+ .setChallenge(challenge)
+ .setRp(rp)
+ .setParameters(params)
+ .setTimeoutSeconds(credentialBundle.getLong("timeoutMS") / 1000.0)
+ .setExcludeList(excludedList)
+ .build();
+
+ final Uri origin = Uri.parse(credentialBundle.getString("origin"));
+
+ final BrowserPublicKeyCredentialCreationOptions browserOptions =
+ new BrowserPublicKeyCredentialCreationOptions.Builder()
+ .setPublicKeyCredentialCreationOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ // Certain Fenix builds and signing keys are whitelisted for Web Authentication.
+ // See https://wiki.mozilla.org/Security/Web_Authentication
+ //
+ // Third party apps will need to get whitelisted themselves.
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(browserOptions);
+ } else {
+ // For non-official builds, websites have to opt-in to permit the
+ // particular version of Gecko to perform WebAuthn operations on
+ // them. See https://developers.google.com/digital-asset-links
+ // for the general form, and Step 1 of
+ // https://developers.google.com/identity/fido/android/native-apps
+ // for details about doing this correctly for the FIDO2 API.
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<MakeCredentialResponse> result = new GeckoResult<>();
+
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ final byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ if (rspData != null) {
+ final AuthenticatorAttestationResponse responseData =
+ AuthenticatorAttestationResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "attestation Object: "
+ + Base64.encodeToString(
+ responseData.getAttestationObject(), Base64.DEFAULT));
+
+ result.complete(
+ new WebAuthnTokenManager.MakeCredentialResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAttestationObject()));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to launch activity: ", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+ });
+
+ intentTask.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<MakeCredentialResponse> webAuthnMakeCredential(
+ final GeckoBundle credentialBundle,
+ final ByteBuffer userId,
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> excludeList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ final byte[] userBytes = new byte[userId.remaining()];
+ try {
+ challenge.get(challBytes);
+ userId.get(userBytes);
+
+ excludeList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return makeCredential(
+ credentialBundle,
+ userBytes,
+ challBytes,
+ excludeList.toArray(new WebAuthnPublicCredential[0]),
+ authenticatorSelection,
+ extensions);
+ } catch (final Exception e) {
+ // We need to ensure we catch any possible exception here in order to ensure
+ // that the Promise on the content side is appropriately rejected. In particular,
+ // we will get `NoClassDefFoundError` if we're running on a device that does not
+ // have Google Play Services.
+ Log.w(LOGTAG, "Couldn't make credential", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI
+ public static class GetAssertionResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] authData;
+ public final byte[] signature;
+ public final byte[] userHandle;
+
+ public GetAssertionResponse(
+ final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] authData,
+ final byte[] signature,
+ final byte[] userHandle) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.authData = authData;
+ this.signature = signature;
+ this.userHandle = userHandle;
+ }
+ }
+
+ private static WebAuthnTokenManager.Exception parseErrorIntent(final Intent intent) {
+ if (!intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) {
+ return null;
+ }
+
+ final byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA);
+ final AuthenticatorErrorResponse responseData =
+ AuthenticatorErrorResponse.deserializeFromBytes(errData);
+
+ Log.e(LOGTAG, "errorCode.name: " + responseData.getErrorCode());
+ Log.e(LOGTAG, "errorMessage: " + responseData.getErrorMessage());
+
+ return new WebAuthnTokenManager.Exception(responseData.getErrorCode().name());
+ }
+
+ private static GeckoResult<GetAssertionResponse> getAssertion(
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] allowList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+
+ if (!assertionBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final List<PublicKeyCredentialDescriptor> allowedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) {
+ allowedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ final AuthenticationExtensions ext = extBuilder.build();
+
+ final PublicKeyCredentialRequestOptions requestOptions =
+ new PublicKeyCredentialRequestOptions.Builder()
+ .setChallenge(challenge)
+ .setAllowList(allowedList)
+ .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0)
+ .setRpId(assertionBundle.getString("rpId"))
+ .setAuthenticationExtensions(ext)
+ .build();
+
+ final Uri origin = Uri.parse(assertionBundle.getString("origin"));
+ final BrowserPublicKeyCredentialRequestOptions browserOptions =
+ new BrowserPublicKeyCredentialRequestOptions.Builder()
+ .setPublicKeyCredentialRequestOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+ // See the makeCredential method for documentation about this
+ // conditional.
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(browserOptions);
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<GetAssertionResponse> result = new GeckoResult<>();
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) {
+ final byte[] rspData =
+ intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ final AuthenticatorAssertionResponse responseData =
+ AuthenticatorAssertionResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "auth data: "
+ + Base64.encodeToString(
+ responseData.getAuthenticatorData(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "signature: "
+ + Base64.encodeToString(responseData.getSignature(), Base64.DEFAULT));
+
+ // Nullable field
+ byte[] userHandle = responseData.getUserHandle();
+ if (userHandle == null) {
+ userHandle = new byte[0];
+ }
+
+ result.complete(
+ new WebAuthnTokenManager.GetAssertionResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAuthenticatorData(),
+ responseData.getSignature(),
+ userHandle));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ });
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<GetAssertionResponse> webAuthnGetAssertion(
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> allowList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ try {
+ challenge.get(challBytes);
+ allowList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return getAssertion(
+ challBytes,
+ allowList.toArray(new WebAuthnPublicCredential[0]),
+ assertionBundle,
+ extensions);
+ } catch (final java.lang.Exception e) {
+ Log.w(LOGTAG, "Couldn't get assertion", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<Boolean> webAuthnIsUserVerifyingPlatformAuthenticatorAvailable() {
+ final Task<Boolean> task;
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ }
+
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ task.addOnSuccessListener(
+ isUVPAA -> {
+ res.complete(isUVPAA);
+ });
+ task.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "isUserVerifyingPlatformAuthenticatorAvailable is failed", e);
+ res.complete(false);
+ });
+ return res;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
new file mode 100644
index 0000000000..a4248142a4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
@@ -0,0 +1,2750 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.graphics.Color;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/** Represents a WebExtension that may be used by GeckoView. */
+public class WebExtension {
+ /**
+ * <code>file:</code> or <code>resource:</code> URI that points to the install location of this
+ * WebExtension. When the WebExtension is included with the APK the file can be specified using
+ * the <code>resource://android</code> alias. E.g.
+ *
+ * <pre><code>
+ * resource://android/assets/web_extensions/my_webextension/
+ * </code></pre>
+ *
+ * Will point to folder <code>/assets/web_extensions/my_webextension/</code> in the APK.
+ */
+ public final @NonNull String location;
+ /** Unique identifier for this WebExtension */
+ public final @NonNull String id;
+ /** {@link Flags} for this WebExtension. */
+ public final @WebExtensionFlags long flags;
+
+ /** Provides information about this {@link WebExtension}. */
+ public final @NonNull MetaData metaData;
+
+ /**
+ * Whether this extension is built-in. Built-in extension can be installed using {@link
+ * WebExtensionController#installBuiltIn}.
+ */
+ public final boolean isBuiltIn;
+
+ /**
+ * Called whenever a delegate is set or unset on this {@link WebExtension} instance. /* package
+ */
+ interface DelegateController {
+ void onMessageDelegate(final String nativeApp, final MessageDelegate delegate);
+
+ void onActionDelegate(final ActionDelegate delegate);
+
+ void onBrowsingDataDelegate(final BrowsingDataDelegate delegate);
+
+ void onTabDelegate(final TabDelegate delegate);
+
+ void onDownloadDelegate(final DownloadDelegate delegate);
+
+ ActionDelegate getActionDelegate();
+
+ BrowsingDataDelegate getBrowsingDataDelegate();
+
+ TabDelegate getTabDelegate();
+
+ DownloadDelegate getDownloadDelegate();
+ }
+
+ /* package */ interface DelegateControllerProvider {
+ @NonNull
+ DelegateController controllerFor(final WebExtension extension);
+ }
+
+ private final DelegateController mDelegateController;
+
+ @Override
+ public String toString() {
+ return "WebExtension {"
+ + "location="
+ + location
+ + ", "
+ + "id="
+ + id
+ + ", "
+ + "flags="
+ + flags
+ + "}";
+ }
+
+ private static final String LOGTAG = "WebExtension";
+
+ // Keep in sync with GeckoViewWebExtension.jsm
+ public static class Flags {
+ /*
+ * Default flags for this WebExtension.
+ */
+ public static final long NONE = 0;
+ /**
+ * Set this flag if you want to enable content scripts messaging. To listen to such messages you
+ * can use {@link SessionController#setMessageDelegate}.
+ */
+ public static final long ALLOW_CONTENT_MESSAGING = 1 << 0;
+
+ // Do not instantiate this class.
+ protected Flags() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {Flags.NONE, Flags.ALLOW_CONTENT_MESSAGING})
+ public @interface WebExtensionFlags {}
+
+ /* package */ WebExtension(final DelegateControllerProvider provider, final GeckoBundle bundle) {
+ location = bundle.getString("locationURI");
+ id = bundle.getString("webExtensionId");
+ flags = bundle.getInt("webExtensionFlags", 0);
+ isBuiltIn = bundle.getBoolean("isBuiltIn", false);
+ if (bundle.containsKey("metaData")) {
+ metaData = new MetaData(bundle.getBundle("metaData"));
+ } else {
+ metaData = null;
+ }
+ mDelegateController = provider.controllerFor(this);
+ }
+
+ /**
+ * Defines the message delegate for a Native App.
+ *
+ * <p>This message delegate will receive messages from the background script for the native app
+ * specified in <code>nativeApp</code>.
+ *
+ * <p>For messages from content scripts, set a session-specific message delegate using {@link
+ * SessionController#setMessageDelegate}.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging">
+ * WebExtensions/Native_messaging </a>
+ *
+ * @param messageDelegate handles messaging between the WebExtension and the app. To send a
+ * message from the WebExtension use the <code>runtime.sendNativeMessage</code> WebExtension
+ * API: E.g.
+ * <pre><code>
+ * browser.runtime.sendNativeMessage(nativeApp,
+ * {message: "Hello from WebExtension!"});
+ * </code></pre>
+ * For bidirectional communication, use <code>runtime.connectNative</code>. E.g. in a content
+ * script:
+ * <pre><code>
+ * let port = browser.runtime.connectNative(nativeApp);
+ * port.onMessage.addListener(message =&gt; {
+ * console.log("Message received from app");
+ * });
+ * port.postMessage("Ping from WebExtension");
+ * </code></pre>
+ * The code above will trigger a {@link MessageDelegate#onConnect} call that will contain the
+ * corresponding {@link Port} object that can be used to send messages to the WebExtension.
+ * Note: the <code>nativeApp</code> specified in the WebExtension needs to match the <code>
+ * nativeApp</code> parameter of this method.
+ * <p>You can unset the message delegate by setting a <code>null</code> messageDelegate.
+ * @param nativeApp which native app id this message delegate will handle messaging for. Needs to
+ * match the <code>application</code> parameter of <code>runtime.sendNativeMessage</code> and
+ * <code>runtime.connectNative</code>.
+ * @see SessionController#setMessageDelegate
+ */
+ @UiThread
+ public void setMessageDelegate(
+ final @Nullable MessageDelegate messageDelegate, final @NonNull String nativeApp) {
+ mDelegateController.onMessageDelegate(nativeApp, messageDelegate);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ value = {
+ BrowsingDataDelegate.Type.CACHE,
+ BrowsingDataDelegate.Type.COOKIES,
+ BrowsingDataDelegate.Type.DOWNLOADS,
+ BrowsingDataDelegate.Type.FORM_DATA,
+ BrowsingDataDelegate.Type.HISTORY,
+ BrowsingDataDelegate.Type.LOCAL_STORAGE,
+ BrowsingDataDelegate.Type.PASSWORDS
+ },
+ flag = true)
+ public @interface BrowsingDataTypes {}
+
+ /**
+ * This delegate is used to handle calls from the |browsingData| WebExtension API.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData">
+ * WebExtensions/API/browsingData </a>
+ */
+ @UiThread
+ public interface BrowsingDataDelegate {
+ /**
+ * This class represents the current default settings for the "Clear Data" functionality in the
+ * browser.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/settings">
+ * WebExtensions/API/browsingData/settings </a>
+ */
+ @UiThread
+ class Settings {
+ /**
+ * Currently selected setting in the browser's "Clear Data" UI for how far back in time to
+ * remove data given in milliseconds since the UNIX epoch.
+ */
+ public final int sinceUnixTimestamp;
+ /**
+ * Data types that can be toggled in the browser's "Clear Data" UI. One or more flags from
+ * {@link Type}.
+ */
+ public final @BrowsingDataTypes long toggleableTypes;
+
+ /**
+ * Data types currently selected in the browser's "Clear Data" UI. One or more flags from
+ * {@link Type}.
+ */
+ public final @BrowsingDataTypes long selectedTypes;
+
+ /**
+ * Creates an instance of Settings.
+ *
+ * <p>This class represents the current default settings for the "Clear Data" functionality in
+ * the browser.
+ *
+ * @param since Currently selected setting in the browser's "Clear Data" UI for how far back
+ * in time to remove data given in milliseconds since the UNIX epoch.
+ * @param toggleableTypes Data types that can be toggled in the browser's "Clear Data" UI. One
+ * or more flags from {@link Type}.
+ * @param selectedTypes Data types currently selected in the browser's "Clear Data" UI. One or
+ * more flags from {@link Type}.
+ */
+ @UiThread
+ public Settings(
+ final int since,
+ final @BrowsingDataTypes long toggleableTypes,
+ final @BrowsingDataTypes long selectedTypes) {
+ this.toggleableTypes = toggleableTypes;
+ this.selectedTypes = selectedTypes;
+ this.sinceUnixTimestamp = since;
+ }
+
+ private GeckoBundle fromBrowsingDataType(final @BrowsingDataTypes long types) {
+ final GeckoBundle result = new GeckoBundle(7);
+ result.putBoolean("cache", (types & Type.CACHE) != 0);
+ result.putBoolean("cookies", (types & Type.COOKIES) != 0);
+ result.putBoolean("downloads", (types & Type.DOWNLOADS) != 0);
+ result.putBoolean("formData", (types & Type.FORM_DATA) != 0);
+ result.putBoolean("history", (types & Type.HISTORY) != 0);
+ result.putBoolean("localStorage", (types & Type.LOCAL_STORAGE) != 0);
+ result.putBoolean("passwords", (types & Type.PASSWORDS) != 0);
+ return result;
+ }
+
+ /* package */ GeckoBundle toGeckoBundle() {
+ final GeckoBundle options = new GeckoBundle(1);
+ options.putLong("since", sinceUnixTimestamp);
+
+ final GeckoBundle result = new GeckoBundle(3);
+ result.putBundle("options", options);
+ result.putBundle("dataToRemove", fromBrowsingDataType(selectedTypes));
+ result.putBundle("dataRemovalPermitted", fromBrowsingDataType(toggleableTypes));
+ return result;
+ }
+ }
+
+ /** Types of data that a browser "Clear Data" UI might have access to. */
+ class Type {
+ protected Type() {}
+
+ public static final long CACHE = 1 << 0;
+ public static final long COOKIES = 1 << 1;
+ public static final long DOWNLOADS = 1 << 2;
+ public static final long FORM_DATA = 1 << 3;
+ public static final long HISTORY = 1 << 4;
+ public static final long LOCAL_STORAGE = 1 << 5;
+ public static final long PASSWORDS = 1 << 6;
+ }
+
+ /**
+ * Gets current settings for the browser's "Clear Data" UI.
+ *
+ * @return a {@link GeckoResult} that resolves to an instance of {@link Settings} that
+ * represents the current state for the browser's "Clear Data" UI.
+ * @see Settings
+ */
+ @Nullable
+ default GeckoResult<Settings> onGetSettings() {
+ return null;
+ }
+
+ /**
+ * Clear form data created after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearFormData(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear passwords saved after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearPasswords(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear history saved after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearHistory(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear downloads created after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearDownloads(final long sinceUnixTimestamp) {
+ return null;
+ }
+ }
+
+ /** Delegates that responds to messages sent from a WebExtension. */
+ @UiThread
+ public interface MessageDelegate {
+ /**
+ * Called whenever the WebExtension sends a message to an app using <code>
+ * runtime.sendNativeMessage</code>.
+ *
+ * @param nativeApp The application identifier of the MessageDelegate that sent this message.
+ * @param message The message that was sent, either a primitive type or a {@link
+ * org.json.JSONObject}.
+ * @param sender The {@link MessageSender} corresponding to the frame that originated the
+ * message.
+ * <p>Note: all messages are to be considered untrusted and should be checked carefully for
+ * validity.
+ * @return A {@link GeckoResult} that resolves with a response to the message.
+ */
+ @Nullable
+ default GeckoResult<Object> onMessage(
+ final @NonNull String nativeApp,
+ final @NonNull Object message,
+ final @NonNull MessageSender sender) {
+ return null;
+ }
+
+ /**
+ * Called whenever the WebExtension connects to an app using <code>runtime.connectNative</code>.
+ *
+ * @param port {@link Port} instance that can be used to send and receive messages from the
+ * WebExtension. Use {@link Port#sender} to verify the origin of this connection request.
+ */
+ @Nullable
+ default void onConnect(final @NonNull Port port) {}
+ }
+
+ /**
+ * Delegate that handles communication from a WebExtension on a specific {@link Port} instance.
+ */
+ @UiThread
+ public interface PortDelegate {
+ /**
+ * Called whenever a message is sent through the corresponding {@link Port} instance.
+ *
+ * @param message The message that was sent, either a primitive type or a {@link
+ * org.json.JSONObject}.
+ * @param port The {@link Port} instance that received this message.
+ */
+ default void onPortMessage(final @NonNull Object message, final @NonNull Port port) {}
+
+ /**
+ * Called whenever the corresponding {@link Port} instance is disconnected or the corresponding
+ * {@link GeckoSession} is destroyed. Any message sent from the port after this call will be
+ * ignored.
+ *
+ * @param port The {@link Port} instance that was disconnected.
+ */
+ @NonNull
+ default void onDisconnect(final @NonNull Port port) {}
+ }
+
+ /**
+ * Port object that can be used for bidirectional communication with a WebExtension.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port">
+ * WebExtensions/API/runtime/Port </a>.
+ *
+ * @see MessageDelegate#onConnect
+ */
+ @UiThread
+ public static class Port {
+ /* package */ final long id;
+ /* package */ PortDelegate delegate;
+ /* package */ boolean disconnected = false;
+ /* package */ final EventDispatcher mEventDispatcher;
+ /* package */ boolean mListenersRegistered = false;
+
+ /** {@link MessageSender} corresponding to this port. */
+ public @NonNull final MessageSender sender;
+
+ /** The application identifier of the MessageDelegate that opened this port. */
+ public @NonNull final String name;
+
+ /** Override for tests. */
+ protected Port() {
+ this.id = -1;
+ this.delegate = null;
+ this.sender = null;
+ this.name = null;
+ mEventDispatcher = null;
+ }
+
+ /* package */ Port(final String name, final long id, final MessageSender sender) {
+ this.id = id;
+ this.delegate = null;
+ this.sender = sender;
+ this.name = name;
+ mEventDispatcher = EventDispatcher.byName("port:" + id);
+ }
+
+ private BundleEventListener mEventListener =
+ new BundleEventListener() {
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if ("GeckoView:WebExtension:Disconnect".equals(event)) {
+ disconnectFromExtension(callback);
+ } else if ("GeckoView:WebExtension:PortMessage".equals(event)) {
+ portMessage(message, callback);
+ }
+ }
+ };
+
+ private void disconnectFromExtension(final EventCallback callback) {
+ delegate.onDisconnect(this);
+ disconnected();
+ }
+
+ private void portMessage(final GeckoBundle bundle, final EventCallback callback) {
+ final Object content;
+ try {
+ content = bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex);
+ return;
+ }
+
+ delegate.onPortMessage(content, this);
+ }
+
+ /**
+ * Post a message to the WebExtension connected to this {@link Port} instance.
+ *
+ * @param message {@link JSONObject} that will be sent to the WebExtension.
+ */
+ public void postMessage(final @NonNull JSONObject message) {
+ final GeckoBundle args = new GeckoBundle(1);
+ try {
+ args.putBundle("message", GeckoBundle.fromJSONObject(message));
+ } catch (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ mEventDispatcher.dispatch("GeckoView:WebExtension:PortMessageFromApp", args);
+ }
+
+ /** Disconnects this port and notifies the other end. */
+ public void disconnect() {
+ if (this.disconnected) {
+ return;
+ }
+
+ final GeckoBundle args = new GeckoBundle(1);
+ args.putLong("portId", id);
+
+ mEventDispatcher.dispatch("GeckoView:WebExtension:PortDisconnect", args);
+ disconnected();
+ }
+
+ private void disconnected() {
+ unregisterListeners();
+ mEventDispatcher.shutdown();
+ this.disconnected = true;
+ }
+
+ /**
+ * Set a delegate for incoming messages through this {@link Port}.
+ *
+ * @param delegate Delegate that will receive messages sent through this {@link Port}.
+ */
+ public void setDelegate(final @Nullable PortDelegate delegate) {
+ this.delegate = delegate;
+
+ if (delegate != null) {
+ registerListeners();
+ } else {
+ unregisterListeners();
+ }
+ }
+
+ private void unregisterListeners() {
+ if (!mListenersRegistered) {
+ return;
+ }
+
+ mEventDispatcher.unregisterUiThreadListener(
+ mEventListener,
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:WebExtension:PortMessage");
+ mListenersRegistered = false;
+ }
+
+ private void registerListeners() {
+ if (mListenersRegistered) {
+ return;
+ }
+
+ mEventDispatcher.registerUiThreadListener(
+ mEventListener,
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:WebExtension:PortMessage");
+ mListenersRegistered = true;
+ }
+ }
+
+ /**
+ * This delegate is invoked whenever an extension uses the `tabs` WebExtension API to modify the
+ * state of a tab. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ */
+ public interface SessionTabDelegate {
+ /**
+ * Called when tabs.remove is invoked, this method decides if WebExtension can close the tab. In
+ * case WebExtension can close the tab, it should close passed GeckoSession and return
+ * GeckoResult.ALLOW or GeckoResult.DENY in case tab cannot be closed.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove">
+ * WebExtensions/API/tabs/remove</a>
+ *
+ * @param source An instance of {@link WebExtension}
+ * @param session An instance of {@link GeckoSession} to be closed.
+ * @return GeckoResult.ALLOW if the tab will be closed, GeckoResult.DENY otherwise
+ */
+ @UiThread
+ @NonNull
+ default GeckoResult<AllowOrDeny> onCloseTab(
+ @Nullable final WebExtension source, @NonNull final GeckoSession session) {
+ return GeckoResult.deny();
+ }
+
+ /**
+ * Called when tabs.update is invoked. The uri is provided for informational purposes, there's
+ * no need to call <code>loadURI</code> on it. The page will be loaded if this method returns
+ * GeckoResult.ALLOW.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update">
+ * WebExtensions/API/tabs/update</a>
+ *
+ * @param extension The extension that requested to update the tab.
+ * @param session The {@link GeckoSession} instance that needs to be updated.
+ * @param details {@link UpdateTabDetails} instance that describes what needs to be updated for
+ * this tab.
+ * @return <code>GeckoResult.ALLOW</code> if the tab will be updated, <code>GeckoResult.DENY
+ * </code> otherwise.
+ */
+ @UiThread
+ @NonNull
+ default GeckoResult<AllowOrDeny> onUpdateTab(
+ final @NonNull WebExtension extension,
+ final @NonNull GeckoSession session,
+ final @NonNull UpdateTabDetails details) {
+ return GeckoResult.deny();
+ }
+ }
+
+ /**
+ * Provides details about upating a tab with <code>tabs.update</code>.
+ *
+ * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update">
+ * WebExtensions/API/tabs/update </a>.
+ */
+ public static class UpdateTabDetails {
+ /**
+ * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs
+ * should stop being highlighted. If <code>false</code>, does nothing.
+ */
+ @Nullable public final Boolean active;
+ /** Whether the tab should be discarded automatically by the app when resources are low. */
+ @Nullable public final Boolean autoDiscardable;
+ /** If <code>true</code> and the tab is not highlighted, it should become active by default. */
+ @Nullable public final Boolean highlighted;
+ /** Whether the tab should be muted. */
+ @Nullable public final Boolean muted;
+ /** Whether the tab should be pinned. */
+ @Nullable public final Boolean pinned;
+ /**
+ * The url that the tab will be navigated to. This url is provided just for informational
+ * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession}
+ * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link
+ * SessionTabDelegate#onUpdateTab}
+ */
+ @Nullable public final String url;
+
+ /** For testing. */
+ protected UpdateTabDetails() {
+ active = null;
+ autoDiscardable = null;
+ highlighted = null;
+ muted = null;
+ pinned = null;
+ url = null;
+ }
+
+ /* package */ UpdateTabDetails(final GeckoBundle bundle) {
+ active = bundle.getBooleanObject("active");
+ autoDiscardable = bundle.getBooleanObject("autoDiscardable");
+ highlighted = bundle.getBooleanObject("highlighted");
+ muted = bundle.getBooleanObject("muted");
+ pinned = bundle.getBooleanObject("pinned");
+ url = bundle.getString("url");
+ }
+ }
+
+ /**
+ * Provides details about creating a tab with <code>tabs.create</code>. See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create">
+ * WebExtensions/API/tabs/create </a>.
+ *
+ * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>.
+ */
+ public static class CreateTabDetails {
+ /**
+ * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs
+ * should stop being highlighted. If <code>false</code>, does nothing.
+ */
+ @Nullable public final Boolean active;
+ /**
+ * The CookieStoreId used for the tab. This option is only available if the extension has the
+ * "cookies" permission.
+ */
+ @Nullable public final String cookieStoreId;
+ /**
+ * Whether the tab is created and made visible in the tab bar without any content loaded into
+ * memory, a state known as discarded. The tab’s content should be loaded when the tab is
+ * activated.
+ */
+ @Nullable public final Boolean discarded;
+ /** The position the tab should take in the window. */
+ @Nullable public final Integer index;
+ /** If true, open this tab in Reader Mode. */
+ @Nullable public final Boolean openInReaderMode;
+ /** Whether the tab should be pinned. */
+ @Nullable public final Boolean pinned;
+ /**
+ * The url that the tab will be navigated to. This url is provided just for informational
+ * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession}
+ * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link
+ * TabDelegate#onNewTab}
+ */
+ @Nullable public final String url;
+
+ /** For testing. */
+ protected CreateTabDetails() {
+ active = null;
+ cookieStoreId = null;
+ discarded = null;
+ index = null;
+ openInReaderMode = null;
+ pinned = null;
+ url = null;
+ }
+
+ /* package */ CreateTabDetails(final GeckoBundle bundle) {
+ active = bundle.getBooleanObject("active");
+ cookieStoreId = bundle.getString("cookieStoreId");
+ discarded = bundle.getBooleanObject("discarded");
+ index = bundle.getInteger("index");
+ openInReaderMode = bundle.getBooleanObject("openInReaderMode");
+ pinned = bundle.getBooleanObject("pinned");
+ url = bundle.getString("url");
+ }
+ }
+
+ /**
+ * This delegate is invoked whenever an extension uses the `tabs` WebExtension API and the request
+ * is not specific to an existing tab, e.g. when creating a new tab. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ */
+ public interface TabDelegate {
+ /**
+ * Called when tabs.create is invoked, this method returns a *newly-created* session that
+ * GeckoView will use to load the requested page on. If the returned value is null the page will
+ * not be opened.
+ *
+ * @param source An instance of {@link WebExtension}
+ * @param createDetails Information about this tab.
+ * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which
+ * case the request for a new tab by the extension will fail. The implementation of onNewTab
+ * is responsible for maintaining a reference to the returned object, to prevent it from
+ * being garbage collected.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onNewTab(
+ @NonNull final WebExtension source, @NonNull final CreateTabDetails createDetails) {
+ return null;
+ }
+
+ /**
+ * Called when runtime.openOptionsPage is invoked with options_ui.open_in_tab = false. In this
+ * case, GeckoView delegates options page handling to the app. With options_ui.open_in_tab =
+ * true, {@link #onNewTab} is called instead.
+ *
+ * @param source An instance of {@link WebExtension}.
+ */
+ @UiThread
+ default void onOpenOptionsPage(@NonNull final WebExtension source) {}
+ }
+
+ /**
+ * Get the tab delegate for this extension.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ *
+ * @return The {@link TabDelegate} instance for this extension.
+ */
+ @UiThread
+ @Nullable
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mDelegateController.getTabDelegate();
+ }
+
+ /**
+ * Set the tab delegate for this extension. This delegate will be invoked whenever this extension
+ * tries to modify the tabs state using the `tabs` WebExtension API.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ *
+ * @param delegate the {@link TabDelegate} instance for this extension.
+ */
+ @UiThread
+ public void setTabDelegate(final @Nullable TabDelegate delegate) {
+ mDelegateController.onTabDelegate(delegate);
+ }
+
+ @UiThread
+ @Nullable
+ public BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mDelegateController.getBrowsingDataDelegate();
+ }
+
+ @UiThread
+ public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) {
+ mDelegateController.onBrowsingDataDelegate(delegate);
+ }
+
+ private static class Sender {
+ public String webExtensionId;
+ public String nativeApp;
+
+ public Sender(final String webExtensionId, final String nativeApp) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof Sender)) {
+ return false;
+ }
+
+ final Sender o = (Sender) other;
+ return webExtensionId.equals(o.webExtensionId) && nativeApp.equals(o.nativeApp);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp});
+ }
+ }
+
+ // Public wrapper for Listener
+ public static class SessionController {
+ private final Listener<SessionTabDelegate> mListener;
+
+ /* package */ void setRuntime(final GeckoRuntime runtime) {
+ mListener.runtime = runtime;
+ }
+
+ /* package */ SessionController(final GeckoSession session) {
+ mListener = new Listener<>(session);
+ }
+
+ /**
+ * Defines a message delegate for a Native App.
+ *
+ * <p>If a delegate is already present, this delegate will replace the existing one.
+ *
+ * <p>This message delegate will be responsible for handling messaging between a WebExtension
+ * content script running on the {@link GeckoSession}.
+ *
+ * <p>Note: To receive messages from content scripts, the WebExtension needs to explicitely
+ * allow it in {@link WebExtension#WebExtension} by setting {@link
+ * Flags#ALLOW_CONTENT_MESSAGING}.
+ *
+ * @param webExtension {@link WebExtension} that this delegate receives messages from.
+ * @param delegate {@link MessageDelegate} that will receive messages from this session.
+ * @param nativeApp which native app id this message delegate will handle messaging for.
+ * @see WebExtension#setMessageDelegate
+ */
+ @AnyThread
+ public void setMessageDelegate(
+ final @NonNull WebExtension webExtension,
+ final @Nullable WebExtension.MessageDelegate delegate,
+ final @NonNull String nativeApp) {
+ mListener.setMessageDelegate(webExtension, delegate, nativeApp);
+ }
+
+ /**
+ * Get the message delegate for <code>nativeApp</code>.
+ *
+ * @param extension {@link WebExtension} that this delegate receives messages from.
+ * @param nativeApp identifier for the native app
+ * @return The {@link MessageDelegate} attached to the <code>nativeApp</code>. <code>null</code>
+ * if no delegate is present.
+ */
+ @AnyThread
+ public @Nullable WebExtension.MessageDelegate getMessageDelegate(
+ final @NonNull WebExtension extension, final @NonNull String nativeApp) {
+ return mListener.getMessageDelegate(extension, nativeApp);
+ }
+
+ /**
+ * Set the Action delegate for this session.
+ *
+ * <p>This delegate will receive page and browser action overrides specific to this session. The
+ * default Action will be received by the delegate set by {@link
+ * WebExtension#setActionDelegate}.
+ *
+ * @param extension the {@link WebExtension} object this delegate will receive updates for
+ * @param delegate the {@link ActionDelegate} that will receive updates.
+ * @see WebExtension.Action
+ */
+ @AnyThread
+ public void setActionDelegate(
+ final @NonNull WebExtension extension, final @Nullable ActionDelegate delegate) {
+ mListener.setActionDelegate(extension, delegate);
+ }
+
+ /**
+ * Get the Action delegate for this session.
+ *
+ * @param extension {@link WebExtension} that this delegates receive updates for.
+ * @return {@link ActionDelegate} for this session
+ */
+ @AnyThread
+ @Nullable
+ public ActionDelegate getActionDelegate(final @NonNull WebExtension extension) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ /**
+ * Set the TabDelegate for this session.
+ *
+ * <p>This delegate will receive messages specific for this session coming from the WebExtension
+ * <code>tabs</code> API.
+ *
+ * @param extension the {@link WebExtension} this delegate will receive updates for
+ * @param delegate the {@link TabDelegate} that will receive updates.
+ * @see WebExtension#setTabDelegate
+ */
+ @AnyThread
+ public void setTabDelegate(
+ final @NonNull WebExtension extension, final @Nullable SessionTabDelegate delegate) {
+ mListener.setTabDelegate(extension, delegate);
+ }
+
+ /**
+ * Get the TabDelegate for the given extension.
+ *
+ * @param extension the {@link WebExtension} this delegate refers to.
+ * @return the current {@link SessionTabDelegate} instance
+ */
+ @AnyThread
+ @Nullable
+ public SessionTabDelegate getTabDelegate(final @NonNull WebExtension extension) {
+ return mListener.getTabDelegate(extension);
+ }
+ }
+
+ /* package */ static final class Listener<TabDelegate> implements BundleEventListener {
+ private final HashMap<Sender, MessageDelegate> mMessageDelegates;
+ private final HashMap<String, ActionDelegate> mActionDelegates;
+ private final HashMap<String, BrowsingDataDelegate> mBrowsingDataDelegates;
+ private final HashMap<String, TabDelegate> mTabDelegates;
+ private final HashMap<String, DownloadDelegate> mDownloadDelegates;
+
+ private final GeckoSession mSession;
+ private final EventDispatcher mEventDispatcher;
+
+ private boolean mActionDelegateRegistered = false;
+ private boolean mBrowsingDataDelegateRegistered = false;
+ private boolean mTabDelegateRegistered = false;
+
+ public GeckoRuntime runtime;
+
+ public Listener(final GeckoRuntime runtime) {
+ this(null, runtime);
+ }
+
+ public Listener(final GeckoSession session) {
+ this(session, null);
+
+ // Close tab event is forwarded to the main listener so we need to listen
+ // to it here.
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:NewTab",
+ "GeckoView:WebExtension:UpdateTab",
+ "GeckoView:WebExtension:CloseTab",
+ "GeckoView:WebExtension:OpenOptionsPage");
+ mTabDelegateRegistered = true;
+ }
+
+ private Listener(final GeckoSession session, final GeckoRuntime runtime) {
+ mMessageDelegates = new HashMap<>();
+ mActionDelegates = new HashMap<>();
+ mBrowsingDataDelegates = new HashMap<>();
+ mTabDelegates = new HashMap<>();
+ mDownloadDelegates = new HashMap<>();
+ mEventDispatcher =
+ session != null ? session.getEventDispatcher() : EventDispatcher.getInstance();
+ mSession = session;
+ this.runtime = runtime;
+
+ // We queue these messages if the delegate has not been attached yet,
+ // so we need to start listening immediately.
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:Message",
+ "GeckoView:WebExtension:PortMessage",
+ "GeckoView:WebExtension:Connect",
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:BrowsingData:GetSettings",
+ "GeckoView:BrowsingData:Clear",
+ "GeckoView:WebExtension:Download");
+ }
+
+ public void unregisterWebExtension(final WebExtension extension) {
+ mMessageDelegates.remove(extension.id);
+ mActionDelegates.remove(extension.id);
+ mBrowsingDataDelegates.remove(extension.id);
+ mTabDelegates.remove(extension.id);
+ mDownloadDelegates.remove(extension.id);
+ }
+
+ public void setTabDelegate(final WebExtension webExtension, final TabDelegate delegate) {
+ if (!mTabDelegateRegistered && delegate != null) {
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:NewTab",
+ "GeckoView:WebExtension:UpdateTab",
+ "GeckoView:WebExtension:CloseTab",
+ "GeckoView:WebExtension:OpenOptionsPage");
+ mTabDelegateRegistered = true;
+ }
+
+ mTabDelegates.put(webExtension.id, delegate);
+ }
+
+ public TabDelegate getTabDelegate(final WebExtension webExtension) {
+ return mTabDelegates.get(webExtension.id);
+ }
+
+ public void setBrowsingDataDelegate(
+ final WebExtension webExtension, final BrowsingDataDelegate delegate) {
+ mBrowsingDataDelegates.put(webExtension.id, delegate);
+ }
+
+ public BrowsingDataDelegate getBrowsingDataDelegate(final WebExtension webExtension) {
+ return mBrowsingDataDelegates.get(webExtension.id);
+ }
+
+ public void setActionDelegate(
+ final WebExtension webExtension, final WebExtension.ActionDelegate delegate) {
+ if (!mActionDelegateRegistered && delegate != null) {
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:BrowserAction:Update",
+ "GeckoView:BrowserAction:OpenPopup",
+ "GeckoView:PageAction:Update",
+ "GeckoView:PageAction:OpenPopup");
+ mActionDelegateRegistered = true;
+ }
+
+ mActionDelegates.put(webExtension.id, delegate);
+ }
+
+ public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) {
+ return mActionDelegates.get(webExtension.id);
+ }
+
+ public void setMessageDelegate(
+ final WebExtension webExtension,
+ final WebExtension.MessageDelegate delegate,
+ final String nativeApp) {
+ mMessageDelegates.put(new Sender(webExtension.id, nativeApp), delegate);
+
+ if (runtime != null && delegate != null) {
+ runtime
+ .getWebExtensionController()
+ .releasePendingMessages(webExtension, nativeApp, mSession);
+ }
+ }
+
+ public WebExtension.MessageDelegate getMessageDelegate(
+ final WebExtension webExtension, final String nativeApp) {
+ return mMessageDelegates.get(new Sender(webExtension.id, nativeApp));
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (runtime == null) {
+ return;
+ }
+
+ runtime.getWebExtensionController().handleMessage(event, message, callback, mSession);
+ }
+
+ public void setDownloadDelegate(
+ final @NonNull WebExtension extension, final @Nullable DownloadDelegate delegate) {
+ mDownloadDelegates.put(extension.id, delegate);
+ }
+
+ public WebExtension.DownloadDelegate getDownloadDelegate(final WebExtension extension) {
+ return mDownloadDelegates.get(extension.id);
+ }
+ }
+
+ /**
+ * Describes the sender of a message from a WebExtension.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender">
+ * WebExtensions/API/runtime/MessageSender</a>
+ */
+ @UiThread
+ public static class MessageSender {
+ /** {@link WebExtension} that sent this message. */
+ public final @NonNull WebExtension webExtension;
+
+ /**
+ * {@link GeckoSession} that sent this message. <code>null</code> if coming from a background
+ * script.
+ */
+ public final @Nullable GeckoSession session;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ENV_TYPE_UNKNOWN, ENV_TYPE_EXTENSION, ENV_TYPE_CONTENT_SCRIPT})
+ public @interface EnvType {}
+ /* package */ static final int ENV_TYPE_UNKNOWN = 0;
+ /** This sender originated inside a privileged extension context like a background script. */
+ public static final int ENV_TYPE_EXTENSION = 1;
+
+ /** This sender originated inside a content script. */
+ public static final int ENV_TYPE_CONTENT_SCRIPT = 2;
+
+ /**
+ * Type of environment that sent this message, either
+ *
+ * <ul>
+ * <li>{@link MessageSender#ENV_TYPE_EXTENSION} if the message was sent from a background page
+ * <li>{@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent from a content
+ * script
+ * </ul>
+ */
+ // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ?
+ public final @EnvType int environmentType;
+
+ /**
+ * URL of the frame that sent this message.
+ *
+ * <p>Use this value together with {@link MessageSender#isTopLevel} to verify that the message
+ * is coming from the expected page. Only top level frames can be trusted.
+ */
+ public final @NonNull String url;
+
+ /* package */ final boolean isTopLevel;
+
+ /* package */ MessageSender(
+ final @NonNull WebExtension webExtension,
+ final @Nullable GeckoSession session,
+ final @Nullable String url,
+ final @EnvType int environmentType,
+ final boolean isTopLevel) {
+ this.webExtension = webExtension;
+ this.session = session;
+ this.isTopLevel = isTopLevel;
+ this.url = url;
+ this.environmentType = environmentType;
+ }
+
+ /** Override for testing. */
+ protected MessageSender() {
+ this.webExtension = null;
+ this.session = null;
+ this.isTopLevel = false;
+ this.url = null;
+ this.environmentType = ENV_TYPE_UNKNOWN;
+ }
+
+ /**
+ * Whether this MessageSender belongs to a top level frame.
+ *
+ * @return true if the MessageSender was sent from the top level frame, false otherwise.
+ */
+ public boolean isTopLevel() {
+ return this.isTopLevel;
+ }
+ }
+
+ /* package */ static WebExtension fromBundle(
+ final DelegateControllerProvider provider, final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new WebExtension(provider, bundle.getBundle("extension"));
+ }
+
+ /**
+ * Represents either a Browser Action or a Page Action from the WebExtension API.
+ *
+ * <p>Instances of this class may represent the default <code>Action</code> which applies to all
+ * WebExtension tabs or a tab-specific override. To reconstruct the full <code>Action</code>
+ * object, you can use {@link Action#withDefault}.
+ *
+ * <p>Tab specific overrides can be obtained by registering a delegate using {@link
+ * SessionController#setActionDelegate}, while default values can be obtained by registering a
+ * delegate using {@link #setActionDelegate}. <br>
+ * See also
+ *
+ * <ul>
+ * <li><a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+ * WebExtensions/API/browserAction </a>
+ * <li><a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+ * WebExtensions/API/pageAction </a>
+ * </ul>
+ */
+ @AnyThread
+ public static class Action {
+ /**
+ * Title of this Action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getTitle">
+ * pageAction/getTitle</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getTitle">
+ * browserAction/getTitle</a>
+ */
+ public final @Nullable String title;
+ /**
+ * Icon for this Action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/setIcon">
+ * pageAction/setIcon</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon">
+ * browserAction/setIcon</a>
+ */
+ public final @Nullable Image icon;
+ /**
+ * Whether this action is enabled and should be visible.
+ *
+ * <p>Note: for page action, this is <code>true</code> when the extension calls <code>
+ * pageAction.show</code> and <code>false</code> when the extension calls <code>pageAction.hide
+ * </code>.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show">
+ * pageAction/show</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/enabled">
+ * browserAction/enabled</a>
+ */
+ public final @Nullable Boolean enabled;
+ /**
+ * Badge text for this action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText">
+ * browserAction/getBadgeText</a>
+ */
+ public final @Nullable String badgeText;
+ /**
+ * Background color for the badge for this Action.
+ *
+ * <p>This method will return an Android color int that can be used in {@link
+ * android.widget.TextView#setBackgroundColor(int)} and similar methods.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor">
+ * browserAction/getBadgeBackgroundColor</a>
+ */
+ public final @Nullable Integer badgeBackgroundColor;
+ /**
+ * Text color for the badge for this Action.
+ *
+ * <p>This method will return an Android color int that can be used in {@link
+ * android.widget.TextView#setTextColor(int)} and similar methods.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor">
+ * browserAction/getBadgeTextColor</a>
+ */
+ public final @Nullable Integer badgeTextColor;
+
+ private final WebExtension mExtension;
+
+ /* package */ static final int TYPE_BROWSER_ACTION = 1;
+ /* package */ static final int TYPE_PAGE_ACTION = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION})
+ public @interface ActionType {}
+
+ /* package */ final @ActionType int type;
+
+ /* package */ Action(
+ final @ActionType int type, final GeckoBundle bundle, final WebExtension extension) {
+ mExtension = extension;
+
+ this.type = type;
+
+ title = bundle.getString("title");
+ badgeText = bundle.getString("badgeText");
+ badgeBackgroundColor = colorFromRgbaArray(bundle.getDoubleArray("badgeBackgroundColor"));
+ badgeTextColor = colorFromRgbaArray(bundle.getDoubleArray("badgeTextColor"));
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+
+ if (bundle.getBoolean("patternMatching", false)) {
+ // This action was enabled by pattern matching
+ enabled = true;
+ } else if (bundle.containsKey("enabled")) {
+ enabled = bundle.getBoolean("enabled");
+ } else {
+ enabled = null;
+ }
+ }
+
+ private Integer colorFromRgbaArray(final double[] c) {
+ if (c == null) {
+ return null;
+ }
+
+ return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]);
+ }
+
+ @Override
+ public String toString() {
+ return "Action {\n"
+ + "\ttitle: "
+ + this.title
+ + ",\n"
+ + "\ticon: "
+ + this.icon
+ + ",\n"
+ + "\tenabled: "
+ + this.enabled
+ + ",\n"
+ + "\tbadgeText: "
+ + this.badgeText
+ + ",\n"
+ + "\tbadgeTextColor: "
+ + this.badgeTextColor
+ + ",\n"
+ + "\tbadgeBackgroundColor: "
+ + this.badgeBackgroundColor
+ + ",\n"
+ + "}";
+ }
+
+ // For testing
+ protected Action() {
+ type = TYPE_BROWSER_ACTION;
+ mExtension = null;
+ title = null;
+ icon = null;
+ enabled = null;
+ badgeText = null;
+ badgeTextColor = null;
+ badgeBackgroundColor = null;
+ }
+
+ /**
+ * Merges values from this Action with the default Action.
+ *
+ * @param defaultValue the default Action as received from {@link
+ * ActionDelegate#onBrowserAction} or {@link ActionDelegate#onPageAction}.
+ * @return an {@link Action} where all <code>null</code> values from this instance are replaced
+ * with values from <code>defaultValue</code>.
+ * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action
+ * is a Page Action and default value is a Browser Action.
+ */
+ @NonNull
+ public Action withDefault(final @NonNull Action defaultValue) {
+ return new Action(this, defaultValue);
+ }
+
+ /**
+ * @see Action#withDefault
+ */
+ private Action(final Action source, final Action defaultValue) {
+ if (source.type != defaultValue.type) {
+ throw new IllegalArgumentException("defaultValue must be of the same type.");
+ }
+
+ type = source.type;
+ mExtension = source.mExtension;
+
+ title = source.title != null ? source.title : defaultValue.title;
+ icon = source.icon != null ? source.icon : defaultValue.icon;
+ enabled = source.enabled != null ? source.enabled : defaultValue.enabled;
+ badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText;
+ badgeTextColor =
+ source.badgeTextColor != null ? source.badgeTextColor : defaultValue.badgeTextColor;
+ badgeBackgroundColor =
+ source.badgeBackgroundColor != null
+ ? source.badgeBackgroundColor
+ : defaultValue.badgeBackgroundColor;
+ }
+
+ /** Notifies the extension that the user has clicked on this Action. */
+ @UiThread
+ public void click() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", mExtension.id);
+
+ // The click event will return the popup uri if we should open a popup in
+ // response to clicking on the action button.
+ final GeckoResult<String> popupUri;
+ if (type == TYPE_BROWSER_ACTION) {
+ popupUri =
+ EventDispatcher.getInstance().queryString("GeckoView:BrowserAction:Click", bundle);
+ } else if (type == TYPE_PAGE_ACTION) {
+ popupUri = EventDispatcher.getInstance().queryString("GeckoView:PageAction:Click", bundle);
+ } else {
+ throw new IllegalStateException("Unknown Action type");
+ }
+
+ popupUri.accept(
+ uri -> {
+ if (uri == null || uri.isEmpty()) {
+ return;
+ }
+
+ final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate();
+ if (delegate == null) {
+ return;
+ }
+
+ // The .accept method will be called from the UIThread in this case because
+ // the GeckoResult instance was created on the UIThread
+ @SuppressLint("WrongThread")
+ final GeckoResult<GeckoSession> popup = delegate.onTogglePopup(mExtension, this);
+ openPopup(popup, uri);
+ });
+ }
+
+ /* package */ void openPopup(final GeckoResult<GeckoSession> popup, final String popupUri) {
+ if (popup == null) {
+ return;
+ }
+
+ popup.accept(
+ session -> {
+ if (session == null) {
+ return;
+ }
+
+ session.getSettings().setIsPopup(true);
+ session.loadUri(popupUri);
+ });
+ }
+ }
+
+ /**
+ * Receives updates whenever a Browser action or a Page action has been defined by an extension.
+ *
+ * <p>This delegate will receive the default action when registered with {@link
+ * WebExtension#setActionDelegate}. To receive {@link GeckoSession}-specific overrides you can use
+ * {@link SessionController#setActionDelegate}.
+ */
+ public interface ActionDelegate {
+ /**
+ * Called whenever a browser action is defined or updated.
+ *
+ * <p>This method will be called whenever an extension that defines a browser action is
+ * registered or the properties of the Action are updated.
+ *
+ * <p>See also <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+ * WebExtensions/API/browserAction </a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_action">
+ * WebExtensions/manifest.json/browser_action </a>.
+ *
+ * @param extension The extension that defined this browser action.
+ * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action
+ * override applies. <code>null</code> if <code>action</code> is the new default value.
+ * @param action {@link Action} containing the override values for this {@link GeckoSession} or
+ * the default value if <code>session</code> is <code>null</code>.
+ */
+ @UiThread
+ default void onBrowserAction(
+ final @NonNull WebExtension extension,
+ final @Nullable GeckoSession session,
+ final @NonNull Action action) {}
+ /**
+ * Called whenever a page action is defined or updated.
+ *
+ * <p>This method will be called whenever an extension that defines a page action is registered
+ * or the properties of the Action are updated.
+ *
+ * <p>See also <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+ * WebExtensions/API/pageAction </a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action">
+ * WebExtensions/manifest.json/page_action </a>.
+ *
+ * @param extension The extension that defined this page action.
+ * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action
+ * override applies. <code>null</code> if <code>action</code> is the new default value.
+ * @param action {@link Action} containing the override values for this {@link GeckoSession} or
+ * the default value if <code>session</code> is <code>null</code>.
+ */
+ @UiThread
+ default void onPageAction(
+ final @NonNull WebExtension extension,
+ final @Nullable GeckoSession session,
+ final @NonNull Action action) {}
+
+ /**
+ * Called whenever the action wants to toggle a popup view.
+ *
+ * @param extension The extension that wants to display a popup
+ * @param action The action where the popup is defined
+ * @return A GeckoSession that will be used to display the pop-up, null if no popup will be
+ * displayed.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onTogglePopup(
+ final @NonNull WebExtension extension, final @NonNull Action action) {
+ return null;
+ }
+
+ /**
+ * Called whenever the action wants to open a popup view.
+ *
+ * @param extension The extension that wants to display a popup
+ * @param action The action where the popup is defined
+ * @return A GeckoSession that will be used to display the pop-up, null if no popup will be
+ * displayed.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onOpenPopup(
+ final @NonNull WebExtension extension, final @NonNull Action action) {
+ return null;
+ }
+ }
+
+ /** Extension thrown when an error occurs during extension installation. */
+ public static class InstallException extends Exception {
+ public static class ErrorCodes {
+ /** The download failed due to network problems. */
+ public static final int ERROR_NETWORK_FAILURE = -1;
+ /** The downloaded file did not match the provided hash. */
+ public static final int ERROR_INCORRECT_HASH = -2;
+ /** The downloaded file seems to be corrupted in some way. */
+ public static final int ERROR_CORRUPT_FILE = -3;
+ /** An error occurred trying to write to the filesystem. */
+ public static final int ERROR_FILE_ACCESS = -4;
+ /** The extension must be signed and isn't. */
+ public static final int ERROR_SIGNEDSTATE_REQUIRED = -5;
+ /** The downloaded extension had a different type than expected. */
+ public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6;
+ /** The downloaded extension had a different version than expected */
+ public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9;
+ /** The extension did not have the expected ID. */
+ public static final int ERROR_INCORRECT_ID = -7;
+ /** The extension did not have the expected ID. */
+ public static final int ERROR_INVALID_DOMAIN = -8;
+ /** The extension install was canceled. */
+ public static final int ERROR_USER_CANCELED = -100;
+ /** The extension install was postponed until restart. */
+ public static final int ERROR_POSTPONED = -101;
+
+ /** For testing. */
+ protected ErrorCodes() {}
+ }
+
+ /** These states should match gecko's AddonManager.STATE_* constants. */
+ private static class StateCodes {
+ public static final int STATE_POSTPONED = 7;
+ public static final int STATE_CANCELED = 12;
+ }
+
+ /* package */ static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) {
+ final GeckoBundle bundle = (GeckoBundle) response;
+ int errorCode = bundle.getInt("installError");
+ final int installState = bundle.getInt("state");
+ if (errorCode == 0 && installState == StateCodes.STATE_CANCELED) {
+ errorCode = ErrorCodes.ERROR_USER_CANCELED;
+ } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) {
+ errorCode = ErrorCodes.ERROR_POSTPONED;
+ }
+ return new WebExtension.InstallException(errorCode);
+ } else {
+ return new Exception(response.toString());
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ErrorCodes.ERROR_NETWORK_FAILURE,
+ ErrorCodes.ERROR_INCORRECT_HASH,
+ ErrorCodes.ERROR_CORRUPT_FILE,
+ ErrorCodes.ERROR_FILE_ACCESS,
+ ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION,
+ ErrorCodes.ERROR_INCORRECT_ID,
+ ErrorCodes.ERROR_INVALID_DOMAIN,
+ ErrorCodes.ERROR_USER_CANCELED,
+ ErrorCodes.ERROR_POSTPONED,
+ })
+ public @interface Codes {}
+
+ /** One of {@link ErrorCodes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ /** For testing */
+ protected InstallException() {
+ this.code = ErrorCodes.ERROR_NETWORK_FAILURE;
+ }
+
+ @Override
+ public String toString() {
+ return "InstallException: " + code;
+ }
+
+ /* package */ InstallException(final @Codes int code) {
+ this.code = code;
+ }
+ }
+
+ /**
+ * Set the Action delegate for this WebExtension.
+ *
+ * <p>This delegate will receive updates every time the default Action value changes.
+ *
+ * <p>To listen for {@link GeckoSession}-specific updates, use {@link
+ * SessionController#setActionDelegate}
+ *
+ * @param delegate {@link ActionDelegate} that will receive updates.
+ */
+ @AnyThread
+ public void setActionDelegate(final @Nullable ActionDelegate delegate) {
+ mDelegateController.onActionDelegate(delegate);
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ if (delegate != null) {
+ EventDispatcher.getInstance().dispatch("GeckoView:ActionDelegate:Attached", bundle);
+ }
+ }
+
+ /**
+ * Describes the signed status for a {@link WebExtension}.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on signing
+ * in Firefox. </a>
+ */
+ public static class SignedStateFlags {
+ // Keep in sync with AddonManager.jsm
+ /**
+ * This extension may be signed but by a certificate that doesn't chain to our our trusted
+ * certificate.
+ */
+ public static final int UNKNOWN = -1;
+ /** This extension is unsigned. */
+ public static final int MISSING = 0;
+ /** This extension has been preliminarily reviewed. */
+ public static final int PRELIMINARY = 1;
+ /** This extension has been fully reviewed. */
+ public static final int SIGNED = 2;
+ /** This extension is a system add-on. */
+ public static final int SYSTEM = 3;
+ /** This extension is signed with a "Mozilla Extensions" certificate. */
+ public static final int PRIVILEGED = 4;
+
+ /* package */ static final int LAST = PRIVILEGED;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SignedStateFlags.UNKNOWN,
+ SignedStateFlags.MISSING,
+ SignedStateFlags.PRELIMINARY,
+ SignedStateFlags.SIGNED,
+ SignedStateFlags.SYSTEM,
+ SignedStateFlags.PRIVILEGED
+ })
+ public @interface SignedState {}
+
+ /**
+ * Describes the blocklist state for a {@link WebExtension}. See <a
+ * href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">Add-ons that
+ * cause stability or security issues are put on a blocklist </a>.
+ */
+ public static class BlocklistStateFlags {
+ // Keep in sync with nsIBlocklistService.idl
+ /** This extension does not appear in the blocklist. */
+ public static final int NOT_BLOCKED = 0;
+ /**
+ * This extension is in the blocklist but the problem is not severe enough to warant forcibly
+ * blocking.
+ */
+ public static final int SOFTBLOCKED = 1;
+ /** This extension should be blocked and never used. */
+ public static final int BLOCKED = 2;
+ /** This extension is considered outdated, and there is a known update available. */
+ public static final int OUTDATED = 3;
+ /** This extension is vulnerable and there is an update. */
+ public static final int VULNERABLE_UPDATE_AVAILABLE = 4;
+ /** This extension is vulnerable and there is no update. */
+ public static final int VULNERABLE_NO_UPDATE = 5;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ BlocklistStateFlags.NOT_BLOCKED,
+ BlocklistStateFlags.SOFTBLOCKED,
+ BlocklistStateFlags.BLOCKED,
+ BlocklistStateFlags.OUTDATED,
+ BlocklistStateFlags.VULNERABLE_UPDATE_AVAILABLE,
+ BlocklistStateFlags.VULNERABLE_NO_UPDATE
+ })
+ public @interface BlocklistState {}
+
+ public static class DisabledFlags {
+ /** The extension has been disabled by the user */
+ public static final int USER = 1 << 1;
+
+ /**
+ * The extension has been disabled by the blocklist. The details of why this extension was
+ * blocked can be found in {@link MetaData#blocklistState}.
+ */
+ public static final int BLOCKLIST = 1 << 2;
+
+ /**
+ * The extension has been disabled by the application. To enable the extension you can use
+ * {@link WebExtensionController#enable} passing in {@link
+ * WebExtensionController.EnableSource#APP} as <code>source</code>.
+ */
+ public static final int APP = 1 << 3;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP})
+ public @interface EnabledFlags {}
+
+ /** Provides information about a {@link WebExtension}. */
+ public class MetaData {
+ /**
+ * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying
+ * prompts.
+ */
+ public final @NonNull Image icon;
+ /**
+ * API permissions requested or granted to this extension.
+ *
+ * <p>Permission identifiers match entries in the manifest, see <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions">
+ * API permissions </a>.
+ */
+ public final @NonNull String[] permissions;
+ /**
+ * Host permissions requested or granted to this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions">
+ * Host permissions </a>.
+ */
+ public final @NonNull String[] origins;
+ /**
+ * Branding name for this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name">
+ * manifest.json/name </a>
+ */
+ public final @Nullable String name;
+ /**
+ * Branding description for this extension. This string will be localized using the current
+ * GeckoView language setting.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description">
+ * manifest.json/description </a>
+ */
+ public final @Nullable String description;
+ /**
+ * Version string for this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version">
+ * manifest.json/version </a>
+ */
+ public final @NonNull String version;
+ /**
+ * Creator name as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
+ * manifest.json/developer </a>
+ */
+ public final @Nullable String creatorName;
+ /**
+ * Creator url as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
+ * manifest.json/developer </a>
+ */
+ public final @Nullable String creatorUrl;
+ /**
+ * Homepage url as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url">
+ * manifest.json/homepage_url </a>
+ */
+ public final @Nullable String homepageUrl;
+ /**
+ * Options page as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui">
+ * manifest.json/options_ui </a>
+ */
+ public final @Nullable String optionsPageUrl;
+ /**
+ * Whether the options page should be open in a Tab or not.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#Syntax">
+ * manifest.json/options_ui#Syntax </a>
+ */
+ public final boolean openOptionsPageInTab;
+ /**
+ * Whether or not this is a recommended extension.
+ *
+ * <p>See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">Recommended
+ * Extensions program </a>
+ */
+ public final boolean isRecommended;
+ /**
+ * Blocklist status for this extension.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">
+ * Add-ons that cause stability or security issues are put on a blocklist </a>.
+ */
+ public final @BlocklistState int blocklistState;
+ /**
+ * Signed status for this extension.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on
+ * signing in Firefox. </a>.
+ */
+ public final @SignedState int signedState;
+
+ /**
+ * Disabled binary flags for this extension.
+ *
+ * <p>This will be either equal to <code>0</code> if the extension is enabled or will contain
+ * one or more flags from {@link DisabledFlags}.
+ *
+ * <p>e.g. if the extension has been disabled by the user, the value in {@link
+ * DisabledFlags#USER} will be equal to <code>1</code>:
+ *
+ * <pre><code>
+ * boolean isUserDisabled = metaData.disabledFlags
+ * &amp; DisabledFlags.USER &gt; 0;
+ * </code></pre>
+ */
+ public final @EnabledFlags int disabledFlags;
+
+ /**
+ * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this
+ * extension.
+ */
+ public final @NonNull String baseUrl;
+
+ /**
+ * Whether this extension is allowed to run in private browsing or not. To modify this value use
+ * {@link WebExtensionController#setAllowedInPrivateBrowsing}.
+ */
+ public final boolean allowedInPrivateBrowsing;
+
+ /** Whether this extension is enabled or not. */
+ public final boolean enabled;
+
+ /**
+ * Whether this extension is temporary or not. Temporary extensions are not retained and will be
+ * uninstalled when the browser exits.
+ */
+ public final boolean temporary;
+
+ /** Override for testing. */
+ protected MetaData() {
+ icon = null;
+ permissions = null;
+ origins = null;
+ name = null;
+ description = null;
+ version = null;
+ creatorName = null;
+ creatorUrl = null;
+ homepageUrl = null;
+ optionsPageUrl = null;
+ openOptionsPageInTab = false;
+ isRecommended = false;
+ blocklistState = BlocklistStateFlags.NOT_BLOCKED;
+ signedState = SignedStateFlags.UNKNOWN;
+ disabledFlags = 0;
+ enabled = true;
+ temporary = false;
+ baseUrl = null;
+ allowedInPrivateBrowsing = false;
+ }
+
+ /* package */ MetaData(final GeckoBundle bundle) {
+ // We only expose permissions that the embedder should prompt for
+ permissions = bundle.getStringArray("promptPermissions");
+ origins = bundle.getStringArray("origins");
+ description = bundle.getString("description");
+ version = bundle.getString("version");
+ creatorName = bundle.getString("creatorName");
+ creatorUrl = bundle.getString("creatorURL");
+ homepageUrl = bundle.getString("homepageURL");
+ name = bundle.getString("name");
+ optionsPageUrl = bundle.getString("optionsPageURL");
+ openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab");
+ isRecommended = bundle.getBoolean("isRecommended");
+ blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED);
+ enabled = bundle.getBoolean("enabled", false);
+ temporary = bundle.getBoolean("temporary", false);
+ baseUrl = bundle.getString("baseURL");
+ allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false);
+
+ final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN);
+ if (signedState <= SignedStateFlags.LAST) {
+ this.signedState = signedState;
+ } else {
+ Log.e(LOGTAG, "Unrecognized signed state: " + signedState);
+ this.signedState = SignedStateFlags.UNKNOWN;
+ }
+
+ int disabledFlags = 0;
+ final String[] disabledFlagsString = bundle.getStringArray("disabledFlags");
+
+ for (final String flag : disabledFlagsString) {
+ if (flag.equals("userDisabled")) {
+ disabledFlags |= DisabledFlags.USER;
+ } else if (flag.equals("blocklistDisabled")) {
+ disabledFlags |= DisabledFlags.BLOCKLIST;
+ } else if (flag.equals("appDisabled")) {
+ disabledFlags |= DisabledFlags.APP;
+ } else {
+ Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag);
+ }
+ }
+ this.disabledFlags = disabledFlags;
+
+ if (bundle.containsKey("icons")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icons"));
+ } else {
+ icon = null;
+ }
+ }
+ }
+
+ // TODO: make public bug 1595822
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Context.NONE,
+ Context.BOOKMARK,
+ Context.BROWSER_ACTION,
+ Context.PAGE_ACTION,
+ Context.TAB,
+ Context.TOOLS_MENU
+ })
+ public @interface ContextFlags {}
+
+ /**
+ * Flags to determine which contexts a menu item should be shown in. See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ContextType>
+ * menus.ContextType</a>.
+ */
+ static class Context {
+ /** Shows the menu item in no contexts. */
+ static final int NONE = 0;
+
+ /**
+ * Shows the menu item when the user context-clicks an item on the bookmarks toolbar, bookmarks
+ * menu, bookmarks sidebar, or Library window.
+ */
+ static final int BOOKMARK = 1 << 1;
+
+ /** Shows the menu item when the user context-clicks the extension's browser action. */
+ static final int BROWSER_ACTION = 1 << 2;
+
+ /** Shows the menu item when the user context-clicks on the extension's page action. */
+ static final int PAGE_ACTION = 1 << 3;
+
+ /** Shows when the user context-clicks on a tab (such as the element on the tab bar.) */
+ static final int TAB = 1 << 4;
+
+ /** Adds the item to the browser's tools menu. */
+ static final int TOOLS_MENU = 1 << 5;
+ }
+
+ // TODO: make public bug 1595822
+
+ /**
+ * Represents an addition to the context menu by an extension.
+ *
+ * <p>In the <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus>menus</a>
+ * API, all elements added by one extension should be collapsed under one header. This class
+ * represents all of one extension's menu items, as well as the icon that should be used with that
+ * header.
+ */
+ static class Menu {
+ /** List of menu items that belong to this extension. */
+ final @NonNull List<MenuItem> items;
+
+ /** Icon for this extension. */
+ final @Nullable Image icon;
+
+ /** Title for the menu header. */
+ final @Nullable String title;
+
+ /** The extension adding this Menu to the context menu. */
+ final @NonNull WebExtension extension;
+
+ /* package */ Menu(final @NonNull WebExtension extension, final GeckoBundle bundle) {
+ this.extension = extension;
+ title = bundle.getString("title", "");
+ final GeckoBundle[] items = bundle.getBundleArray("items");
+ this.items = new ArrayList<>();
+ if (items != null) {
+ for (final GeckoBundle item : items) {
+ this.items.add(new MenuItem(this.extension, item));
+ }
+ }
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+ }
+
+ /** Notifies the extension that a user has opened the context menu. */
+ void show() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", extension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuShow", bundle);
+ }
+
+ /** Notifies the extension that a user has hidden the context menu. */
+ void hide() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", extension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuHide", bundle);
+ }
+ }
+
+ // TODO: make public bug 1595822
+ /**
+ * Represents an item in the menu.
+ *
+ * <p>If there is only one menu item in the list, the embedder should display that item as itself,
+ * not under a header.
+ */
+ static class MenuItem {
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = false,
+ value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR})
+ public @interface Type {}
+
+ /** A set of constants that represents the display type of this menu item. */
+ static class MenuType {
+ /**
+ * This represents a menu item that just displays a label.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.normal</a>
+ */
+ static final int NORMAL = 0;
+
+ /**
+ * This represents a menu item that can be selected and deselected.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.checkbox</a>
+ */
+ static final int CHECKBOX = 1;
+
+ /**
+ * This represents a menu item that is one of a group of choices. All menu items for an
+ * extension that are of type radio are part of one radio group.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.radio</a>
+ */
+ static final int RADIO = 2;
+
+ /**
+ * This represents a line separating elements.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.separator</a>
+ */
+ static final int SEPARATOR = 3;
+ }
+
+ /**
+ * Direct children for this menu item. These should be displayed as a sub-menu.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create>
+ * createProperties.parentId</a>
+ */
+ final @Nullable List<MenuItem> children;
+
+ /** One of the {@link Type} constants. Determines the type of the action. */
+ final @Type int type;
+
+ /**
+ * The id of this menu item. See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create>
+ * createProperties.id</a>
+ */
+ final @Nullable String id;
+
+ /** Determines if the menu item should be currently displayed. */
+ final boolean visible;
+
+ /** The title to be displayed for this menu item. */
+ final @Nullable String title;
+
+ /** Whether or not the menu item is initially checked. Defaults to false. */
+ final boolean checked;
+
+ /** Contexts that this menu item should be shown in. */
+ final @ContextFlags int contexts;
+
+ /** Icon for this menu item. */
+ final @Nullable Image icon;
+
+ final WebExtension mExtension;
+
+ /**
+ * Creates a new menu item using a bundle and a reference to the extension that this item
+ * belongs to.
+ *
+ * @param extension WebExtension object.
+ * @param bundle GeckoBundle containing the item information.
+ */
+ /* package */ MenuItem(final WebExtension extension, final GeckoBundle bundle) {
+ title = bundle.getString("title");
+ mExtension = extension;
+ checked = bundle.getBoolean("checked", false);
+ visible = bundle.getBoolean("visible", true);
+ id = bundle.getString("id");
+ contexts = bundle.getInt("contexts");
+ type = bundle.getInt("type");
+ children = new ArrayList<>();
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+ }
+
+ /** Notifies the extension that the user has clicked on this menu item. */
+ void click() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("menuId", this.id);
+ bundle.putString("extensionId", mExtension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuClick", bundle);
+ }
+ }
+
+ public interface DownloadDelegate {
+ /**
+ * Method that is called when Web Extension requests a download (when downloads.download() is
+ * called in Web Extension)
+ *
+ * @param source - Web Extension that requested the download
+ * @param request - contains the {@link WebRequest} and additional parameters for the request
+ * @return {@link DownloadInitData} instance
+ */
+ @AnyThread
+ @Nullable
+ default GeckoResult<WebExtension.DownloadInitData> onDownload(
+ @NonNull final WebExtension source, @NonNull final DownloadRequest request) {
+ return null;
+ }
+ }
+
+ /**
+ * Set the download delegate for this extension. This delegate will be invoked whenever this
+ * extension tries to use the `downloads` WebExtension API.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions/API/downloads</a>.
+ *
+ * @param delegate the {@link DownloadDelegate} instance for this extension.
+ */
+ @UiThread
+ public void setDownloadDelegate(final @Nullable DownloadDelegate delegate) {
+ mDelegateController.onDownloadDelegate(delegate);
+ }
+
+ /**
+ * Get the download delegate for this extension.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions
+ * downloads API</a>.
+ *
+ * @return The {@link DownloadDelegate} instance for this extension.
+ */
+ @UiThread
+ @Nullable
+ public DownloadDelegate getDownloadDelegate() {
+ return mDelegateController.getDownloadDelegate();
+ }
+
+ /**
+ * Represents a download for <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">downloads
+ * API</a> Instantiate using {@link WebExtensionController#createDownload}
+ */
+ public static class Download {
+ /**
+ * Represents a unique identifier for the downloaded item that is persistent across browser
+ * sessions
+ */
+ public final int id;
+
+ /**
+ * For testing.
+ *
+ * @param id - integer id for the download item
+ */
+ protected Download(final int id) {
+ this.id = id;
+ }
+
+ /* package */ void setDelegate(final Delegate delegate) {}
+
+ /**
+ * Updates the download state. This will trigger a call to <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged">downloads.onChanged</a>
+ * event to the corresponding `DownloadItem` on the extension side.
+ *
+ * @param data - current metadata associated with the download. {@link Download.Info}
+ * implementation instance
+ * @return GeckoResult with nothing or error inside
+ */
+ @Nullable
+ @UiThread
+ public GeckoResult<Void> update(final @NonNull Download.Info data) {
+ final GeckoBundle bundle = new GeckoBundle(12);
+
+ bundle.putInt("downloadItemId", this.id);
+
+ bundle.putString("filename", data.filename());
+ bundle.putString("mime", data.mime());
+ bundle.putString("startTime", String.valueOf(data.startTime()));
+ bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime()));
+ bundle.putInt("state", data.state());
+ bundle.putBoolean("canResume", data.canResume());
+ bundle.putBoolean("paused", data.paused());
+ final Integer error = data.error();
+ if (error != null) {
+ bundle.putInt("error", error);
+ }
+ bundle.putLong("totalBytes", data.totalBytes());
+ bundle.putLong("fileSize", data.fileSize());
+ bundle.putBoolean("exists", data.fileExists());
+
+ return EventDispatcher.getInstance()
+ .queryVoid("GeckoView:WebExtension:DownloadChanged", bundle)
+ .map(
+ null,
+ e -> {
+ if (e instanceof EventDispatcher.QueryException) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) e;
+ if (queryException.data instanceof String) {
+ return new IllegalArgumentException((String) queryException.data);
+ }
+ }
+ return e;
+ });
+ }
+
+ /* package */ interface Delegate {
+
+ default GeckoResult<Void> onPause(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onResume(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onCancel(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onErase(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onOpen(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onRemoveFile(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+ }
+
+ /**
+ * Represents a download in progress where the app is currently receiving data from the server.
+ * See also {@link Info#state()}.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({STATE_IN_PROGRESS, STATE_INTERRUPTED, STATE_COMPLETE})
+ public @interface DownloadState {}
+
+ /** Download is in progress. Default state */
+ public static final int STATE_IN_PROGRESS = 0;
+
+ /** An error broke the connection with the server. */
+ public static final int STATE_INTERRUPTED = 1;
+
+ /** The download completed successfully. */
+ public static final int STATE_COMPLETE = 2;
+
+ /**
+ * Represents a possible reason why a download was interrupted. See also {@link Info#error()}.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ INTERRUPT_REASON_NO_INTERRUPT,
+ INTERRUPT_REASON_FILE_FAILED,
+ INTERRUPT_REASON_FILE_ACCESS_DENIED,
+ INTERRUPT_REASON_FILE_NO_SPACE,
+ INTERRUPT_REASON_FILE_NAME_TOO_LONG,
+ INTERRUPT_REASON_FILE_TOO_LARGE,
+ INTERRUPT_REASON_FILE_VIRUS_INFECTED,
+ INTERRUPT_REASON_FILE_TRANSIENT_ERROR,
+ INTERRUPT_REASON_FILE_BLOCKED,
+ INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED,
+ INTERRUPT_REASON_FILE_TOO_SHORT,
+ INTERRUPT_REASON_NETWORK_FAILED,
+ INTERRUPT_REASON_NETWORK_TIMEOUT,
+ INTERRUPT_REASON_NETWORK_DISCONNECTED,
+ INTERRUPT_REASON_NETWORK_SERVER_DOWN,
+ INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+ INTERRUPT_REASON_SERVER_FAILED,
+ INTERRUPT_REASON_SERVER_NO_RANGE,
+ INTERRUPT_REASON_SERVER_BAD_CONTENT,
+ INTERRUPT_REASON_SERVER_UNAUTHORIZED,
+ INTERRUPT_REASON_SERVER_CERT_PROBLEM,
+ INTERRUPT_REASON_SERVER_FORBIDDEN,
+ INTERRUPT_REASON_USER_CANCELED,
+ INTERRUPT_REASON_USER_SHUTDOWN,
+ INTERRUPT_REASON_CRASH
+ })
+ public @interface DownloadInterruptReason {}
+
+ // File-related errors
+ public static final int INTERRUPT_REASON_NO_INTERRUPT = 0;
+ public static final int INTERRUPT_REASON_FILE_FAILED = 1;
+ public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2;
+ public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3;
+ public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4;
+ public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5;
+ public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6;
+ public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7;
+ public static final int INTERRUPT_REASON_FILE_BLOCKED = 8;
+ public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9;
+ public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10;
+ // Network-related errors
+ public static final int INTERRUPT_REASON_NETWORK_FAILED = 11;
+ public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12;
+ public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13;
+ public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14;
+ public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15;
+ // Server-related errors
+ public static final int INTERRUPT_REASON_SERVER_FAILED = 16;
+ public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17;
+ public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18;
+ public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19;
+ public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20;
+ public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21;
+ // User-related errors
+ public static final int INTERRUPT_REASON_USER_CANCELED = 22;
+ public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23;
+ // Miscellaneous
+ public static final int INTERRUPT_REASON_CRASH = 24;
+
+ /**
+ * Interface for communicating the state of downloads to Web Extensions. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/DownloadItem">WebExtensions/API/downloads/DownloadItem</a>
+ */
+ public interface Info {
+
+ /**
+ * @return A number representing the number of bytes received so far from the host during the
+ * download This does not take file compression into consideration
+ */
+ @UiThread
+ default long bytesReceived() {
+ return 0;
+ }
+
+ /**
+ * @return boolean indicating whether a currently-interrupted (e.g. paused) download can be
+ * resumed from the point where it was interrupted
+ */
+ @UiThread
+ default boolean canResume() {
+ return false;
+ }
+
+ /**
+ * @return A number representing the time when this download ended. This is null if the
+ * download has not yet finished.
+ */
+ @Nullable
+ @UiThread
+ default Long endTime() {
+ return null;
+ }
+
+ /**
+ * @return One of <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/InterruptReason">Interrupt
+ * Reason</a> constants denoting the error reason.
+ */
+ @Nullable
+ @UiThread
+ default @DownloadInterruptReason Integer error() {
+ return null;
+ }
+
+ /**
+ * @return the estimated number of milliseconds between the UNIX epoch and when this download
+ * is estimated to be completed. This is null if it is not known.
+ */
+ @Nullable
+ @UiThread
+ default Long estimatedEndTime() {
+ return null;
+ }
+
+ /**
+ * @return boolean indicating whether a downloaded file still exists
+ */
+ @UiThread
+ default boolean fileExists() {
+ return false;
+ }
+
+ /**
+ * @return the filename.
+ */
+ @NonNull
+ @UiThread
+ default String filename() {
+ return "";
+ }
+
+ /**
+ * @return the total number of bytes in the whole file, after decompression. A value of -1
+ * means that the total file size is unknown.
+ */
+ @UiThread
+ default long fileSize() {
+ return -1;
+ }
+
+ /**
+ * @return the downloaded file's MIME type
+ */
+ @NonNull
+ @UiThread
+ default String mime() {
+ return "";
+ }
+
+ /**
+ * @return boolean indicating whether the download is paused i.e. if the download has stopped
+ * reading data from the host but has kept the connection open
+ */
+ @UiThread
+ default boolean paused() {
+ return false;
+ }
+
+ /**
+ * @return String representing the downloaded file's referrer
+ */
+ @NonNull
+ @UiThread
+ default String referrer() {
+ return "";
+ }
+
+ /**
+ * @return the number of milliseconds between the UNIX epoch and when this download began
+ */
+ @UiThread
+ default long startTime() {
+ return -1;
+ }
+
+ /**
+ * @return a new state; one of the state constants to indicate whether the download is in
+ * progress, interrupted or complete
+ */
+ @UiThread
+ default @DownloadState int state() {
+ return STATE_IN_PROGRESS;
+ }
+
+ /**
+ * @return total number of bytes in the file being downloaded. This does not take file
+ * compression into consideration. A value of -1 here means that the total number of bytes
+ * is unknown
+ */
+ @UiThread
+ default long totalBytes() {
+ return -1;
+ }
+ }
+
+ @NonNull
+ /* package */ static GeckoBundle downloadInfoToBundle(final @NonNull Info data) {
+ final GeckoBundle dataBundle = new GeckoBundle();
+
+ dataBundle.putLong("bytesReceived", data.bytesReceived());
+ dataBundle.putBoolean("canResume", data.canResume());
+ dataBundle.putBoolean("exists", data.fileExists());
+ dataBundle.putString("filename", data.filename());
+ dataBundle.putLong("fileSize", data.fileSize());
+ dataBundle.putString("mime", data.mime());
+ dataBundle.putBoolean("paused", data.paused());
+ dataBundle.putString("referrer", data.referrer());
+ dataBundle.putString("startTime", String.valueOf(data.startTime()));
+ dataBundle.putInt("state", data.state());
+ dataBundle.putLong("totalBytes", data.totalBytes());
+
+ final Long endTime = data.endTime();
+ if (endTime != null) {
+ dataBundle.putString("endTime", endTime.toString());
+ }
+ final Integer error = data.error();
+ if (error != null) {
+ dataBundle.putInt("error", error);
+ }
+ final Long estimatedEndTime = data.estimatedEndTime();
+ if (estimatedEndTime != null) {
+ dataBundle.putString("estimatedEndTime", estimatedEndTime.toString());
+ }
+
+ return dataBundle;
+ }
+ }
+
+ /** Represents Web Extension API specific download request */
+ public static class DownloadRequest {
+ /** Regular GeckoView {@link WebRequest} object */
+ public final @NonNull WebRequest request;
+
+ /** Optional fetch flags for {@link GeckoWebExecutor} */
+ public final @GeckoWebExecutor.FetchFlags int downloadFlags;
+
+ /** A file path relative to the default downloads directory */
+ public final @Nullable String filename;
+
+ /**
+ * The action you want taken if there is a filename conflict, as defined <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/FilenameConflictAction">here</a>
+ */
+ public final @ConflictActionFlags int conflictActionFlag;
+
+ /**
+ * Specifies whether to provide a file chooser dialog to allow the user to select a filename
+ * (true), or not (false)
+ */
+ public final boolean saveAs;
+
+ /**
+ * Flag that enables downloads to continue even if they encounter HTTP errors. When false, the
+ * download is canceled when it encounters an HTTP error. When true, the download continues when
+ * an HTTP error is encountered and the HTTP server error is not reported. However, if the
+ * download fails due to file-related, network-related, user-related, or other error, that error
+ * is reported.
+ */
+ public final boolean allowHttpErrors;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT})
+ public @interface ConflictActionFlags {}
+
+ /** The app should modify the filename to make it unique */
+ public static final int CONFLICT_ACTION_UNIQUIFY = 0;
+
+ /** The app should overwrite the old file with the newly-downloaded file */
+ public static final int CONFLICT_ACTION_OVERWRITE = 1;
+
+ /** The app should prompt the user, asking them to choose whether to uniquify or overwrite */
+ public static final int CONFLICT_ACTION_PROMPT = 1 << 1;
+
+ protected DownloadRequest(final DownloadRequest.Builder builder) {
+ this.request = builder.mRequest;
+ this.downloadFlags = builder.mDownloadFlags;
+ this.filename = builder.mFilename;
+ this.conflictActionFlag = builder.mConflictActionFlag;
+ this.saveAs = builder.mSaveAs;
+ this.allowHttpErrors = builder.mAllowHttpErrors;
+ }
+
+ /**
+ * Convenience method to convert a GeckoBundle to a DownloadRequest.
+ *
+ * @param optionsBundle - in the shape of the options object browser.downloads.download()
+ * accepts
+ * @return request - a DownloadRequest instance
+ */
+ /* package */ static DownloadRequest fromBundle(final GeckoBundle optionsBundle) {
+ final String uri = optionsBundle.getString("url");
+
+ final WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri);
+
+ final String method = optionsBundle.getString("method");
+ if (method != null) {
+ mainRequestBuilder.method(method);
+
+ if (method.equals("POST")) {
+ final String body = optionsBundle.getString("body");
+ mainRequestBuilder.body(body);
+ }
+ }
+
+ final GeckoBundle[] headers = optionsBundle.getBundleArray("headers");
+ if (headers != null) {
+ for (final GeckoBundle header : headers) {
+ String value = header.getString("value");
+ if (value == null) {
+ value = header.getString("binaryValue");
+ }
+ mainRequestBuilder.addHeader(header.getString("name"), value);
+ }
+ }
+
+ final WebRequest mainRequest = mainRequestBuilder.build();
+
+ int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE;
+ final boolean incognito = optionsBundle.getBoolean("incognito");
+ if (incognito) {
+ downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE;
+ }
+
+ final boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors");
+
+ int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY;
+ final String conflictActionString = optionsBundle.getString("conflictAction");
+ if (conflictActionString != null) {
+ switch (conflictActionString.toLowerCase(Locale.ROOT)) {
+ case "overwrite":
+ conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_OVERWRITE;
+ break;
+ case "prompt":
+ conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_PROMPT;
+ break;
+ }
+ }
+
+ final boolean saveAs = optionsBundle.getBoolean("saveAs");
+
+ final WebExtension.DownloadRequest request =
+ new WebExtension.DownloadRequest.Builder(mainRequest)
+ .filename(optionsBundle.getString("filename"))
+ .downloadFlags(downloadFlags)
+ .conflictAction(conflictActionFlags)
+ .saveAs(saveAs)
+ .allowHttpErrors(allowHttpErrors)
+ .build();
+
+ return request;
+ }
+
+ /* package */ static class Builder {
+ private final WebRequest mRequest;
+ private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0;
+ private String mFilename = null;
+ private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY;
+ private boolean mSaveAs = false;
+ private boolean mAllowHttpErrors = false;
+
+ /* package */ Builder(final WebRequest request) {
+ this.mRequest = request;
+ }
+
+ /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) {
+ this.mDownloadFlags = flags;
+ return this;
+ }
+
+ /* package */ Builder filename(final String filename) {
+ this.mFilename = filename;
+ return this;
+ }
+
+ /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) {
+ this.mConflictActionFlag = conflictActionFlag;
+ return this;
+ }
+
+ /* package */ Builder saveAs(final boolean saveAs) {
+ this.mSaveAs = saveAs;
+ return this;
+ }
+
+ /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) {
+ this.mAllowHttpErrors = allowHttpErrors;
+ return this;
+ }
+
+ /* package */ DownloadRequest build() {
+ return new DownloadRequest(this);
+ }
+ }
+ }
+
+ /** Represents initial information on a download provided to Web Extension */
+ public static class DownloadInitData {
+ @NonNull public final WebExtension.Download download;
+ @NonNull public final Download.Info initData;
+
+ public DownloadInitData(final Download download, final Download.Info initData) {
+ this.download = download;
+ this.initData = initData;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
new file mode 100644
index 0000000000..edcd2b3e10
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
@@ -0,0 +1,1359 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.os.Build;
+import android.util.Log;
+import android.util.SparseArray;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import org.json.JSONException;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.MultiMap;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+public class WebExtensionController {
+ private static final String LOGTAG = "WebExtension";
+
+ private DebuggerDelegate mDebuggerDelegate;
+ private PromptDelegate mPromptDelegate;
+ private final WebExtension.Listener<WebExtension.TabDelegate> mListener;
+
+ // Map [ (extensionId, nativeApp, session) -> message ]
+ private final MultiMap<MessageRecipient, Message> mPendingMessages;
+ private final MultiMap<String, Message> mPendingNewTab;
+ private final MultiMap<String, Message> mPendingBrowsingData;
+ private final MultiMap<String, Message> mPendingDownload;
+
+ private final SparseArray<WebExtension.Download> mDownloads;
+
+ private static class Message {
+ final GeckoBundle bundle;
+ final EventCallback callback;
+ final String event;
+ final GeckoSession session;
+
+ public Message(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ this.bundle = bundle;
+ this.callback = callback;
+ this.event = event;
+ this.session = session;
+ }
+ }
+
+ private static class ExtensionStore {
+ private final Map<String, WebExtension> mData = new HashMap<>();
+ private Observer mObserver;
+
+ interface Observer {
+ /**
+ * * This event is fired every time a new extension object is created by the store.
+ *
+ * @param extension the newly-created extension object
+ */
+ WebExtension onNewExtension(final GeckoBundle extension);
+ }
+
+ public GeckoResult<WebExtension> get(final String id) {
+ final WebExtension extension = mData.get(id);
+ if (extension != null) {
+ return GeckoResult.fromValue(extension);
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ final GeckoResult<WebExtension> pending =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Get", bundle)
+ .map(
+ extensionBundle -> {
+ final WebExtension ext = mObserver.onNewExtension(extensionBundle);
+ mData.put(ext.id, ext);
+ return ext;
+ });
+
+ return pending;
+ }
+
+ public void setObserver(final Observer observer) {
+ mObserver = observer;
+ }
+
+ public void remove(final String id) {
+ mData.remove(id);
+ }
+
+ /**
+ * Add this extension to the store and update it's current value if it's already present.
+ *
+ * @param id the {@link WebExtension} id.
+ * @param extension the {@link WebExtension} to add to the store.
+ */
+ public void update(final String id, final WebExtension extension) {
+ mData.put(id, extension);
+ }
+ }
+
+ private ExtensionStore mExtensions = new ExtensionStore();
+
+ private Internals mInternals = new Internals();
+
+ // Avoids exposing listeners to the API
+ private class Internals implements BundleEventListener, ExtensionStore.Observer {
+ @Override
+ // BundleEventListener
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ WebExtensionController.this.handleMessage(event, message, callback, null);
+ }
+
+ @Override
+ public WebExtension onNewExtension(final GeckoBundle bundle) {
+ return WebExtension.fromBundle(mDelegateControllerProvider, bundle);
+ }
+ }
+
+ /* package */ void releasePendingMessages(
+ final WebExtension extension, final String nativeApp, final GeckoSession session) {
+ Log.i(
+ LOGTAG,
+ "releasePendingMessages:"
+ + " extension="
+ + extension.id
+ + " nativeApp="
+ + nativeApp
+ + " session="
+ + session);
+ final List<Message> messages =
+ mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session));
+ if (messages == null) {
+ return;
+ }
+
+ for (final Message message : messages) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+ }
+
+ private class DelegateController implements WebExtension.DelegateController {
+ private final WebExtension mExtension;
+
+ public DelegateController(final WebExtension extension) {
+ mExtension = extension;
+ }
+
+ @Override
+ public void onMessageDelegate(
+ final String nativeApp, final WebExtension.MessageDelegate delegate) {
+ mListener.setMessageDelegate(mExtension, delegate, nativeApp);
+ }
+
+ @Override
+ public void onActionDelegate(final WebExtension.ActionDelegate delegate) {
+ mListener.setActionDelegate(mExtension, delegate);
+ }
+
+ @Override
+ public WebExtension.ActionDelegate getActionDelegate() {
+ return mListener.getActionDelegate(mExtension);
+ }
+
+ @Override
+ public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) {
+ mListener.setBrowsingDataDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingBrowsingData.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingBrowsingData.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mListener.getBrowsingDataDelegate(mExtension);
+ }
+
+ @Override
+ public void onTabDelegate(final WebExtension.TabDelegate delegate) {
+ mListener.setTabDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingNewTab.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingNewTab.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mListener.getTabDelegate(mExtension);
+ }
+
+ @Override
+ public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) {
+ mListener.setDownloadDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingDownload.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingDownload.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.DownloadDelegate getDownloadDelegate() {
+ return mListener.getDownloadDelegate(mExtension);
+ }
+ }
+
+ final WebExtension.DelegateControllerProvider mDelegateControllerProvider =
+ new WebExtension.DelegateControllerProvider() {
+ @Override
+ public WebExtension.DelegateController controllerFor(final WebExtension extension) {
+ return new DelegateController(extension);
+ }
+ };
+
+ /**
+ * This delegate will be called whenever an extension is about to be installed or it needs new
+ * permissions, e.g during an update or because it called <code>permissions.request</code>
+ */
+ @UiThread
+ public interface PromptDelegate {
+ /**
+ * Called whenever a new extension is being installed. This is intended as an opportunity for
+ * the app to prompt the user for the permissions required by this extension.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be installed or {@link AllowOrDeny#DENY DENY} if this extension
+ * should not be installed. A null value will be interpreted as {@link AllowOrDeny#DENY
+ * DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) {
+ return null;
+ }
+
+ /**
+ * Called whenever an updated extension has new permissions. This is intended as an opportunity
+ * for the app to prompt the user for the new permissions required by this extension.
+ *
+ * @param currentlyInstalled The {@link WebExtension} that is currently installed.
+ * @param updatedExtension The {@link WebExtension} that will replace the previous extension.
+ * @param newPermissions The new permissions that are needed.
+ * @param newOrigins The new origins that are needed.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be update or {@link AllowOrDeny#DENY DENY} if this extension should
+ * not be update. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onUpdatePrompt(
+ @NonNull final WebExtension currentlyInstalled,
+ @NonNull final WebExtension updatedExtension,
+ @NonNull final String[] newPermissions,
+ @NonNull final String[] newOrigins) {
+ return null;
+ }
+
+ /**
+ * Called whenever permissions are requested. This is intended as an opportunity for the app to
+ * prompt the user for the permissions required by this extension at runtime.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @param permissions The permissions that are requested.
+ * @param origins The requested host permissions.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the
+ * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be
+ * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOptionalPrompt(
+ final @NonNull WebExtension extension,
+ final @NonNull String[] permissions,
+ final @NonNull String[] origins) {
+ return null;
+ }
+ }
+
+ public interface DebuggerDelegate {
+ /**
+ * Called whenever the list of installed extensions has been modified using the debugger with
+ * tools like web-ext.
+ *
+ * <p>This is intended as an opportunity to refresh the list of installed extensions using
+ * {@link WebExtensionController#list} and to set delegates on the new {@link WebExtension}
+ * objects, e.g. using {@link WebExtension#setActionDelegate} and {@link
+ * WebExtension#setMessageDelegate}.
+ *
+ * @see <a
+ * href="https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext">
+ * Getting started with web-ext</a>
+ */
+ @UiThread
+ default void onExtensionListUpdated() {}
+ }
+
+ /**
+ * @return the current {@link PromptDelegate} instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ @Nullable
+ public PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the {@link PromptDelegate} for this instance. This delegate will be used to be notified
+ * whenever an extension is being installed or needs new permissions.
+ *
+ * @param delegate the delegate instance.
+ * @see PromptDelegate
+ */
+ @UiThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ if (delegate == null && mPromptDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ } else if (delegate != null && mPromptDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mInternals,
+ "GeckoView:WebExtension:InstallPrompt",
+ "GeckoView:WebExtension:UpdatePrompt",
+ "GeckoView:WebExtension:OptionalPrompt");
+ }
+
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Set the {@link DebuggerDelegate} for this instance. This delegate will receive updates about
+ * extension changes using developer tools.
+ *
+ * @param delegate the Delegate instance
+ */
+ @UiThread
+ public void setDebuggerDelegate(final @NonNull DebuggerDelegate delegate) {
+ if (delegate == null && mDebuggerDelegate != null) {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ } else if (delegate != null && mDebuggerDelegate == null) {
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated");
+ }
+
+ mDebuggerDelegate = delegate;
+ }
+
+ private static class InstallCanceller implements GeckoResult.CancellationDelegate {
+ public final String installId;
+
+ public InstallCanceller() {
+ installId = UUID.randomUUID().toString();
+ }
+
+ @Override
+ public GeckoResult<Boolean> cancel() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("installId", installId);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:CancelInstall", bundle)
+ .map(response -> response.getBoolean("cancelled"));
+ }
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate.
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(final @NonNull String uri) {
+ final InstallCanceller canceller = new InstallCanceller();
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("installId", canceller.installId);
+
+ final GeckoResult<WebExtension> result =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Install", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ result.setCancellationDelegate(canceller);
+ return result;
+ }
+
+ /**
+ * Set whether an extension should be allowed to run in private browsing or not.
+ *
+ * @param extension the {@link WebExtension} instance to modify.
+ * @param allowed true if this extension should be allowed to run in private browsing pages, false
+ * otherwise.
+ * @return the updated {@link WebExtension} instance.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> setAllowedInPrivateBrowsing(
+ final @NonNull WebExtension extension, final boolean allowed) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("extensionId", extension.id);
+ bundle.putBoolean("allowed", allowed);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Install a built-in extension.
+ *
+ * <p>Built-in extensions have access to native messaging, don't need to be signed and are
+ * installed from a folder in the APK instead of a .xpi bundle.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.installBuiltIn("resource://android/assets/example/");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> installBuiltIn(final @NonNull String uri) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("locationUri", uri);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Ensure that a built-in extension is installed.
+ *
+ * <p>Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already
+ * present and it has the same version.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @param id Extension ID as present in the manifest.json file.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> ensureBuiltIn(
+ final @NonNull String uri, final @Nullable String id) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("webExtensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Uninstall an extension.
+ *
+ * <p>Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance,
+ * delete all its data and trigger a request to close all extension pages currently open.
+ *
+ * @param extension The {@link WebExtension} to be uninstalled.
+ * @return A {@link GeckoResult} that will complete when the uninstall process is completed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Uninstall", bundle)
+ .accept(result -> unregisterWebExtension(extension));
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EnableSource.USER, EnableSource.APP})
+ public @interface EnableSources {}
+
+ /**
+ * Contains the possible values for the <code>source</code> parameter in {@link #enable} and
+ * {@link #disable}.
+ */
+ public static class EnableSource {
+ /** Action has been requested by the user. */
+ public static final int USER = 1;
+
+ /**
+ * Action requested by the app itself, e.g. to disable an extension that is not supported in
+ * this version of the app.
+ */
+ public static final int APP = 2;
+
+ static String toString(final @EnableSources int flag) {
+ if (flag == USER) {
+ return "user";
+ } else if (flag == APP) {
+ return "app";
+ } else {
+ throw new IllegalArgumentException("Value provided in flags is not valid.");
+ }
+ }
+ }
+
+ /**
+ * Enable an extension that has been disabled. If the extension is already enabled, this method
+ * has no effect.
+ *
+ * @param extension The {@link WebExtension} to be enabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user,use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the enablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> enable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Enable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Disable an extension that is enabled. If the extension is already disabled, this method has no
+ * effect.
+ *
+ * @param extension The {@link WebExtension} to be disabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user, use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the disablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> disable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Disable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ private List<WebExtension> listFromBundle(final GeckoBundle response) {
+ final GeckoBundle[] bundles = response.getBundleArray("extensions");
+ final List<WebExtension> list = new ArrayList<>(bundles.length);
+
+ for (final GeckoBundle bundle : bundles) {
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle);
+ list.add(registerWebExtension(extension));
+ }
+
+ return list;
+ }
+
+ /**
+ * List installed extensions for this {@link GeckoRuntime}.
+ *
+ * <p>The returned list can be used to set delegates on the {@link WebExtension} objects using
+ * {@link WebExtension#setActionDelegate}, {@link WebExtension#setMessageDelegate}.
+ *
+ * @return a {@link GeckoResult} that will resolve when the list of extensions is available.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<List<WebExtension>> list() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:List")
+ .map(this::listFromBundle);
+ }
+
+ /**
+ * Update a web extension.
+ *
+ * <p>When checking for an update, GeckoView will download the update manifest that is defined by
+ * the web extension's manifest property <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/">browser_specific_settings.gecko.update_url</a>.
+ * If an update is found it will be downloaded and installed. If the extension needs any new
+ * permissions the {@link PromptDelegate#updatePrompt} will be triggered.
+ *
+ * <p>More information about the update manifest format is available <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/#manifest-structure">here</a>.
+ *
+ * @param extension The extension to update.
+ * @return A {@link GeckoResult} that will complete when the update process finishes. If an update
+ * is found and installed successfully, the GeckoResult will return the updated {@link
+ * WebExtension}. If no update is available, null will be returned. If the updated extension
+ * requires new permissions, the {@link PromptDelegate#installPrompt} will be called.
+ * @see PromptDelegate#updatePrompt
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> update(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Update", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /* package */ WebExtensionController(final GeckoRuntime runtime) {
+ mListener = new WebExtension.Listener<>(runtime);
+ mPendingMessages = new MultiMap<>();
+ mPendingNewTab = new MultiMap<>();
+ mPendingBrowsingData = new MultiMap<>();
+ mPendingDownload = new MultiMap<>();
+ mExtensions.setObserver(mInternals);
+ mDownloads = new SparseArray<>();
+ }
+
+ /* package */ WebExtension registerWebExtension(final WebExtension webExtension) {
+ if (webExtension != null) {
+ mExtensions.update(webExtension.id, webExtension);
+ }
+ return webExtension;
+ }
+
+ /* package */ void handleMessage(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ final Message message = new Message(event, bundle, callback, session);
+
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:WebExtension:InstallPrompt".equals(event)) {
+ installPrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) {
+ updatePrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) {
+ if (mDebuggerDelegate != null) {
+ mDebuggerDelegate.onExtensionListUpdated();
+ }
+ return;
+ }
+
+ extensionFromBundle(bundle)
+ .accept(
+ extension -> {
+ if ("GeckoView:WebExtension:NewTab".equals(event)) {
+ newTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:UpdateTab".equals(event)) {
+ updateTab(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:CloseTab".equals(event)) {
+ closeTab(message, extension);
+ return;
+ } else if ("GeckoView:BrowserAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:Update".equals(event)) {
+ actionUpdate(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION);
+ return;
+ } else if ("GeckoView:PageAction:OpenPopup".equals(event)) {
+ openPopup(message, extension, WebExtension.Action.TYPE_PAGE_ACTION);
+ return;
+ } else if ("GeckoView:WebExtension:OpenOptionsPage".equals(event)) {
+ openOptionsPage(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:GetSettings".equals(event)) {
+ getSettings(message, extension);
+ return;
+ } else if ("GeckoView:BrowsingData:Clear".equals(event)) {
+ browsingDataClear(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:Download".equals(event)) {
+ download(message, extension);
+ return;
+ } else if ("GeckoView:WebExtension:OptionalPrompt".equals(event)) {
+ optionalPrompt(message, extension);
+ return;
+ }
+
+ // GeckoView:WebExtension:Connect and GeckoView:WebExtension:Message
+ // are handled below.
+ final String nativeApp = bundle.getString("nativeApp");
+ if (nativeApp == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing required nativeApp message parameter.");
+ }
+ callback.sendError("Missing nativeApp parameter.");
+ return;
+ }
+
+ final GeckoBundle senderBundle = bundle.getBundle("sender");
+ final WebExtension.MessageSender sender =
+ fromBundle(extension, senderBundle, session);
+ if (sender == null) {
+ if (callback != null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ try {
+ Log.e(
+ LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject());
+ } catch (final JSONException ex) {
+ }
+ }
+ callback.sendError("Could not find recipient for " + bundle.getBundle("sender"));
+ }
+ return;
+ }
+
+ if ("GeckoView:WebExtension:Connect".equals(event)) {
+ connect(nativeApp, bundle.getLong("portId", -1), message, sender);
+ } else if ("GeckoView:WebExtension:Message".equals(event)) {
+ message(nativeApp, message, sender);
+ }
+ });
+ }
+
+ private void installPrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle extensionBundle = message.getBundle("extension");
+ if (extensionBundle == null
+ || !extensionBundle.containsKey("webExtensionId")
+ || !extensionBundle.containsKey("locationURI")) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing webExtensionId or locationURI");
+ }
+
+ Log.e(LOGTAG, "Missing webExtensionId or locationURI");
+ return;
+ }
+
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG, "Tried to install extension " + extension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void updatePrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle currentBundle = message.getBundle("currentlyInstalled");
+ final GeckoBundle updatedBundle = message.getBundle("updatedExtension");
+ final String[] newPermissions = message.getStringArray("newPermissions");
+ final String[] newOrigins = message.getStringArray("newOrigins");
+ if (currentBundle == null || updatedBundle == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing bundle");
+ }
+
+ Log.e(LOGTAG, "Missing bundle");
+ return;
+ }
+
+ final WebExtension currentExtension =
+ new WebExtension(mDelegateControllerProvider, currentBundle);
+
+ final WebExtension updatedExtension =
+ new WebExtension(mDelegateControllerProvider, updatedBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to update extension " + currentExtension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onUpdatePrompt(
+ currentExtension, updatedExtension, newPermissions, newOrigins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void optionalPrompt(final Message message, final WebExtension extension) {
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to request optional permissions for extension "
+ + extension.id
+ + " but no delegate is registered");
+ return;
+ }
+
+ final String[] permissions =
+ message.bundle.getBundle("permissions").getStringArray("permissions");
+ final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins");
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onOptionalPrompt(extension, permissions, origins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ message.callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ @SuppressLint("WrongThread") // for .toGeckoBundle
+ private void getSettings(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final GeckoResult<WebExtension.BrowsingDataDelegate.Settings> settingsResult =
+ delegate.onGetSettings();
+ if (settingsResult == null) {
+ message.callback.sendError("browsingData.settings is not supported");
+ return;
+ }
+ message.callback.resolveTo(settingsResult.map(settings -> settings.toGeckoBundle()));
+ }
+
+ private void browsingDataClear(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final long unixTimestamp = message.bundle.getLong("since");
+ final String dataType = message.bundle.getString("dataType");
+
+ final GeckoResult<Void> response;
+ if ("downloads".equals(dataType)) {
+ response = delegate.onClearDownloads(unixTimestamp);
+ } else if ("formData".equals(dataType)) {
+ response = delegate.onClearFormData(unixTimestamp);
+ } else if ("history".equals(dataType)) {
+ response = delegate.onClearHistory(unixTimestamp);
+ } else if ("passwords".equals(dataType)) {
+ response = delegate.onClearPasswords(unixTimestamp);
+ } else {
+ throw new IllegalStateException("Illegal clear data type: " + dataType);
+ }
+
+ message.callback.resolveTo(response);
+ }
+
+ /* package */ void download(final Message message, final WebExtension extension) {
+ final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension);
+ if (delegate == null) {
+ mPendingDownload.add(extension.id, message);
+ return;
+ }
+
+ final GeckoBundle optionsBundle = message.bundle.getBundle("options");
+
+ final WebExtension.DownloadRequest request =
+ WebExtension.DownloadRequest.fromBundle(optionsBundle);
+
+ final GeckoResult<WebExtension.DownloadInitData> result =
+ delegate.onDownload(extension, request);
+ if (result == null) {
+ message.callback.sendError("downloads.download is not supported");
+ return;
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == null) {
+ Log.e(LOGTAG, "onDownload returned invalid null value");
+ throw new IllegalArgumentException("downloads.download is not supported");
+ }
+
+ final GeckoBundle returnMessage =
+ WebExtension.Download.downloadInfoToBundle(value.initData);
+ returnMessage.putInt("id", value.download.id);
+
+ return returnMessage;
+ }));
+ }
+
+ /* package */ void openOptionsPage(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+
+ if (delegate != null) {
+ delegate.onOpenOptionsPage(extension);
+ } else {
+ message.callback.sendError("runtime.openOptionsPage is not supported");
+ }
+
+ message.callback.sendSuccess(null);
+ }
+
+ /* package */
+ @SuppressLint("WrongThread") // for .isOpen
+ void newTab(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+ final WebExtension.CreateTabDetails details =
+ new WebExtension.CreateTabDetails(bundle.getBundle("createProperties"));
+
+ final GeckoResult<GeckoSession> result;
+ if (delegate != null) {
+ result = delegate.onNewTab(extension, details);
+ } else {
+ mPendingNewTab.add(extension.id, message);
+ return;
+ }
+
+ if (result == null) {
+ message.callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.bundle.getString("newSessionId");
+ message.callback.resolveTo(
+ result.map(
+ session -> {
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new IllegalArgumentException("Must use an unopened GeckoSession instance");
+ }
+
+ session.open(mListener.runtime, newSessionId);
+ return true;
+ }));
+ }
+
+ /* package */ void updateTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+ final EventCallback callback = message.callback;
+
+ if (delegate == null) {
+ callback.sendError("tabs.update is not supported");
+ return;
+ }
+
+ final WebExtension.UpdateTabDetails details =
+ new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties"));
+ callback.resolveTo(
+ delegate
+ .onUpdateTab(extension, message.session, details)
+ .map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.update is not supported");
+ }
+ }));
+ }
+
+ /* package */ void closeTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+
+ final GeckoResult<AllowOrDeny> result;
+ if (delegate != null) {
+ result = delegate.onCloseTab(extension, message.session);
+ } else {
+ result = GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.remove is not supported");
+ }
+ }));
+ }
+
+ /**
+ * Notifies extensions about a active tab change over the `tabs.onActivated` event.
+ *
+ * @param session The {@link GeckoSession} of the newly selected session/tab.
+ * @param active true if the tab became active, false if the tab became inactive.
+ */
+ @AnyThread
+ public void setTabActive(@NonNull final GeckoSession session, final boolean active) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("active", active);
+ session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle);
+ }
+
+ /* package */ void unregisterWebExtension(final WebExtension webExtension) {
+ mExtensions.remove(webExtension.id);
+ mListener.unregisterWebExtension(webExtension);
+ }
+
+ private WebExtension.MessageSender fromBundle(
+ final WebExtension extension, final GeckoBundle sender, final GeckoSession session) {
+ if (extension == null) {
+ // All senders should have an extension
+ return null;
+ }
+
+ final String envType = sender.getString("envType");
+ @WebExtension.MessageSender.EnvType final int environmentType;
+
+ if ("content_child".equals(envType)) {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT;
+ } else if ("addon_child".equals(envType)) {
+ // TODO Bug 1554277: check that this message is coming from the right process
+ environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION;
+ } else {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN;
+ }
+
+ if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing or unknown envType: " + envType);
+ }
+
+ return null;
+ }
+
+ final String url = sender.getString("url");
+ final boolean isTopLevel;
+ if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ // This message is coming from the background page, a popup, or an extension page
+ isTopLevel = true;
+ } else {
+ // If session is present we are either receiving this message from a content script or
+ // an extension page, let's make sure we have the proper identification so that
+ // embedders can check the origin of this message.
+ // -1 is an invalid frame id
+ final boolean hasFrameId =
+ sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1;
+ final boolean hasUrl = sender.containsKey("url");
+ if (!hasFrameId || !hasUrl) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException(
+ "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl);
+ }
+
+ // This message does not have the proper identification and may be compromised,
+ // let's ignore it.
+ return null;
+ }
+
+ isTopLevel = sender.getInt("frameId", -1) == 0;
+ }
+
+ return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel);
+ }
+
+ private WebExtension.MessageDelegate getDelegate(
+ final String nativeApp,
+ final WebExtension.MessageSender sender,
+ final EventCallback callback) {
+ if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0
+ && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) {
+ callback.sendError("This NativeApp can't receive messages from Content Scripts.");
+ return null;
+ }
+
+ WebExtension.MessageDelegate delegate = null;
+
+ if (sender.session != null) {
+ delegate =
+ sender
+ .session
+ .getWebExtensionController()
+ .getMessageDelegate(sender.webExtension, nativeApp);
+ } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp);
+ }
+
+ return delegate;
+ }
+
+ private static class MessageRecipient {
+ public final String webExtensionId;
+ public final String nativeApp;
+ public final GeckoSession session;
+
+ public MessageRecipient(
+ final String webExtensionId, final String nativeApp, final GeckoSession session) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ this.session = session;
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ return Objects.equals(a, b);
+ }
+
+ return (a == b) || (a != null && a.equals(b));
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof MessageRecipient)) {
+ return false;
+ }
+
+ final MessageRecipient o = (MessageRecipient) other;
+ return equals(webExtensionId, o.webExtensionId)
+ && equals(nativeApp, o.nativeApp)
+ && equals(session, o.session);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session});
+ }
+ }
+
+ private void connect(
+ final String nativeApp,
+ final long portId,
+ final Message message,
+ final WebExtension.MessageSender sender) {
+ if (portId == -1) {
+ message.callback.sendError("Missing portId.");
+ return;
+ }
+
+ final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender);
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ delegate.onConnect(port);
+ message.callback.sendSuccess(true);
+ }
+
+ private void message(
+ final String nativeApp, final Message message, final WebExtension.MessageSender sender) {
+ final EventCallback callback = message.callback;
+
+ final Object content;
+ try {
+ content = message.bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex.getMessage());
+ return;
+ }
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ final GeckoResult<Object> response = delegate.onMessage(nativeApp, content, sender);
+ if (response == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(response);
+ }
+
+ private GeckoResult<WebExtension> extensionFromBundle(final GeckoBundle message) {
+ final String extensionId = message.getString("extensionId");
+ return mExtensions.get(extensionId);
+ }
+
+ private void openPopup(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ final String popupUri = message.bundle.getString("popupUri");
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+ action.openPopup(popup, popupUri);
+ }
+
+ private WebExtension.ActionDelegate actionDelegateFor(
+ final WebExtension extension, final GeckoSession session) {
+ if (session == null) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ return session.getWebExtensionController().getActionDelegate(extension);
+ }
+
+ private void actionUpdate(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) {
+ delegate.onBrowserAction(extension, message.session, action);
+ } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) {
+ delegate.onPageAction(extension, message.session, action);
+ }
+ }
+
+ // TODO: implement bug 1595822
+ /* package */ static GeckoResult<List<WebExtension.Menu>> getMenu(
+ final GeckoBundle menuArrayBundle) {
+ return null;
+ }
+
+ @Nullable
+ @UiThread
+ public WebExtension.Download createDownload(final int id) {
+ if (mDownloads.indexOfKey(id) >= 0) {
+ throw new IllegalArgumentException("Download with this id already exists");
+ } else {
+ final WebExtension.Download download = new WebExtension.Download(id);
+ mDownloads.put(id, download);
+
+ return download;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
new file mode 100644
index 0000000000..520cb9faa0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/** This is an abstract base class for HTTP request and response types. */
+@WrapForJNI
+@AnyThread
+public abstract class WebMessage {
+
+ /** The URI for the request or response. */
+ public final @NonNull String uri;
+
+ /** An unmodifiable Map of headers. Defaults to an empty instance. */
+ public final @NonNull Map<String, String> headers;
+
+ protected WebMessage(final @NonNull Builder builder) {
+ uri = builder.mUri;
+ headers = Collections.unmodifiableMap(builder.mHeaders);
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderKeys() {
+ final String[] keys = new String[headers.size()];
+ headers.keySet().toArray(keys);
+ return keys;
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderValues() {
+ final String[] values = new String[headers.size()];
+ headers.values().toArray(values);
+ return values;
+ }
+
+ /** This is a Builder used by subclasses of {@link WebMessage}. */
+ @AnyThread
+ public abstract static class Builder {
+ /* package */ String mUri;
+ /* package */ Map<String, String> mHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ /* package */ ByteBuffer mBody;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ /* package */ Builder(final @NonNull String uri) {
+ uri(uri);
+ }
+
+ /**
+ * Set the URI
+ *
+ * @param uri A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder uri(final @NonNull String uri) {
+ mUri = uri;
+ return this;
+ }
+
+ /**
+ * Set a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, it will be replaced by this value.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ mHeaders.put(key, value);
+ return this;
+ }
+
+ /**
+ * Add a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, the values will be merged.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ final String existingValue = mHeaders.get(key);
+ if (existingValue != null) {
+ final StringBuilder builder = new StringBuilder(existingValue);
+ builder.append(", ");
+ builder.append(value);
+ mHeaders.put(key, builder.toString());
+ } else {
+ mHeaders.put(key, value);
+ }
+
+ return this;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
new file mode 100644
index 0000000000..c2de231f80
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
@@ -0,0 +1,233 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.ParcelFormatException;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This class represents a single <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web Notification</a>. These
+ * can be received by connecting a {@link WebNotificationDelegate} to {@link GeckoRuntime} via
+ * {@link GeckoRuntime#setWebNotificationDelegate(WebNotificationDelegate)}.
+ */
+public class WebNotification implements Parcelable {
+
+ /**
+ * Title is shown at the top of the notification window.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/title">Web
+ * Notification - title</a>
+ */
+ public final @Nullable String title;
+
+ /**
+ * Tag is the ID of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/tag">Web
+ * Notification - tag</a>
+ */
+ public final @NonNull String tag;
+
+ private final @Nullable String mCookie;
+
+ /**
+ * Text represents the body of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/body">Web
+ * Notification - text</a>
+ */
+ public final @Nullable String text;
+
+ /**
+ * ImageURL contains the URL of an icon to be displayed as part of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/icon">Web
+ * Notification - icon</a>
+ */
+ public final @Nullable String imageUrl;
+
+ /**
+ * TextDirection indicates the direction that the language of the text is displayed. Possible
+ * values are: auto: adopts the browser's language setting behaviour (the default.) ltr: left to
+ * right. rtl: right to left.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir">Web
+ * Notification - dir</a>
+ */
+ public final @Nullable String textDirection;
+
+ /**
+ * Lang indicates the notification's language, as specified using a DOMString representing a BCP
+ * 47 language tag.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMString">DOM String</a>
+ * @see <a href="http://www.rfc-editor.org/rfc/bcp/bcp47.txt">BCP 47</a>
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/lang">Web
+ * Notification - lang</a>
+ */
+ public final @Nullable String lang;
+
+ /**
+ * RequireInteraction indicates whether a notification should remain active until the user clicks
+ * or dismisses it, rather than closing automatically.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/requireInteraction">Web
+ * Notification - requireInteraction</a>
+ */
+ public final @NonNull boolean requireInteraction;
+
+ /**
+ * This is the URL of the page or Service Worker that generated the notification. Null if this
+ * notification was not generated by a Web Page (e.g. from an Extension).
+ *
+ * <p>TODO: make NonNull once we have Bug 1589693
+ */
+ public final @Nullable String source;
+
+ /**
+ * When set, indicates that no sounds or vibrations should be made.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent">Web
+ * Notification - silent</a>
+ */
+ public final boolean silent;
+
+ /** indicates whether the notification came from private browsing mode or not. */
+ public final boolean privateBrowsing;
+
+ /**
+ * A vibration pattern to run with the display of the notification. A vibration pattern can be an
+ * array with as few as one member. The values are times in milliseconds where the even indices
+ * (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause.
+ * For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate">Web
+ * Notification - vibrate</a>
+ */
+ public final @NonNull int[] vibrate;
+
+ @WrapForJNI
+ /* package */ WebNotification(
+ @Nullable final String title,
+ @NonNull final String tag,
+ @Nullable final String cookie,
+ @Nullable final String text,
+ @Nullable final String imageUrl,
+ @Nullable final String textDirection,
+ @Nullable final String lang,
+ @NonNull final boolean requireInteraction,
+ @NonNull final String source,
+ final boolean silent,
+ final boolean privateBrowsing,
+ @NonNull final int[] vibrate) {
+ this.tag = tag;
+ this.mCookie = cookie;
+ this.title = title;
+ this.text = text;
+ this.imageUrl = imageUrl;
+ this.textDirection = textDirection;
+ this.lang = lang;
+ this.requireInteraction = requireInteraction;
+ this.source = "".equals(source) ? null : source;
+ this.silent = silent;
+ this.vibrate = vibrate;
+ this.privateBrowsing = privateBrowsing;
+ }
+
+ /**
+ * This should be called when the user taps or clicks a notification. Note that this does not
+ * automatically dismiss the notification as far as Web Content is concerned. For that, see {@link
+ * #dismiss()}.
+ */
+ @UiThread
+ public void click() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClick(tag, mCookie);
+ }
+
+ /**
+ * This should be called when the app stops showing the notification. This is important, as there
+ * may be a limit to the number of active notifications each site can display.
+ */
+ @UiThread
+ public void dismiss() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClose(tag, mCookie);
+ }
+
+ // Increment this value whenever anything changes in the parcelable representation.
+ private static final int VERSION = 1;
+
+ // To avoid TransactionTooLargeException, we only store small imageUrls
+ private static final int IMAGE_URL_LENGTH_MAX = 150;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeString(title);
+ dest.writeString(tag);
+ dest.writeString(mCookie);
+ dest.writeString(text);
+ if (imageUrl.length() < IMAGE_URL_LENGTH_MAX) {
+ dest.writeString(imageUrl);
+ } else {
+ dest.writeString("");
+ }
+ dest.writeString(textDirection);
+ dest.writeString(lang);
+ dest.writeInt(requireInteraction ? 1 : 0);
+ dest.writeString(source);
+ dest.writeInt(silent ? 1 : 0);
+ dest.writeInt(privateBrowsing ? 1 : 0);
+ dest.writeIntArray(vibrate);
+ }
+
+ private WebNotification(final Parcel in) {
+ title = in.readString();
+ tag = in.readString();
+ mCookie = in.readString();
+ text = in.readString();
+ imageUrl = in.readString();
+ textDirection = in.readString();
+ lang = in.readString();
+ requireInteraction = in.readInt() == 1;
+ source = in.readString();
+ silent = in.readInt() == 1;
+ privateBrowsing = in.readInt() == 1;
+ vibrate = in.createIntArray();
+ }
+
+ public static final Creator<WebNotification> CREATOR =
+ new Creator<>() {
+ @Override
+ public WebNotification createFromParcel(final Parcel in) {
+ final int version = in.readInt();
+ if (version != VERSION) {
+ throw new ParcelFormatException(
+ "Mismatched version: " + version + " expected: " + VERSION);
+ }
+ return new WebNotification(in);
+ }
+
+ @Override
+ public WebNotification[] newArray(final int size) {
+ return new WebNotification[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
new file mode 100644
index 0000000000..40db55fa3c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
@@ -0,0 +1,29 @@
+/* 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public interface WebNotificationDelegate {
+ /**
+ * This is called when a new notification is created.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onShowNotification(@NonNull final WebNotification notification) {}
+
+ /**
+ * This is called when an existing notification is closed.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onCloseNotification(@NonNull final WebNotification notification) {}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
new file mode 100644
index 0000000000..f5ea153bfe
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
@@ -0,0 +1,165 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class WebPushController {
+ private static final String LOGTAG = "WebPushController";
+
+ private WebPushDelegate mDelegate;
+ private BundleEventListener mEventListener;
+
+ /* package */ WebPushController() {
+ mEventListener = new EventListener();
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mEventListener,
+ "GeckoView:PushSubscribe",
+ "GeckoView:PushUnsubscribe",
+ "GeckoView:PushGetSubscription");
+ }
+
+ /**
+ * Sets the {@link WebPushDelegate} for this instance.
+ *
+ * @param delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable WebPushDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Gets the {@link WebPushDelegate} for this instance.
+ *
+ * @return delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public WebPushDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mDelegate;
+ }
+
+ /**
+ * Send a push event for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+ onPushEvent(scope, null);
+ }
+
+ /**
+ * Send a push event with a payload for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ * @param data The unencrypted payload.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope, final @Nullable byte[] data) {
+ ThreadUtils.assertOnUiThread();
+
+ GeckoThread.waitForState(GeckoThread.State.JNI_READY)
+ .accept(
+ val -> {
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("scope", scope);
+ msg.putString("data", Base64Utils.encode(data));
+ EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg);
+ },
+ e -> Log.e(LOGTAG, "Unable to deliver Web Push message", e));
+ }
+
+ /**
+ * Notify that a given subscription has changed. This is normally a signal to the content that it
+ * needs to re-subscribe.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onSubscriptionChanged(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("scope", scope);
+ EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg);
+ }
+
+ private class EventListener implements BundleEventListener {
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (mDelegate == null) {
+ callback.sendError("Not allowed");
+ return;
+ }
+
+ switch (event) {
+ case "GeckoView:PushSubscribe":
+ {
+ byte[] appServerKey = null;
+ if (message.containsKey("appServerKey")) {
+ appServerKey = Base64Utils.decode(message.getString("appServerKey"));
+ }
+
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onSubscribe(message.getString("scope"), appServerKey);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ subscription ->
+ callback.sendSuccess(subscription != null ? subscription.toBundle() : null),
+ error -> callback.sendSuccess(null));
+ break;
+ }
+ case "GeckoView:PushUnsubscribe":
+ {
+ final GeckoResult<Void> result = mDelegate.onUnsubscribe(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(result.map(val -> null));
+ break;
+ }
+ case "GeckoView:PushGetSubscription":
+ {
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onGetSubscription(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(subscription -> subscription != null ? subscription.toBundle() : null));
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
new file mode 100644
index 0000000000..d9e9c39274
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
@@ -0,0 +1,62 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+public interface WebPushDelegate {
+ /**
+ * Creates a push subscription for the given service worker scope. A scope uniquely identifies a
+ * service worker. `appServerKey` optionally creates a restricted subscription.
+ *
+ * <p>Applications will likely want to persist the returned {@link WebPushSubscription} in order
+ * to support {@link #onGetSubscription(String)}.
+ *
+ * @param scope The Service Worker scope.
+ * @param appServerKey An optional application server key.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-subscribe">subscribe()</a>
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscriptionoptionsinit-applicationserverkey">Application
+ * server key</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onSubscribe(
+ @NonNull final String scope, @Nullable final byte[] appServerKey) {
+ return null;
+ }
+
+ /**
+ * Retrieves a subscription for the given service worker scope.
+ *
+ * @param scope The scope for the requested {@link WebPushSubscription}.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushmanager-getsubscription">getSubscription()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onGetSubscription(
+ @NonNull final String scope) {
+ return null;
+ }
+
+ /**
+ * Removes a push subscription. If this fails, apps should resolve the returned {@link
+ * GeckoResult} with an exception.
+ *
+ * @param scope The Service Worker scope for the subscription.
+ * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing.
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscription-unsubscribe">unsubscribe()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<Void> onUnsubscribe(@NonNull final String scope) {
+ return null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
new file mode 100644
index 0000000000..7ce9a3d60c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
@@ -0,0 +1,180 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * This class represents a single Web Push subscription, as described in the <a
+ * href="https://www.w3.org/TR/push-api/">Web Push API</a> specification.
+ *
+ * <p>This is a low-level interface, allowing applications to do all of the heavy lifting
+ * themselves. It is recommended that consumers have a thorough understanding of the Web Push API,
+ * especially <a href="https://tools.ietf.org/html/rfc8291">RFC 8291</a>.
+ *
+ * <p>Only trivial sanity checks are performed on the values held here. The application must ensure
+ * it is generating compliant keys/secrets itself.
+ */
+public class WebPushSubscription implements Parcelable {
+ private static final int P256_PUBLIC_KEY_LENGTH = 65;
+
+ /**
+ * The Service Worker scope associated with this subscription.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">ServiceWorker
+ * registration</a>
+ */
+ @NonNull public final String scope;
+
+ /**
+ * The Web Push endpoint for this subscription. This is the URL of a web service which implements
+ * the Web Push protocol.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc8030#section-5">RFC 8030</a>
+ */
+ @NonNull public final String endpoint;
+
+ /**
+ * This is an optional public key provided by the application server to authenticate itself with
+ * the endpoint, formatted according to X9.62.
+ *
+ * <p>This key is used for VAPID, the Voluntary Application Server Identification (VAPID) for Web
+ * Push, from <a href="https://tools.ietf.org/html/rfc8292">RFC 8292</a>.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey">applicationServerKey</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+ @Nullable public final byte[] appServerKey;
+
+ /**
+ * The P-256 EC public key, formatted as X9.62, generated by the embedder, to be provided to the
+ * app server for message encryption.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh">PushEncryptionKeyName
+ * - p256dh</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.1">RFC 8291 section 3.1</a>
+ */
+ @NonNull public final byte[] browserPublicKey;
+
+ /**
+ * 16 byte secret key, generated by the embedder, to be provided to the app server for use in
+ * encrypting and authenticating messages sent to the {@link #endpoint}.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth">PushEncryptionKeyName
+ * - auth</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.2">RFC 8291, section 3.2</a>
+ */
+ @NonNull public final byte[] authSecret;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public WebPushSubscription(
+ final @NonNull String scope,
+ final @NonNull String endpoint,
+ final @Nullable byte[] appServerKey,
+ final @NonNull byte[] browserPublicKey,
+ final @NonNull byte[] authSecret) {
+ this.scope = scope;
+ this.endpoint = endpoint;
+ this.appServerKey = appServerKey;
+ this.browserPublicKey = browserPublicKey;
+ this.authSecret = authSecret;
+
+ if (appServerKey != null) {
+ if (appServerKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("appServerKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (Arrays.equals(appServerKey, browserPublicKey)) {
+ throw new IllegalArgumentException("appServerKey and browserPublicKey must differ");
+ }
+ }
+
+ if (browserPublicKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("browserPublicKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (authSecret.length != 16) {
+ throw new IllegalArgumentException("authSecret must be 128 bits");
+ }
+ }
+
+ private WebPushSubscription(final Parcel in) {
+ this.scope = in.readString();
+ this.endpoint = in.readString();
+
+ if (ParcelableUtils.readBoolean(in)) {
+ this.appServerKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.appServerKey);
+ } else {
+ appServerKey = null;
+ }
+
+ this.browserPublicKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.browserPublicKey);
+
+ this.authSecret = new byte[16];
+ in.readByteArray(this.authSecret);
+ }
+
+ /* package */ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(5);
+ bundle.putString("scope", scope);
+ bundle.putString("endpoint", endpoint);
+ if (appServerKey != null) {
+ bundle.putString("appServerKey", Base64Utils.encode(appServerKey));
+ }
+ bundle.putString("browserPublicKey", Base64Utils.encode(browserPublicKey));
+ bundle.putString("authSecret", Base64Utils.encode(authSecret));
+ return bundle;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(scope);
+ out.writeString(endpoint);
+
+ ParcelableUtils.writeBoolean(out, appServerKey != null);
+ if (appServerKey != null) {
+ out.writeByteArray(appServerKey);
+ }
+
+ out.writeByteArray(browserPublicKey);
+ out.writeByteArray(authSecret);
+ }
+
+ public static final Parcelable.Creator<WebPushSubscription> CREATOR =
+ new Parcelable.Creator<WebPushSubscription>() {
+ @Override
+ @AnyThread
+ public WebPushSubscription createFromParcel(final Parcel parcel) {
+ return new WebPushSubscription(parcel);
+ }
+
+ @Override
+ @AnyThread
+ public WebPushSubscription[] newArray(final int size) {
+ return new WebPushSubscription[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
new file mode 100644
index 0000000000..30ee5451aa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
@@ -0,0 +1,248 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this
+ * class via {@link WebRequest.Builder}, and fetch responses via {@link
+ * GeckoWebExecutor#fetch(WebRequest)}.
+ */
+@WrapForJNI
+@AnyThread
+public class WebRequest extends WebMessage {
+ /** The HTTP method for the request. Defaults to "GET". */
+ public final @NonNull String method;
+
+ /** The body of the request. Must be a directly-allocated ByteBuffer. May be null. */
+ public final @Nullable ByteBuffer body;
+
+ /**
+ * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. These modes match those from
+ * the DOM Fetch API.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request/cache">DOM Fetch API
+ * cache modes</a>
+ */
+ public final @CacheMode int cacheMode;
+
+ /**
+ * If true, do not use newer protocol features that might have interop problems on the Internet.
+ * Intended only for use with critical infrastructure.
+ */
+ public final boolean beConservative;
+
+ /** The value of the Referer header for this request. */
+ public final @Nullable String referrer;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CACHE_MODE_DEFAULT,
+ CACHE_MODE_NO_STORE,
+ CACHE_MODE_RELOAD,
+ CACHE_MODE_NO_CACHE,
+ CACHE_MODE_FORCE_CACHE,
+ CACHE_MODE_ONLY_IF_CACHED
+ })
+ public @interface CacheMode {};
+
+ /** Default cache mode. Normal caching rules apply. */
+ public static final int CACHE_MODE_DEFAULT = 1;
+
+ /**
+ * The response will be fetched from the server without looking in the cache, and will not update
+ * the cache with the downloaded response.
+ */
+ public static final int CACHE_MODE_NO_STORE = 2;
+
+ /**
+ * The response will be fetched from the server without looking in the cache. The cache will be
+ * updated with the downloaded response.
+ */
+ public static final int CACHE_MODE_RELOAD = 3;
+
+ /** Forces a conditional request to the server if there is a cache match. */
+ public static final int CACHE_MODE_NO_CACHE = 4;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match, a normal request will be made and the cache will be updated with the downloaded
+ * response.
+ */
+ public static final int CACHE_MODE_FORCE_CACHE = 5;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match from the cache, 504 Gateway Timeout will be returned.
+ */
+ public static final int CACHE_MODE_ONLY_IF_CACHED = 6;
+
+ /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT;
+ /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED;
+
+ /**
+ * Constructs a WebRequest with the specified URI.
+ *
+ * @param uri A URI String, e.g. https://mozilla.org
+ */
+ public WebRequest(final @NonNull String uri) {
+ this(new Builder(uri));
+ }
+
+ /** Constructs a new WebRequest from a {@link WebRequest.Builder}. */
+ /* package */ WebRequest(final @NonNull Builder builder) {
+ super(builder);
+ method = builder.mMethod;
+ cacheMode = builder.mCacheMode;
+ referrer = builder.mReferrer;
+ beConservative = builder.mBeConservative;
+
+ if (builder.mBody != null) {
+ body = builder.mBody.asReadOnlyBuffer();
+ } else {
+ body = null;
+ }
+ }
+
+ /** Builder offers a convenient way for constructing {@link WebRequest} instances. */
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ String mMethod = "GET";
+ /* package */ int mCacheMode = CACHE_MODE_DEFAULT;
+ /* package */ String mReferrer;
+ /* package */ boolean mBeConservative;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ public Builder(final @NonNull String uri) {
+ super(uri);
+ }
+
+ @Override
+ public @NonNull Builder uri(final @NonNull String uri) {
+ super.uri(uri);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ super.header(key, value);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ super.addHeader(key, value);
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param buffer A {@link ByteBuffer} with the data. Must be allocated directly via {@link
+ * ByteBuffer#allocateDirect(int)}.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable ByteBuffer buffer) {
+ if (buffer != null && !buffer.isDirect()) {
+ throw new IllegalArgumentException("body must be directly allocated");
+ }
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param bodyString A {@link String} with the data.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable String bodyString) {
+ if (bodyString == null) {
+ mBody = null;
+ return this;
+ }
+ final CharBuffer chars = CharBuffer.wrap(bodyString);
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(bodyString.length());
+ Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true);
+
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the HTTP method.
+ *
+ * @param method The HTTP method String.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder method(final @NonNull String method) {
+ mMethod = method;
+ return this;
+ }
+
+ /**
+ * Set the cache mode.
+ *
+ * @param mode One of the {@link #CACHE_MODE_DEFAULT CACHE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder cacheMode(final @CacheMode int mode) {
+ if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+ mCacheMode = mode;
+ return this;
+ }
+
+ /**
+ * Set the HTTP Referer header.
+ *
+ * @param referrer A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder referrer(final @Nullable String referrer) {
+ mReferrer = referrer;
+ return this;
+ }
+
+ /**
+ * Set the beConservative property.
+ *
+ * @param beConservative If true, do not use newer protocol features that might have interop
+ * problems on the Internet. Intended only for use with critical infrastructure.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder beConservative(final boolean beConservative) {
+ mBeConservative = beConservative;
+ return this;
+ }
+
+ /**
+ * @return A {@link WebRequest} constructed with the values from this Builder instance.
+ */
+ public @NonNull WebRequest build() {
+ if (mUri == null) {
+ throw new IllegalStateException("Must set URI");
+ }
+ return new WebRequest(this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
new file mode 100644
index 0000000000..455078feb7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
@@ -0,0 +1,380 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.XPCOMError;
+
+/**
+ * WebRequestError is simply a container for error codes and categories used by {@link
+ * GeckoSession.NavigationDelegate#onLoadError(GeckoSession, String, WebRequestError)}.
+ */
+@AnyThread
+public class WebRequestError extends Exception {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_CATEGORY_UNKNOWN,
+ ERROR_CATEGORY_SECURITY,
+ ERROR_CATEGORY_NETWORK,
+ ERROR_CATEGORY_CONTENT,
+ ERROR_CATEGORY_URI,
+ ERROR_CATEGORY_PROXY,
+ ERROR_CATEGORY_SAFEBROWSING
+ })
+ public @interface ErrorCategory {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_UNKNOWN,
+ ERROR_SECURITY_SSL,
+ ERROR_SECURITY_BAD_CERT,
+ ERROR_NET_RESET,
+ ERROR_NET_INTERRUPT,
+ ERROR_NET_TIMEOUT,
+ ERROR_CONNECTION_REFUSED,
+ ERROR_UNKNOWN_PROTOCOL,
+ ERROR_UNKNOWN_HOST,
+ ERROR_UNKNOWN_SOCKET_TYPE,
+ ERROR_UNKNOWN_PROXY_HOST,
+ ERROR_MALFORMED_URI,
+ ERROR_REDIRECT_LOOP,
+ ERROR_SAFEBROWSING_PHISHING_URI,
+ ERROR_SAFEBROWSING_MALWARE_URI,
+ ERROR_SAFEBROWSING_UNWANTED_URI,
+ ERROR_SAFEBROWSING_HARMFUL_URI,
+ ERROR_CONTENT_CRASHED,
+ ERROR_OFFLINE,
+ ERROR_PORT_BLOCKED,
+ ERROR_PROXY_CONNECTION_REFUSED,
+ ERROR_FILE_NOT_FOUND,
+ ERROR_FILE_ACCESS_DENIED,
+ ERROR_INVALID_CONTENT_ENCODING,
+ ERROR_UNSAFE_CONTENT_TYPE,
+ ERROR_CORRUPTED_CONTENT,
+ ERROR_DATA_URI_TOO_LONG,
+ ERROR_HTTPS_ONLY,
+ ERROR_BAD_HSTS_CERT
+ })
+ public @interface Error {}
+
+ /**
+ * This is normally used for error codes that don't currently fit into any of the other
+ * categories.
+ */
+ public static final int ERROR_CATEGORY_UNKNOWN = 0x1;
+
+ /** This is used for error codes that relate to SSL certificate validation. */
+ public static final int ERROR_CATEGORY_SECURITY = 0x2;
+
+ /** This is used for error codes relating to network problems. */
+ public static final int ERROR_CATEGORY_NETWORK = 0x3;
+
+ /** This is used for error codes relating to invalid or corrupt web pages. */
+ public static final int ERROR_CATEGORY_CONTENT = 0x4;
+
+ public static final int ERROR_CATEGORY_URI = 0x5;
+ public static final int ERROR_CATEGORY_PROXY = 0x6;
+ public static final int ERROR_CATEGORY_SAFEBROWSING = 0x7;
+
+ /** An unknown error occurred */
+ public static final int ERROR_UNKNOWN = 0x11;
+
+ // Security
+ /** This is used for a variety of SSL negotiation problems. */
+ public static final int ERROR_SECURITY_SSL = 0x22;
+
+ /** This is used to indicate an untrusted or otherwise invalid SSL certificate. */
+ public static final int ERROR_SECURITY_BAD_CERT = 0x32;
+
+ // Network
+ /** The network connection was interrupted. */
+ public static final int ERROR_NET_INTERRUPT = 0x23;
+
+ /** The network request timed out. */
+ public static final int ERROR_NET_TIMEOUT = 0x33;
+
+ /** The network request was refused by the server. */
+ public static final int ERROR_CONNECTION_REFUSED = 0x43;
+
+ /** The network request tried to use an unknown socket type. */
+ public static final int ERROR_UNKNOWN_SOCKET_TYPE = 0x53;
+
+ /** A redirect loop was detected. */
+ public static final int ERROR_REDIRECT_LOOP = 0x63;
+
+ /** This device does not have a network connection. */
+ public static final int ERROR_OFFLINE = 0x73;
+
+ /** The request tried to use a port that is blocked by either the OS or Gecko. */
+ public static final int ERROR_PORT_BLOCKED = 0x83;
+
+ /** The connection was reset. */
+ public static final int ERROR_NET_RESET = 0x93;
+
+ /**
+ * GeckoView could not connect to this website in HTTPS-only mode. Call
+ * document.reloadWithHttpsOnlyException() in the error page to temporarily disable HTTPS only
+ * mode for this request.
+ *
+ * <p>See also {@link GeckoSession.NavigationDelegate#onLoadError}
+ */
+ public static final int ERROR_HTTPS_ONLY = 0xA3;
+
+ /**
+ * A certificate validation error occurred when connecting to a site that does not allow error
+ * overrides.
+ */
+ public static final int ERROR_BAD_HSTS_CERT = 0xB3;
+
+ // Content
+ /** A content type was returned which was deemed unsafe. */
+ public static final int ERROR_UNSAFE_CONTENT_TYPE = 0x24;
+
+ /** The content returned was corrupted. */
+ public static final int ERROR_CORRUPTED_CONTENT = 0x34;
+
+ /** The content process crashed. */
+ public static final int ERROR_CONTENT_CRASHED = 0x44;
+
+ /** The content has an invalid encoding. */
+ public static final int ERROR_INVALID_CONTENT_ENCODING = 0x54;
+
+ // URI
+ /** The host could not be resolved. */
+ public static final int ERROR_UNKNOWN_HOST = 0x25;
+
+ /** An invalid URL was specified. */
+ public static final int ERROR_MALFORMED_URI = 0x35;
+
+ /** An unknown protocol was specified. */
+ public static final int ERROR_UNKNOWN_PROTOCOL = 0x45;
+
+ /** A file was not found (usually used for file:// URIs). */
+ public static final int ERROR_FILE_NOT_FOUND = 0x55;
+
+ /** The OS blocked access to a file. */
+ public static final int ERROR_FILE_ACCESS_DENIED = 0x65;
+
+ /** A data:// URI is too long to load at the top level. */
+ public static final int ERROR_DATA_URI_TOO_LONG = 0x75;
+
+ // Proxy
+ /** The proxy server refused the connection. */
+ public static final int ERROR_PROXY_CONNECTION_REFUSED = 0x26;
+
+ /** The host name of the proxy server could not be resolved. */
+ public static final int ERROR_UNKNOWN_PROXY_HOST = 0x36;
+
+ // Safebrowsing
+ /** The requested URI was present in the "malware" blocklist. */
+ public static final int ERROR_SAFEBROWSING_MALWARE_URI = 0x27;
+
+ /** The requested URI was present in the "unwanted" blocklist. */
+ public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 0x37;
+
+ /** The requested URI was present in the "harmful" blocklist. */
+ public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 0x47;
+
+ /** The requested URI was present in the "phishing" blocklist. */
+ public static final int ERROR_SAFEBROWSING_PHISHING_URI = 0x57;
+
+ /** The error code, e.g. {@link #ERROR_MALFORMED_URI}. */
+ public final int code;
+
+ /** The error category, e.g. {@link #ERROR_CATEGORY_URI}. */
+ public final int category;
+
+ /**
+ * The server certificate used. This can be useful if the error code is is e.g. {@link
+ * #ERROR_SECURITY_BAD_CERT}.
+ */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ */
+ public WebRequestError(final @Error int code, final @ErrorCategory int category) {
+ this(code, category, null);
+ }
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ * @param certificate The X509Certificate server certificate used, if applicable.
+ */
+ public WebRequestError(
+ final @Error int code, final @ErrorCategory int category, final X509Certificate certificate) {
+ super(String.format("Request failed, error=0x%x, category=0x%x", code, category));
+ this.code = code;
+ this.category = category;
+ this.certificate = certificate;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (other == null || !(other instanceof WebRequestError)) {
+ return false;
+ }
+
+ final WebRequestError otherError = (WebRequestError) other;
+
+ // We don't compare the certificate here because it's almost never what you want.
+ return otherError.code == this.code && otherError.category == this.category;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {category, code});
+ }
+
+ @WrapForJNI
+ /* package */ static WebRequestError fromGeckoError(
+ final long geckoError,
+ final int geckoErrorModule,
+ final int geckoErrorClass,
+ final byte[] certificateBytes) {
+ // XXX: the geckoErrorModule argument is redundant
+ assert geckoErrorModule == XPCOMError.getErrorModule(geckoError);
+ final int code = convertGeckoError(geckoError, geckoErrorClass);
+ final int category = getErrorCategory(XPCOMError.getErrorModule(geckoError), code);
+ X509Certificate certificate = null;
+ if (certificateBytes != null) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ certificate =
+ (X509Certificate)
+ factory.generateCertificate(new ByteArrayInputStream(certificateBytes));
+ } catch (final CertificateException e) {
+ throw new IllegalArgumentException("Unable to parse DER certificate");
+ }
+ }
+
+ return new WebRequestError(code, category, certificate);
+ }
+
+ @SuppressLint("WrongConstant")
+ @WrapForJNI
+ /* package */ static @ErrorCategory int getErrorCategory(
+ final long errorModule, final @Error int error) {
+ if (errorModule == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ return ERROR_CATEGORY_SECURITY;
+ }
+ return error & 0xF;
+ }
+
+ @WrapForJNI
+ /* package */ static @Error int convertGeckoError(
+ final long geckoError, final int geckoErrorClass) {
+ // safebrowsing
+ if (geckoError == XPCOMError.NS_ERROR_PHISHING_URI) {
+ return ERROR_SAFEBROWSING_PHISHING_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALWARE_URI) {
+ return ERROR_SAFEBROWSING_MALWARE_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNWANTED_URI) {
+ return ERROR_SAFEBROWSING_UNWANTED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HARMFUL_URI) {
+ return ERROR_SAFEBROWSING_HARMFUL_URI;
+ }
+ // content
+ if (geckoError == XPCOMError.NS_ERROR_CONTENT_CRASHED) {
+ return ERROR_CONTENT_CRASHED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_INVALID_CONTENT_ENCODING) {
+ return ERROR_INVALID_CONTENT_ENCODING;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNSAFE_CONTENT_TYPE) {
+ return ERROR_UNSAFE_CONTENT_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CORRUPTED_CONTENT) {
+ return ERROR_CORRUPTED_CONTENT;
+ }
+ // network
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_RESET;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_INTERRUPT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_TIMEOUT) {
+ return ERROR_NET_TIMEOUT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CONNECTION_REFUSED) {
+ return ERROR_CONNECTION_REFUSED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_SOCKET_TYPE) {
+ return ERROR_UNKNOWN_SOCKET_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_REDIRECT_LOOP) {
+ return ERROR_REDIRECT_LOOP;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HTTPS_ONLY) {
+ return ERROR_HTTPS_ONLY;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_BAD_HSTS_CERT) {
+ return ERROR_BAD_HSTS_CERT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_OFFLINE) {
+ return ERROR_OFFLINE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PORT_ACCESS_NOT_ALLOWED) {
+ return ERROR_PORT_BLOCKED;
+ }
+ // uri
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROTOCOL) {
+ return ERROR_UNKNOWN_PROTOCOL;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_HOST) {
+ return ERROR_UNKNOWN_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALFORMED_URI) {
+ return ERROR_MALFORMED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_NOT_FOUND) {
+ return ERROR_FILE_NOT_FOUND;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_ACCESS_DENIED) {
+ return ERROR_FILE_ACCESS_DENIED;
+ }
+ // proxy
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROXY_HOST) {
+ return ERROR_UNKNOWN_PROXY_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PROXY_CONNECTION_REFUSED) {
+ return ERROR_PROXY_CONNECTION_REFUSED;
+ }
+
+ if (XPCOMError.getErrorModule(geckoError) == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ if (geckoErrorClass == 1) {
+ return ERROR_SECURITY_SSL;
+ }
+ if (geckoErrorClass == 2) {
+ return ERROR_SECURITY_BAD_CERT;
+ }
+ }
+
+ return ERROR_UNKNOWN;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
new file mode 100644
index 0000000000..09bdc2a21e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
@@ -0,0 +1,183 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * 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/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * WebResponse represents an HTTP[S] response. It is normally created by {@link
+ * GeckoWebExecutor#fetch(WebRequest)}.
+ */
+@WrapForJNI
+@AnyThread
+public class WebResponse extends WebMessage {
+ /** The default read timeout for the {@link #body} stream. */
+ public static final long DEFAULT_READ_TIMEOUT_MS = 30000;
+
+ /** The HTTP status code for the response, e.g. 200. */
+ public final int statusCode;
+
+ /** A boolean indicating whether or not this response is the result of a redirection. */
+ public final boolean redirected;
+
+ /** Whether or not this response was delivered via a secure connection. */
+ public final boolean isSecure;
+
+ /** The server certificate used with this response, if any. */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * An {@link InputStream} containing the response body, if available. Attention: the stream must
+ * be closed whenever the app is done with it, even when the body is ignored. Otherwise the
+ * connection will not be closed until the stream is garbage collected
+ */
+ public final @Nullable InputStream body;
+
+ protected WebResponse(final @NonNull Builder builder) {
+ super(builder);
+ this.statusCode = builder.mStatusCode;
+ this.redirected = builder.mRedirected;
+ this.body = builder.mBody;
+ this.isSecure = builder.mIsSecure;
+ this.certificate = builder.mCertificate;
+
+ this.setReadTimeoutMillis(DEFAULT_READ_TIMEOUT_MS);
+ }
+
+ /**
+ * Sets the maximum amount of time to wait for data in the {@link #body} read() method. By
+ * default, the read timeout is set to {@link #DEFAULT_READ_TIMEOUT_MS}.
+ *
+ * <p>If 0, there will be no timeout and read() will block indefinitely.
+ *
+ * @param millis The duration in milliseconds for the timeout.
+ */
+ public void setReadTimeoutMillis(final long millis) {
+ if (this.body != null && this.body instanceof GeckoInputStream) {
+ ((GeckoInputStream) this.body).setReadTimeoutMillis(millis);
+ }
+ }
+
+ /** Builder offers a convenient way to create WebResponse instances. */
+ @WrapForJNI
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ int mStatusCode;
+ /* package */ boolean mRedirected;
+ /* package */ InputStream mBody;
+ /* package */ boolean mIsSecure;
+ /* package */ X509Certificate mCertificate;
+
+ /**
+ * Constructs a new Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ public Builder(final @NonNull String uri) {
+ super(uri);
+ }
+
+ @Override
+ public @NonNull Builder uri(final @NonNull String uri) {
+ super.uri(uri);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ super.header(key, value);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ super.addHeader(key, value);
+ return this;
+ }
+
+ /**
+ * Sets the {@link InputStream} containing the body of this response.
+ *
+ * @param stream An {@link InputStream} with the body of the response.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @NonNull InputStream stream) {
+ mBody = stream;
+ return this;
+ }
+
+ /**
+ * @param isSecure Whether or not this response is secure.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder isSecure(final boolean isSecure) {
+ mIsSecure = isSecure;
+ return this;
+ }
+
+ /**
+ * @param certificate The certificate used.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder certificate(final @NonNull X509Certificate certificate) {
+ mCertificate = certificate;
+ return this;
+ }
+
+ /**
+ * @param encodedCert The certificate used, encoded via DER. Only used via JNI.
+ */
+ @WrapForJNI(exceptionMode = "nsresult")
+ private void certificateBytes(final @NonNull byte[] encodedCert) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ final X509Certificate cert =
+ (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encodedCert));
+ certificate(cert);
+ } catch (final CertificateException e) {
+ throw new IllegalArgumentException("Unable to parse DER certificate");
+ }
+ }
+
+ /**
+ * Set the HTTP status code, e.g. 200.
+ *
+ * @param code A int representing the HTTP status code.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder statusCode(final int code) {
+ mStatusCode = code;
+ return this;
+ }
+
+ /**
+ * Set whether or not this response was the result of a redirect.
+ *
+ * @param redirected A boolean representing whether or not the request was redirected.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder redirected(final boolean redirected) {
+ mRedirected = redirected;
+ return this;
+ }
+
+ /**
+ * @return A {@link WebResponse} constructed with the values from this Builder instance.
+ */
+ public @NonNull WebResponse build() {
+ return new WebResponse(this);
+ }
+ }
+}
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
new file mode 100644
index 0000000000..55ea58fe19
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -0,0 +1,1299 @@
+---
+layout: default
+title: API Changelog
+description: GeckoView API Changelog.
+nav_exclude: true
+exclude: true
+---
+
+{% capture javadoc_uri %}{{ site.url }}{{ site.baseurl}}/javadoc/mozilla-central/org/mozilla/geckoview{% endcapture %}
+{% capture bugzilla %}https://bugzilla.mozilla.org/show_bug.cgi?id={% endcapture %}
+
+# GeckoView API Changelog.
+
+⚠️ breaking change and deprecation notices
+
+## v110
+- Added [`GeckoSession.ContentDelegate.onCookieBannerDetected`][110.1] and [`GeckoSession.ContentDelegate.onCookieBannerHandled`][110.2]
+- Added [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][110.3], for detecting cookie banners but not handle them, see ([bug 1797581]({{bugzilla}}1806188))
+- Added [`StorageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain`][110.4] see ([bug 1804747]({{bugzilla}}1804747))
+- Added [`Autofill.Node.getScreenRect`][110.5] for fission compatible.
+- ⚠️ Deprecated [`Autofill.Node.getDimensions`][110.6].
+ ([bug 1803733]({{bugzilla}}1803733))
+- Added [`ColorPrompt.predefinedValues`][110.7] to expose predefined values by [`datalist`][110.8] element in the color prompt.
+ ([bug 1805616]({{bugzilla}}1805616))
+
+[110.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerDetected(org.mozilla.geckoview.GeckoSession)
+[110.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerHandled(org.mozilla.geckoview.GeckoSession)
+[110.3]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY
+[110.4]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeAndPersistInPrivateBrowsingForDomain(java.lang.String,int)
+[110.5]: {{javadoc_uri}}/Autofill.Node.html#getScreenRect()
+[110.6]: {{javadoc_uri}}/Autofill.Node.html#getDimensions()
+[110.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ColorPrompt.html#predefinedValues
+[110.8]: https://developer.mozilla.org/en/docs/Web/HTML/Element/datalist
+
+## v109
+- Added [`SelectionActionDelegate.Selection.screenRect`][109.1] for fission compatible.
+- ⚠️ Deprecated [`SelectionActionDelegate.Selection.clientRect`][109.2],
+ [`BasicSelectionActionDelegate.mTempMatrix`][109.3] and
+ [`BasicSelectionActionDelegate.mTempRect`][109.4].
+ ([bug 1785759]({{bugzilla}}1785759))
+- Added [`StorageController.setCookieBannerModeForDomain`][109.5], [`StorageController.getCookieBannerModeForDomain`][109.6] and [`StorageController.removeCookieBannerModeForDomain`][109.7] see ([bug 1797581]({{bugzilla}}1797581))
+
+[109.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#screenRect
+[109.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect
+[109.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix
+[109.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect
+[109.5]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeForDomain(java.lang.String,int,boolean)
+[109.6]: {{javadoc_uri}}/StorageController.html#getCookieBannerModeForDomain(java.lang.String,boolean)
+[109.7]: {{javadoc_uri}}/StorageController.html#removeCookieBannerModeForDomain(java.lang.String,boolean)
+
+## v108
+- Added [`ContentBlocking.CookieBannerMode`][108.1]; [`cookieBannerHandlingMode`][108.2] and [`cookieBannerHandlingModePrivateBrowsing`][108.3] to [`ContentBlocking.Settings.Builder`][81.1];
+ [`getCookieBannerMode`][108.4], [`setCookieBannerMode`][108.5], [`getCookieBannerModePrivateBrowsing`][108.6] and [`setCookieBannerModePrivateBrowsing`][108.7] to [`ContentBlocking.Settings`][81.2]
+ ([bug 1790724]({{bugzilla}}1790724))
+- Added [`GeckoSession.GeckoPrintException`][108.9] to improver error reporting while generating a PDF from website, ([bug 1798402]({{bugzilla}}1798402)).
+- Added [`GeckoSession.containsFormData`][108.10] that returns a `GeckoResult<Boolean>` for whether or not a session has form data, ([bug 1777506]({{bugzilla}}1777506)).
+
+[108.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html
+[108.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingMode(int)
+[108.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingModePrivateBrowsing(int)
+[108.4]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerMode()
+[108.5]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerMode(int)
+[108.6]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerModePrivateBrowsing()
+[108.7]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerModePrivateBrowsing(int)
+[108.9]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html
+[108.10]: {{javadoc_uri}}/GeckoSession.html#containsFormData()
+
+## v107
+- Removed deprecated [`cookieLifetime`][103.2]
+- Removed deprecated `setPermission`, see deprecation note in [v90](#v90)
+
+## 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))
+- Removed deprecated `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5]
+- Removed deprecated [`GeckoSession.autofill`][102.18].
+ ([bug 1781180]({{bugzilla}}1781180))
+- Removed deprecated [`onLocationChange(2)`][102.3]
+ ([bug 1781180]({{bugzilla}}1781180))
+
+## v103
+- Added [`GeckoSession.saveAsPdf`][103.1] that returns a `GeckoResult<InputStream>` that contains a PDF of the current session's page.
+- Added missing `@Deprecated` tag for `setPermission`, see deprecation note in [v90](#v90).
+- ⚠️ Deprecated [`cookieLifetime`][103.2], this feature is not available anymore.
+
+[103.1]: {{javadoc_uri}}/GeckoSession.html#saveAsPdf()
+[103.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieLifetime(int)
+
+## v102
+- Added [`DateTimePrompt.stepValue`][102.1] to export [`step`][102.2] attribute of input element.
+ ([bug 1499635]({{bugzilla}}1499635))
+- Deprecated [`onLocationChange(2)`][102.3], please use [`onLocationChange(3)`][102.4].
+- Added [`GeckoSession.setPriorityHint`][102.5] function to set the session to either high priority or default.
+- [`WebRequestError.ERROR_HTTPS_ONLY`][102.6] now has error category
+ `ERROR_CATEGORY_NETWORK` rather than `ERROR_CATEGORY_SECURITY`.
+- ⚠️ The Autofill.Delegate API now receives a [`AutofillNode`][102.7] object instead of
+ the entire [`Node`][102.8] structure. The `onAutofill` delegate method is now split
+ into several methods: [`onNodeAdd`][102.9], [`onNodeBlur`][102.10],
+ [`onNodeFocus`][102.11], [`onNodeRemove`][102.12], [`onNodeUpdate`][102.13],
+ [`onSessionCancel`][102.14], [`onSessionCommit`][102.15],
+ [`onSessionStart`][102.16].
+- Added [`PromptInstanceDelegate.onPromptUpdate`][102.17] to allow GeckoView to update current prompts.
+ ([bug 1758800]({{bugzilla}}1758800))
+- Deprecated [`GeckoSession.autofill`][102.18], use [`Autofill.Session.autofill`][102.19] instead.
+ ([bug 1770010]({{bugzilla}}1770010))
+- Added [`WebRequestError.ERROR_BAD_HSTS_CERT`][102.20] error code to notify the app of a connection to a site that does not allow error overrides.
+ ([bug 1721220]({{bugzilla}}1721220))
+
+[102.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.DateTimePrompt.html#stepValue
+[102.2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#step
+[102.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[102.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[102.5]: {{javadoc_uri}}/GeckoSession.html#setPriorityHint(int)
+[102.6]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+[102.7]: {{javadoc_uri}}/Autofill.AutofillNode.html
+[102.8]: {{javadoc_uri}}/Autofill.Node.html
+[102.9]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeAdd(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.10]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeBlur(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.11]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeFocus(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.12]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeRemove(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.13]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeUpdate(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.14]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCancel(org.mozilla.geckoview.GeckoSession)
+[102.15]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCommit(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.16]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionStart(org.mozilla.geckoview.GeckoSession)
+[102.17]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html#onPromptUpdate(org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt)
+[102.18]: {{javadoc_uri}}/GeckoSession.html#autofill(android.util.SparseArray)
+[102.19]: {{javadoc_uri}}/Autofill.Session.html#autofill(android.util.SparseArray)
+[102.20]: {{javadoc_uri}}/WebRequestError.html#ERROR_BAD_HSTS_CERT
+
+## v101
+- Added [`GeckoDisplay.surfaceChanged`][101.1] function taking new type [`GeckoDisplay.SurfaceInfo`][101.2].
+ This allows the caller to provide a [`SurfaceControl`][101.3] object, which must be set on SDK level 29 and
+ above when rendering in to a `SurfaceView`.
+ ([bug 1762424]({{bugzilla}}1762424))
+- ⚠️ Deprecated old `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5].
+- Add [`WebExtensionController.optionalPrompt`][101.6] to allow handling of optional permission requests from extensions.
+
+[101.1]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(org.mozilla.geckoview.GeckoDisplay.SurfaceInfo)
+[101.2]: {{javadoc_uri}}/GeckoDisplay.SurfaceInfo.html
+[101.3]: https://developer.android.com/reference/android/view/SurfaceControl
+[101.4]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int)
+[101.5]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int,int,int)
+[101.6]: {{javadoc_uri}}/WebExtensionController.html#optionalPrompt(org.mozilla.geckoview.WebExtension.Message,org.mozilla.geckoview.WebExtension)
+
+## v100
+- ⚠️ Changed [`GeckoSession.isOpen`][100.1] to `@UiThread`.
+- [`WebNotification`][100.2] now implements [`Parcelable`][100.3] to support
+ persisting notifications and responding to them while the browser is not
+ running.
+- Removed deprecated `GeckoRuntime.EXTRA_CRASH_FATAL`
+- Removed deprecated `MediaSource.rawId`
+
+[100.1]: {{javadoc_uri}}/GeckoSession.html#isOpen()
+[100.2]: {{javadoc_uri}}/WebNotification.html
+[100.3]: https://developer.android.com/reference/android/os/Parcelable
+
+## v99
+- Removed deprecated `GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`.
+ ([bug 1754244]({{bugzilla}}1754244))
+
+## v98
+- Add [`WebRequest.beConservative`][98.1] to allow critical infrastructure to
+ avoid using bleeding-edge network features.
+ ([bug 1750231]({{bugzilla}}1750231))
+
+[98.1]: {{javadoc_uri}}/WebRequest.html#beConservative
+
+## v97
+- ⚠️ Deprecated [`MediaSource.rawId`][97.1],
+ which now provides the same string as [`id`][97.2].
+ ([bug 1744346]({{bugzilla}}1744346))
+- Added [`EXTRA_CRASH_PROCESS_TYPE`][97.3] field to `ACTION_CRASHED` intents,
+ and corresponding [`CRASHED_PROCESS_TYPE_*`][97.4] constants, indicating which
+ type of process a crash occured in.
+ ([bug 1743454]({{bugzilla}}1743454))
+- ⚠️ Deprecated [`EXTRA_CRASH_FATAL`][97.5]. Use `EXTRA_CRASH_PROCESS_TYPE` instead.
+ ([bug 1743454]({{bugzilla}}1743454))
+- Added [`OrientationController`][97.6] to allow GeckoView to handle orientation locking.
+ ([bug 1697647]({{bugzilla}}1697647))
+- Added [GeckoSession.goBack][97.7] and [GeckoSession.goForward][97.8] with a
+ `userInteraction` parameter. Updated the default goBack/goForward behaviour
+ to also be considered as a user interaction.
+ ([bug 1644595]({{bugzilla}}1644595))
+
+[97.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#rawId
+[97.2]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#id
+[97.3]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_PROCESS_TYPE
+[97.4]: {{javadoc_uri}}/GeckoRuntime.html#CRASHED_PROCESS_TYPE_MAIN
+[97.5]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_FATAL
+[97.6]: {{javadoc_uri}}/OrientationController.html
+[97.7]: {{javadoc_uri}}/GeckoSession.html#goBack(boolean)
+[97.8]: {{javadoc_uri}}/GeckoSession.html#goForward(boolean)
+
+## v96
+- Added [`onLoginFetch`][96.1] which allows apps to provide all saved logins to
+ GeckoView.
+ ([bug 1733423]({{bugzilla}}1733423))
+- Added [`GeckoResult.finally_`][96.2] to unconditionally run an action after
+ the GeckoResult has been completed.
+ ([bug 1736433]({{bugzilla}}1736433))
+- Added [`ERROR_INVALID_DOMAIN`][96.3] to `WebExtension.InstallException.ErrorCodes`.
+ ([bug 1740634]({{bugzilla}}1740634))
+- Added [`Selection.pasteAsPlainText`][96.4] to paste HTML content as plain
+ text.
+ ([bug 1740414]({{bugzilla}}1740414))
+- Removed deprecated Content Blocking APIs.
+ ([bug 1743706]({{bugzilla}}1743706))
+
+[96.1]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html#onLoginFetch()
+[96.2]: {{javadoc_uri}}/GeckoResult.html#finally_(java.lang.Runnable)
+[96.3]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INVALID_DOMAIN
+[96.4]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#pasteAsPlainText()
+
+## v95
+- Added [`GeckoSession.ContentDelegate.onPointerIconChange()`][95.1] to notify
+ the application of changing pointer icon. If the application wants to handle
+ pointer icon, it should override this.
+ ([bug 1672609]({{bugzilla}}1672609))
+- Deprecated [`ContentBlockingController`][95.2], use
+ [`StorageController`][95.3] instead. A [`PERMISSION_TRACKING`][95.4]
+ permission is now present in [`onLocationChange`][95.5] for every page load,
+ which can be used to set tracking protection exceptions.
+ ([bug 1714945]({{bugzilla}}1714945))
+- Added [`setPrivateBrowsingPermanentPermission`][95.6], which allows apps to set
+ permanent permissions in private browsing (e.g. to set permanent tracking
+ protection permissions in private browsing).
+ ([bug 1714945]({{bugzilla}}1714945))
+- Deprecated [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7] due to typo.
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoRuntimeSettings.Builder.enterpriseRootsEnabled`][95.8] to replace [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7].
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoSession.ContentDelegate.onPreviewImage`][95.9] to notify
+ the application of a preview image URL.
+ ([bug 1732219]({{bugzilla}}1732219))
+
+[95.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPointerIconChange(org.mozilla.geckoview.GeckoSession,android.view.PointerIcon)
+[95.2]: {{javadoc_uri}}/ContentBlockingController.html
+[95.3]: {{javadoc_uri}}/StorageController.java
+[95.4]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING
+[95.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[95.6]: {{javadoc_uri}}/StorageController.html#setPrivateBrowsingPermanentPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[95.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpiseRootsEnabled(boolean)
+[95.8]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean)
+[95.9]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPreviewImage(org.mozilla.geckoview.GeckoSession,java.lang.String)
+
+## v94
+- Extended [`Autocomplete`][78.7] API to support credit card saving.
+ ([bug 1703976]({{bugzilla}}1703976))
+
+## v93
+- Removed deprecated [`Autocomplete.LoginStorageDelegate`][78.8].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Removed deprecated [`GeckoRuntime.getProfileDir`][90.5].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Added [`PromptInstanceDelegate`][93.1] to allow GeckoView to dismiss stale prompts.
+ ([bug 1710668]({{bugzilla}}1710668))
+- Added [`WebRequestError.ERROR_HTTPS_ONLY`][93.2] error code to allow GeckoView display custom HTTPS-only error pages and bypass them.
+ ([bug 1697866]({{bugzilla}}1697866))
+
+[93.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html
+[93.2]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+
+## v92
+- Added [`PermissionDelegate.PERMISSION_STORAGE_ACCESS`][92.1] to
+ control the allowing of third-party frames to access first-party cookies and
+ storage. ([bug 1543720]({{bugzilla}}1543720))
+- Added [`ContentDelegate.onShowDynamicToolbar`][92.2] to notify
+ the app that it must fully-expand its dynamic toolbar ([bug 1690296]({{bugzilla}}1690296))
+- Removed deprecated `GeckoResult.ALLOW` and `GeckoResult.DENY`.
+ Use [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9] instead.
+
+[92.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_STORAGE_ACCESS
+[92.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onShowDynamicToolbar(org.mozilla.geckoview.GeckoSession)
+
+## v91
+- Extended [`Autocomplete`][78.7] API to support addresses.
+ ([bug 1699794]({{bugzilla}}1699794)).
+- Added [`clearDataFromBaseDomain`][91.1] to [`StorageController`][90.2] for
+ clearing site data by base domain. This includes data of associated subdomains
+ and data partitioned via [`State Partitioning`][91.3].
+- Removed deprecated `MediaElement` API.
+
+[91.1]: {{javadoc_uri}}/StorageController.html#clearDataFromBaseDomain(java.lang.String,long)
+[91.2]: {{javadoc_uri}}/StorageController.html
+[91.3]: https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning
+
+## v90
+- Added [`WebNotification.silent`][90.1] and [`WebNotification.vibrate`][90.2]
+ support. See also [Web/API/Notification/silent][90.3] and
+ [Web/API/Notification/vibrate][90.4].
+ ([bug 1696145]({{bugzilla}}1696145))
+- ⚠️ Deprecated [`GeckoRuntime.getProfileDir`][90.5], the API is being kept for
+ compatibility but it always returns null.
+- Added [`forceEnableAccessibility`][90.6] runtime setting to enable
+ accessibility during testing.
+ ([bug 1701269]({{bugzilla}}1701269))
+- Removed deprecated [`GeckoView.onTouchEventForResult`][88.4].
+ ([bug 1706403]({{bugzilla}}1706403))
+- ⚠️ Updated [`onContentPermissionRequest`][90.7] to use [`ContentPermission`][90.8]; added
+ [`setPermission`][90.9] to [`StorageController`][90.10] for modifying existing permissions, and
+ allowed Gecko to handle persisting permissions.
+- ⚠️ Added a deprecation schedule to most existing content blocking exception functionality;
+ other than [`addException`][90.11], content blocking exceptions should be treated as content
+ permissions going forward.
+
+[90.1]: {{javadoc_uri}}/WebNotification.html#silent
+[90.2]: {{javadoc_uri}}/WebNotification.html#vibrate
+[90.3]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent
+[90.4]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate
+[90.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfileDir()
+[90.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setForceEnableAccessibility(boolean)
+[90.7]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission)
+[90.8]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[90.9]: {{javadoc_uri}}/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[90.10]: {{javadoc_uri}}/StorageController.html
+[90.11]: {{javadoc_uri}}/ContentBlockingController.html#addException(org.mozilla.geckoview.GeckoSession)
+
+## v89
+- Added [`ContentPermission`][89.1], which is used to report what permissions content
+ is loaded with in `onLocationChange`.
+- Added [`StorageController.getPermissions`][89.2] and [`StorageController.getAllPermissions`][89.3],
+ allowing inspection of what permissions have been set for a given URI and for all URIs.
+- ⚠️ Deprecated [`NavigationDelegate.onLocationChange`][89.4], to be removed in v92. The
+ new `onLocationChange` callback simply adds permissions information, migration of existing
+ functionality should only require updating the function signature.
+- Added [`GeckoRuntimeSettings.setEnterpriseRootsEnabled`][89.5] which allows
+ GeckoView to add third party certificate roots from the Android OS CA store.
+ ([bug 1678191]({{bugzilla}}1678191)).
+- ⚠️ [`GeckoSession.load`][89.6] now throws `IllegalArgumentException` if the
+ session has no [`GeckoSession.NavigationDelegate`][89.7] and the request's `data` URI is too long.
+ If a `GeckoSession` *does* have a `GeckoSession.NavigationDelegate` and `GeckoSession.load` is called
+ with a top-level `data` URI that is too long, [`NavigationDelgate.onLoadError`][89.8] will be called
+ with a [`WebRequestError`][89.9] containing error code [`WebRequestError.ERROR_DATA_URI_TOO_LONG`][89.10].
+ ([bug 1668952]({{bugzilla}}1668952))
+- Extended [`Autocomplete`][78.7] API to support credit cards.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- ⚠️ Deprecated [`Autocomplete.LoginStorageDelegate`][78.8] with the intention
+ of removing it in GeckoView v93. Please use
+ [`Autocomplete.StorageDelegate`][89.11] instead.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- Added [`ALLOWED_TRACKING_CONTENT`][89.12] to content blocking API to indicate
+ when unsafe content is allowed by a shim.
+ ([bug 1661330]({{bugzilla}}1661330))
+- ⚠️ Added [`setCookieBehaviorPrivateMode`][89.13] to control cookie behavior for private browsing
+ mode independently of normal browsing mode. To maintain current behavior, set this to the same
+ value as [`setCookieBehavior`][89.14] is set to.
+
+[89.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[89.2]: {{javadoc_uri}}/StorageController.html#getPermissions(java.lang.String)
+[89.3]: {{javadoc_uri}}/StorageController.html#getAllPermissions()
+[89.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[89.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setEnterpriseRootsEnabled(boolean)
+[89.6]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[89.7]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html
+[89.8]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadError(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.WebRequestError)
+[89.9]: {{javadoc_uri}}/WebRequestError.html
+[89.10]: {{javadoc_uri}}/WebRequestError.html#ERROR_DATA_URI_TOO_LONG
+[89.11]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html
+[89.12]: {{javadoc_uri}}/ContentBlockingController.Event.html#ALLOWED_TRACKING_CONTENT
+[89.13]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehaviorPrivateMode(int)
+[89.14]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehavior(int)
+
+## v88
+- Added [`WebExtension.Download#update`][88.1] that can be used to
+ implement the WebExtension `downloads` API. This method is used to communicate
+ updates in the download status to the Web Extension
+- Added [`PanZoomController.onTouchEventForDetailResult`][88.2] and
+ [`GeckoView.onTouchEventForDetailResult`][88.3] to tell information
+ that the website doesn't expect browser apps to react the event,
+ also and deprecated [`PanZoomController.onTouchEventForResult`][88.4]
+ and [`GeckoView.onTouchEventForResult`][88.5]. With these new methods
+ browser apps can differentiate cases where the browser can do something
+ the browser's specific behavior in response to the event (e.g.
+ pull-to-refresh) and cases where the browser should not react to the event
+ because the event was consumed in the web site (e.g. in canvas like
+ web apps).
+ ([bug 1678505]({{bugzilla}}1678505)).
+- ⚠️ Deprecate the [`MediaElement`][65.11] API to be removed in v91.
+ Please use [`MediaSession`][81.6] for media events and control.
+ ([bug 1693584]({{bugzilla}}1693584)).
+- ⚠️ Deprecate [`GeckoResult.ALLOW`][89.6] and [`GeckoResult.DENY`][89.7] in
+ favor of [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9].
+ ([bug 1697270]({{bugzilla}}1697270)).
+- ⚠️ Update [`SessionState`][88.10] to handle null states/strings more gracefully.
+ ([bug 1685486]({{bugzilla}}1685486)).
+
+[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update(org.mozilla.geckoview.WebExtension.Download.Info)
+[88.2]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForDetailResult
+[88.3]: {{javadoc_uri}}/GeckoView.html#onTouchEventForDetailResult
+[88.4]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForResult
+[88.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult
+[88.6]: {{javadoc_uri}}/GeckoResult.html#ALLOW
+[88.7]: {{javadoc_uri}}/GeckoResult.html#DENY
+[88.8]: {{javadoc_uri}}/GeckoResult.html#allow()
+[88.9]: {{javadoc_uri}}/GeckoResult.html#deny()
+[88.10]: {{javadoc_uri}}/GeckoSession.SessionState.html
+
+## v87
+- ⚠️ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to
+ implement the WebExtension `downloads` API. This class represents initial state of a download.
+- Added [`WebExtension.Download.Info`][87.2] interface that can be used to
+ implement the WebExtension `downloads` API. This interface allows communicating
+ download's state to Web Extension.
+- [`Image#getBitmap`][87.3] now throws [`ImageProcessingException`][87.4] if
+ the image cannot be processed.
+ ([bug 1689745]({{bugzilla}}1689745))
+- Added support for HTTPS-only mode to [`GeckoRuntimeSettings`][87.5] via
+ [`setAllowInsecureConnections`][87.6].
+- Removed `JSONException` throws from [`SessionState.fromString`][87.7], fixed annotations,
+ and clarified null-handling a bit.
+
+[87.1]: {{javadoc_uri}}/WebExtension.DownloadInitData.html
+[87.2]: {{javadoc_uri}}/WebExtension.Download.Info.html
+[87.3]: {{javadoc_uri}}/Image.html#getBitmap(int)
+[87.4]: {{javadoc_uri}}/Image.ImageProcessingException.html
+[87.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[87.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAllowInsecureConnections(int)
+[87.7]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+
+## v86
+- Removed deprecated `ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`.
+ Use [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] instead.
+ ([bug 1665157]({{bugzilla}}1665157))
+- Added [`WebExtension.DownloadDelegate`][86.1] and that can be used to
+ implement the WebExtension `downloads` API.
+ ([bug 1656336]({{bugzilla}}1656336))
+- Added [`WebRequest.Builder#body(@Nullable String)`][86.2] which converts a string to direct byte buffer.
+- Removed deprecated `REPLACED_UNSAFE_CONTENT`.
+ ([bug 1667471]({{bugzilla}}1667471))
+- Removed deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`GeckoResult#map`][86.3] to synchronously map a GeckoResult value.
+- Added [`PanZoomController#INPUT_RESULT_IGNORED`][86.4].
+ ([bug 1687430]({{bugzilla}}1687430))
+
+[86.1]: {{javadoc_uri}}/WebExtension.DownloadDelegate.html
+[86.2]: {{javadoc_uri}}/WebRequest.Builder#body(java.lang.String)
+[86.3]: {{javadoc_uri}}/GeckoResult.html#map(org.mozilla.geckoview.GeckoResult.OnValueMapper)
+[86.4]: {{javadoc_uri}}/PanZoomController.html#INPUT_RESULT_IGNORED
+
+## v85
+- Added [`WebExtension.BrowsingDataDelegate`][85.1] that can be used to
+ implement the WebExtension `browsingData` API.
+
+[85.1]: {{javadoc_uri}}/WebExtension.BrowsingDataDelegate.html
+
+## v84
+- ⚠️ Removed deprecated `GeckoRuntimeSettings.Builder.useMultiprocess` and
+ [`GeckoRuntimeSettings.getUseMultiprocess`]. Single-process GeckoView is no
+ longer supported. ([bug 1650118]({{bugzilla}}1650118))
+- Deprecated members now have an additional [`@DeprecationSchedule`][84.1] annotation which
+ includes the `version` that we expect to remove the member and an `id` that
+ can be used to group annotation notices in tooling.
+ ([bug 1671460]({{bugzilla}}1671460))
+- ⚠️ Removed deprecated `ContentBlockingController.ExceptionList` and
+ `ContentBlockingController.restoreExceptionList`. ([bug 1674500]({{bugzilla}}1674500))
+
+[84.1]: {{javadoc_uri}}/DeprecationSchedule.html
+
+## v83
+- Added [`WebExtension.MetaData.temporary`][83.1] which exposes whether an extension
+ has been installed temporarily, e.g. when using web-ext.
+ ([bug 1624410]({{bugzilla}}1624410))
+- ⚠️ Removing unsupported `MediaSession.Delegate.onPictureInPicture` for now.
+ Also, [`MediaSession.Delegate.onMetadata`][83.2] is no longer dispatched for
+ plain media elements.
+ ([bug 1658937]({{bugzilla}}1658937))
+- Replaced android.util.ArrayMap with java.util.TreeMap in [`WebMessage`][65.13] to enable case-insensitive handling of the HTTP headers.
+ ([bug 1666013]({{bugzilla}}1666013))
+- Added [`ContentBlocking.SafeBrowsingProvider`][83.3] to configure Safe
+ Browsing providers.
+ ([bug 1660241]({{bugzilla}}1660241))
+- Added [`GeckoRuntime.ActivityDelegate`][83.4] which allows applications to handle
+ starting external Activities on behalf of GeckoView. Currently this is used to integrate
+ FIDO support for WebAuthn.
+- Added [`GeckoWebExecutor#FETCH_FLAG_PRIVATE`][83.5]. This new flag allows for private browsing downloads using WebExecutor.
+ ([bug 1665426]({{bugzilla}}1665426))
+- ⚠️ Deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`Loader#headerFilter`][83.9] to override the default header filtering
+ behavior.
+ ([bug 1667471]({{bugzilla}}1667471))
+
+[83.1]: {{javadoc_uri}}/WebExtension.MetaData.html#temporary
+[83.2]: {{javadoc_uri}}/MediaSession.Delegate.html#onMetadata(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.MediaSession,org.mozilla.geckoview.MediaSession.Metadata)
+[83.3]: {{javadoc_uri}}/ContentBlocking.SafeBrowsingProvider.html
+[83.4]: {{javadoc_uri}}/GeckoRuntime.ActivityDelegate.html
+[83.5]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAG_PRIVATE
+[83.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int,java.util.Map)
+[83.7]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[83.8]: {{javadoc_uri}}/GeckoSession.Loader.html
+[83.9]: {{javadoc_uri}}/GeckoSession.Loader.html#headerFilter(int)
+
+## v82
+- ⚠️ [`WebNotification.source`][79.2] is now `@Nullable` to account for
+ WebExtension notifications which don't have a `source` field.
+- ⚠️ Deprecated [`ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`][82.1] with the intention of removing
+ them in GeckoView v85.
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] to eliminate the need
+ to make a second request for downloads and ensure more efficient and reliable downloads in a single request. The second
+ parameter is now a [`WebResponse`][65.15]
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`Image`][82.3] support for size-dependent bitmap retrieval from image resources.
+ ([bug 1658456]({{bugzilla}}1658456))
+- ⚠️ Use [`Image`][82.3] for [`MediaSession`][81.6] artwork and [`WebExtension`][69.5] icon support.
+ ([bug 1662508]({{bugzilla}}1662508))
+- Added [`RepostConfirmPrompt`][82.4] to prompt the user for cofirmation before
+ resending POST requests.
+ ([bug 1659073]({{bugzilla}}1659073))
+- Removed `Parcelable` support in `GeckoSession`. Use [`ProgressDelegate#onSessionStateChange`][68.29] and [`ProgressDelegate#restoreState`][82.5] instead.
+ ([bug 1650108]({{bugzilla}}1650108))
+- ⚠️ Use AndroidX instead of the Android support library. For the public API this only changes
+ the thread and nullable annotation types.
+- Added [`REPLACED_TRACKING_CONTENT`][82.6] to content blocking API to indicate when unsafe content is shimmed.
+ ([bug 1663756]({{bugzilla}}1663756))
+
+[82.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.WebResponseInfo)
+[82.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoResult)
+[82.3]: {{javadoc_uri}}/Image.html
+[82.4]: {{javadoc_uri}}/GeckoSession.PromptDelegate.RepostConfirmPrompt.html
+[82.5]: {{javadoc_uri}}/GeckoSession.html#restoreState(org.mozilla.geckoview.GeckoSession.SessionState)
+[82.6]: {{javadoc_uri}}/ContentBlockingController.Event.html#REPLACED_TRACKING_CONTENT
+
+## v81
+- Added `cookiePurging` to [`ContentBlocking.Settings.Builder`][81.1] and `getCookiePurging` and `setCookiePurging`
+ to [`ContentBlocking.Settings`][81.2].
+- Added [`GeckoSession.ContentDelegate.onPaintStatusReset()`][81.3] callback which notifies when valid content is no longer being rendered.
+- Made [`GeckoSession.ContentDelegate.onFirstContentfulPaint()`][81.4] additionally be called for the first contentful paint following a `onPaintStatusReset()` event, rather than just the first contentful paint of the session.
+- Removed deprecated `GeckoRuntime.registerWebExtension`. Use [`WebExtensionController.install`][73.1] instead.
+⚠️ - Changed [`GeckoView.onTouchEventForResult`][81.5] to return a `GeckoResult`, as it now
+makes a round-trip to Gecko. The result will be more accurate now, since how content treats
+the event is now considered.
+- Added [`MediaSession`][81.6] API for session-based media events and control.
+
+[81.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[81.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+[81.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPaintStatusReset(org.mozilla.geckoview.GeckoSession)
+[81.4]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[81.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[81.6]: {{javadoc_uri}}/MediaSession.html
+
+## v80
+- Removed `GeckoSession.hashCode` and `GeckoSession.equals` overrides in favor
+ of the default implementations. ([bug 1647883]({{bugzilla}}1647883))
+- Added `strictSocialTrackingProtection` to [`ContentBlocking.Settings.Builder`][80.1] and `getStrictSocialTrackingProtection`
+ to [`ContentBlocking.Settings`][80.2].
+
+[80.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[80.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+
+## v79
+- Added `runtime.openOptionsPage` support. For `options_ui.open_in_new_tab ==
+ false`, [`TabDelegate.onOpenOptionsPage`][79.1] is called.
+ ([bug 1618058]({{bugzilla}}1619766))
+- Added [`WebNotification.source`][79.2], which is the URL of the page
+ or Service Worker that created the notification.
+- Removed deprecated `WebExtensionController.setTabDelegate` and `WebExtensionController.getTabDelegate`
+ APIs ([bug 1618987]({{bugzilla}}1618987)).
+- ⚠️ [`RuntimeTelemetry#getSnapshots`][68.10] is removed after deprecation.
+ Use Glean to handle Gecko telemetry.
+ ([bug 1644447]({{bugzilla}}1644447))
+- Added [`ensureBuiltIn`][79.3] that ensures that a built-in extension is
+ installed without re-installing.
+ ([bug 1635564]({{bugzilla}}1635564))
+- Added [`ProfilerController`][79.4], accessible via [`GeckoRuntime.getProfilerController`][79.5]
+to allow adding gecko profiler markers.
+([bug 1624993]({{bugzilla}}1624993))
+- ⚠️ Deprecated `Parcelable` support in `GeckoSession` with the intention of removing
+ in GeckoView v82. ([bug 1649529]({{bugzilla}}1649529))
+- ⚠️ Deprecated [`GeckoRuntimeSettings.Builder.useMultiprocess`][79.6] and
+ [`GeckoRuntimeSettings.getUseMultiprocess`][79.7] with the intention of removing
+ them in GeckoView v82. ([bug 1649530]({{bugzilla}}1649530))
+
+[79.1]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onOpenOptionsPage(org.mozilla.geckoview.WebExtension)
+[79.2]: {{javadoc_uri}}/WebNotification.html#source
+[79.3]: {{javadoc_uri}}/WebExtensionController.html#ensureBuiltIn(java.lang.String,java.lang.String)
+[79.4]: {{javadoc_uri}}/ProfilerController.html
+[79.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfilerController()
+[79.6]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[79.7]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getUseMultiprocess()
+
+## v78
+- Added [`WebExtensionController.installBuiltIn`][78.1] that allows installing an
+ extension that is bundled with the APK. This method is meant as a replacement
+ for [`GeckoRuntime.registerWebExtension`][67.15], ⚠️ which is now deprecated
+ and will be removed in GeckoView 81.
+- Added [`CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS`][78.2] to allow
+ enabling dynamic first party isolation; this will block tracking cookies and
+ isolate all other third party cookies by keying them based on the first party
+ from which they are accessed.
+- Added `cookieStoreId` field to [`WebExtension.CreateTabDetails`][78.3]. This adds the optional
+ ability to create a tab with a given cookie store ID for its [`contextual identity`][78.4].
+ ([bug 1622500]({{bugzilla}}1622500))
+- Added [`NavigationDelegate.onSubframeLoadRequest`][78.5] to allow intercepting
+ non-top-level navigations.
+- Added [`BeforeUnloadPrompt`][78.6] to respond to prompts from onbeforeunload.
+- ⚠️ Refactored `LoginStorage` to the [`Autocomplete`][78.7] API to support
+ login form autocomplete delegation.
+ Refactored `LoginStorage.Delegate` to [`Autocomplete.LoginStorageDelegate`][78.8].
+ Refactored `GeckoSession.PromptDelegate.onLoginStoragePrompt` to
+ [`GeckoSession.PromptDelegate.onLoginSave`][78.9].
+ Added [`GeckoSession.PromptDelegate.onLoginSelect`][78.10].
+ ([bug 1618058]({{bugzilla}}1618058))
+- Added [`GeckoRuntimeSettings#setLoginAutofillEnabled`][78.11] to control
+ whether login forms should be automatically filled in suitable situations.
+
+[78.1]: {{javadoc_uri}}/WebExtensionController.html#installBuiltIn(java.lang.String)
+[78.2]: {{javadoc_uri}}/ContentBlocking.CookieBehavior.html#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS
+[78.3]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[78.4]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities
+[78.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onSubframeLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+[78.6]: {{javadoc_uri}}/GeckoSession.PromptDelegate.BeforeUnloadPrompt.html
+[78.7]: {{javadoc_uri}}/Autocomplete.html
+[78.8]: {{javadoc_uri}}/Autocomplete.LoginStorageDelegate.html
+[78.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSave(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.10]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSelect(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.11]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLoginAutofillEnabled(boolean)
+
+## v77
+- Added [`GeckoRuntime.appendAppNotesToCrashReport`][77.1] For adding app notes to the crash report.
+ ([bug 1626979]({{bugzilla}}1626979))
+- ⚠️ Remove the `DynamicToolbarAnimator` API along with accesors on `GeckoView` and `GeckoSession`.
+ ([bug 1627716]({{bugzilla}}1627716))
+
+[77.1]: {{javadoc_uri}}/GeckoRuntime.html#appendAppNotesToCrashReport(java.lang.String)
+
+## v76
+- Added [`GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS`][76.1] to control EME media key access.
+- [`RuntimeTelemetry#getSnapshots`][68.10] is deprecated and will be removed
+ in 79. Use Glean to handle Gecko telemetry.
+ ([bug 1620395]({{bugzilla}}1620395))
+- Added `LoadRequest.isDirectNavigation` to know when calls to
+ [`onLoadRequest`][76.3] originate from a direct navigation made by the app
+ itself.
+ ([bug 1624675]({{bugzilla}}1624675))
+
+[76.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS
+[76.2]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isDirectNavigation
+[76.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+
+## v75
+- ⚠️ Remove `GeckoRuntimeSettings.Builder#useContentProcessHint`. The content
+ process is now preloaded by default if
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1] is enabled.
+- ⚠️ Move `GeckoSessionSettings.Builder#useMultiprocess` to
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1]. Multiprocess state is
+ no longer determined per session.
+- Added [`DebuggerDelegate#onExtensionListUpdated`][75.2] to notify that a temporary
+ extension has been installed by the debugger.
+ ([bug 1614295]({{bugzilla}}1614295))
+- ⚠️ Removed [`GeckoRuntimeSettings.setAutoplayDefault`][75.3], use
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13] to
+ control autoplay.
+ ([bug 1614894]({{bugzilla}}1614894))
+- Added [`GeckoSession.reload(int flags)`][75.4] That takes a [load flag][75.5] parameter.
+- ⚠️ Moved [`ActionDelegate`][75.6] and [`MessageDelegate`][75.7] to
+ [`SessionController`][75.8].
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate`][75.9] to [`SessionController`][75.8] and
+ [`TabDelegate`][75.10] to [`WebExtension`][69.5] which receive respectively
+ calls for the session and the runtime. `TabDelegate` is also now
+ per-`WebExtension` object instead of being global. The existing global
+ [`TabDelegate`][75.11] is now deprecated and will be removed in GeckoView 77.
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate#onUpdateTab`][75.12] which is called whenever an
+ extension calls `tabs.update` on the corresponding `GeckoSession`.
+ [`TabDelegate#onCreateTab`][75.13] now takes a [`CreateTabDetails`][75.14]
+ object which contains additional information about the newly created tab
+ (including the `url` which used to be passed in directly).
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`GeckoRuntimeSettings.setWebManifestEnabled`][75.15],
+ [`GeckoRuntimeSettings.webManifest`][75.16], and
+ [`GeckoRuntimeSettings.getWebManifestEnabled`][75.17]
+ ([bug 1614894]({{bugzilla}}1603673)), to enable or check Web Manifest support.
+- Added [`GeckoDisplay.safeAreaInsetsChanged`][75.18] to notify the content of [safe area insets][75.19].
+ ([bug 1503656]({{bugzilla}}1503656))
+- Added [`GeckoResult#cancel()`][75.22], [`GeckoResult#setCancellationDelegate()`][75.22],
+ and [`GeckoResult.CancellationDelegate`][75.23]. This adds the optional ability to cancel
+ an operation behind a pending `GeckoResult`.
+- Added [`baseUrl`][75.24] to [`WebExtension.MetaData`][75.25] to expose the
+ base URL for all WebExtension pages for a given extension.
+ ([bug 1560048]({{bugzilla}}1560048))
+- Added [`allowedInPrivateBrowsing`][75.26] and
+ [`setAllowedInPrivateBrowsing`][75.27] to control whether an extension can
+ run in private browsing or not. Extensions installed with
+ [`registerWebExtension`][67.15] will always be allowed to run in private
+ browsing.
+ ([bug 1599139]({{bugzilla}}1599139))
+
+[75.1]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[75.2]: {{javadoc_uri}}/WebExtensionController.DebuggerDelegate.html#onExtensionListUpdated()
+[75.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#autoplayDefault(boolean)
+[75.4]: {{javadoc_uri}}/GeckoSession.html#reload(int)
+[75.5]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_NONE
+[75.6]: {{javadoc_uri}}/WebExtension.ActionDelegate.html
+[75.7]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[75.8]: {{javadoc_uri}}/WebExtension.SessionController.html
+[75.9]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html
+[75.10]: {{javadoc_uri}}/WebExtension.TabDelegate.html
+[75.11]: {{javadoc_uri}}/WebExtensionRuntime.TabDelegate.html
+[75.12]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html#onUpdateTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebExtension.UpdateTabDetails)
+[75.13]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onNewTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.CreateTabDetails)
+[75.14]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[75.15]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#setWebManifestEnabled(boolean)
+[75.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#webManifest(boolean)
+[75.17]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#getWebManifestEnabled()
+[75.18]: {{javadoc_uri}}/GeckoDisplay.html#safeAreaInsetsChanged(int,int,int,int)
+[75.19]: https://developer.mozilla.org/en-US/docs/Web/CSS/env
+[75.20]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_POSTPONED
+[75.21]: {{javadoc_uri}}/GeckoResult.html#cancel()
+[75.22]: {{javadoc_uri}}/GeckoResult.html#setCancellationDelegate(CancellationDelegate)
+[75.23]: {{javadoc_uri}}/GeckoResult.CancellationDelegate.html
+[75.24]: {{javadoc_uri}}/WebExtension.MetaData.html#baseUrl
+[75.25]: {{javadoc_uri}}/WebExtension.MetaData.html
+[75.26]: {{javadoc_uri}}/WebExtension.MetaData.html#allowedInPrivateBrowsing
+[75.27]: {{javadoc_uri}}/WebExtensionController.html#setAllowedInPrivateBrowsing(org.mozilla.geckoview.WebExtension,boolean)
+
+## v74
+- Added [`WebExtensionController.enable`][74.1] and [`disable`][74.2] to
+ enable and disable extensions.
+ ([bug 1599585]({{bugzilla}}1599585))
+- ⚠️ Added [`GeckoSession.ProgressDelegate.SecurityInformation#certificate`][74.3], which is the
+ full server certificate in use, if any. The other certificate-related fields were removed.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#isSecure`][74.4], which indicates whether or not the response was
+ delivered over a secure connection.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#certificate`][74.5], which is the server certificate used for the
+ response, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebRequestError#certificate`][74.6], which is the server certificate used in the
+ failed request, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- ⚠️ Updated [`ContentBlockingController`][74.7] to use new representation for content blocking
+ exceptions and to add better support for removing exceptions. This deprecates [`ExceptionList`][74.8]
+ and [`restoreExceptionList`][74.9] with the intent to remove them in 76.
+ ([bug 1587552]({{bugzilla}}1587552))
+- Added [`GeckoSession.ContentDelegate.onMetaViewportFitChange`][74.10]. This exposes `viewport-fit` value that is CSS Round Display Level 1. ([bug 1574307]({{bugzilla}}1574307))
+- Extended [`LoginStorage.Delegate`][74.11] with [`onLoginUsed`][74.12] to
+ report when existing login entries are used for autofill.
+ ([bug 1610353]({{bugzilla}}1610353))
+- Added [`WebExtensionController#setTabActive`][74.13], which is used to notify extensions about
+ tab changes
+ ([bug 1597793]({{bugzilla}}1597793))
+- Added [`WebExtension.metaData.optionsUrl`][74.14] and [`WebExtension.metaData.openOptionsPageInTab`][74.15],
+ which is the addon metadata necessary to show their option pages.
+ ([bug 1598792]({{bugzilla}}1598792))
+- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581))
+- ⚠️ Replaced `subscription` argument in [`WebPushDelegate.onSubscriptionChanged`][74.17] from a [`WebPushSubscription`][74.18] to the [`String`][74.19] `scope`.
+
+[74.1]: {{javadoc_uri}}/WebExtensionController.html#enable(org.mozilla.geckoview.WebExtension,int)
+[74.2]: {{javadoc_uri}}/WebExtensionController.html#disable(org.mozilla.geckoview.WebExtension,int)
+[74.3]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html#certificate
+[74.4]: {{javadoc_uri}}/WebResponse.html#isSecure
+[74.5]: {{javadoc_uri}}/WebResponse.html#certificate
+[74.6]: {{javadoc_uri}}/WebRequestError.html#certificate
+[74.7]: {{javadoc_uri}}/ContentBlockingController.html
+[74.8]: {{javadoc_uri}}/ContentBlockingController.ExceptionList.html
+[74.9]: {{javadoc_uri}}/ContentBlockingController.html#restoreExceptionList(org.mozilla.geckoview.ContentBlockingController.ExceptionList)
+[74.10]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onMetaViewportFitChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[74.11]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[74.12]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginUsed(org.mozilla.geckoview.LoginStorage.LoginEntry,int)
+[74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive
+[74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl
+[74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab
+[74.16]: {{javadoc_uri}}/WebExtensionController.html#update(org.mozilla.geckoview.WebExtension,int)
+[74.17]: {{javadoc_uri}}/WebPushController.html#onSubscriptionChange(org.mozilla.geckoview.WebPushSubscription,byte[])
+[74.18]: {{javadoc_uri}}/WebPushSubscription.html
+[74.19]: https://developer.android.com/reference/java/lang/String
+
+## v73
+- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to
+ manage installed extensions
+- ⚠️ Renamed `ScreenLength.VIEWPORT_WIDTH`, `ScreenLength.VIEWPORT_HEIGHT`,
+ `ScreenLength.fromViewportWidth` and `ScreenLength.fromViewportHeight` to
+ [`ScreenLength.VISUAL_VIEWPORT_WIDTH`][73.3],
+ [`ScreenLength.VISUAL_VIEWPORT_HEIGHT`][73.4],
+ [`ScreenLength.fromVisualViewportWidth`][73.5] and
+ [`ScreenLength.fromVisualViewportHeight`][73.6] respectively.
+- Added the [`LoginStorage`][73.7] API. Apps may handle login fetch requests now by
+ attaching a [`LoginStorage.Delegate`][73.8] via
+ [`GeckoRuntime#setLoginStorageDelegate`][73.9]
+ ([bug 1602881]({{bugzilla}}1602881))
+- ⚠️ [`WebExtension`][69.5]'s constructor now requires a `WebExtensionController`
+ instance.
+- Added [`GeckoResult.allOf`][73.10] for consuming a list of results.
+- Added [`WebExtensionController.list`][73.11] to list all installed extensions.
+- Added [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13]. These control
+ autoplay permissions for audible and inaudible videos.
+ ([bug 1577596]({{bugzilla}}1577596))
+- Added [`LoginStorage.Delegate.onLoginSave`][73.14] for login storage save
+ requests and [`GeckoSession.PromptDelegate.onLoginStoragePrompt`][73.15] for
+ login storage prompts.
+ ([bug 1599873]({{bugzilla}}1599873))
+
+[73.1]: {{javadoc_uri}}/WebExtensionController.html#install(java.lang.String)
+[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall(org.mozilla.geckoview.WebExtension)
+[73.3]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_WIDTH
+[73.4]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_HEIGHT
+[73.5]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportWidth(double)
+[73.6]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportHeight(double)
+[73.7]: {{javadoc_uri}}/LoginStorage.html
+[73.8]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[73.9]: {{javadoc_uri}}/GeckoRuntime.html#setLoginStorageDelegate(org.mozilla.geckoview.LoginStorage.Delegate)
+[73.10]: {{javadoc_uri}}/GeckoResult.html#allOf(java.util.List)
+[73.11]: {{javadoc_uri}}/WebExtensionController.html#list()
+[73.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE
+[73.13]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE
+[73.14]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginSave(org.mozilla.geckoview.LoginStorage.LoginEntry)
+[73.15]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginStoragePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.LoginStoragePrompt)
+
+## v72
+- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates
+ if a load was requested while a user gesture was active (e.g., a tap).
+ ([bug 1555337]({{bugzilla}}1555337))
+- ⚠️ Refactored `AutofillElement` and `AutofillSupport` into the
+ [`Autofill`][72.2] API.
+ ([bug 1591462]({{bugzilla}}1591462))
+- Make `read()` in the `InputStream` returned from [`WebResponse#body`][72.3] timeout according
+ to [`WebResponse#setReadTimeoutMillis()`][72.4]. The default timeout value is reflected in
+ [`WebResponse#DEFAULT_READ_TIMEOUT_MS`][72.5], currently 30s.
+ ([bug 1595145]({{bugzilla}}1595145))
+- ⚠️ Removed `GeckoResponse`
+ ([bug 1581161]({{bugzilla}}1581161))
+- ⚠️ Removed `actions` and `response` arguments from [`SelectionActionDelegate.onShowActionRequest`][72.6]
+ and [`BasicSelectionActionDelegate.onShowActionRequest`][72.7]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added text selection action methods to [`SelectionActionDelegate.Selection`][72.8]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added [`BasicSelectionActionDelegate.getSelection`][72.9]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public.
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added `Autofill` commit support.
+ ([bug 1577005]({{bugzilla}}1577005))
+- Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be
+ backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13].
+ ([bug 1530402]({{bugzilla}}1530402))
+- Added support for Browser and Page Action from the WebExtension API.
+ See [`WebExtension.Action`][72.14].
+ ([bug 1530402]({{bugzilla}}1530402))
+- ⚠️ Split [`ContentBlockingController.Event.LOADED_TRACKING_CONTENT`][72.15] into
+ [`ContentBlockingController.Event.LOADED_LEVEL_1_TRACKING_CONTENT`][72.16] and
+ [`ContentBlockingController.Event.LOADED_LEVEL_2_TRACKING_CONTENT`][72.17].
+- Replaced `subscription` argument in [`WebPushDelegate.onPushEvent`][72.18] from a [`WebPushSubscription`][72.19] to the [`String`][72.20] `scope`.
+- ⚠️ Renamed `WebExtension.ActionIcon` to [`Icon`][72.21].
+- Added [`GeckoWebExecutor#FETCH_FLAGS_STREAM_FAILURE_TEST`][72.22], which is a new
+ flag used to immediately fail when reading a `WebResponse` body.
+ ([bug 1594905]({{bugzilla}}1594905))
+- Changed [`CrashReporter#sendCrashReport(Context, File, JSONObject)`][72.23] to
+ accept a JSON object instead of a Map. Said object also includes the
+ application name that was previously passed as the fourth argument to the
+ method, which was thus removed.
+- Added WebXR device access permission support, [`PERMISSION_PERSISTENT_XR`][72.24].
+ ([bug 1599927]({{bugzilla}}1599927))
+
+[72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture
+[72.2]: {{javadoc_uri}}/Autofill.html
+[72.3]: {{javadoc_uri}}/WebResponse.html#body
+[72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis(long)
+[72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS
+[72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html
+[72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection
+[72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection
+[72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend(int)
+[72.12]: https://developer.android.com/reference/android/view/TextureView
+[72.13]: https://developer.android.com/reference/android/view/SurfaceView
+[72.14]: {{javadoc_uri}}/WebExtension.Action.html
+[72.15]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_TRACKING_CONTENT
+[72.16]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_1_TRACKING_CONTENT
+[72.17]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_2_TRACKING_CONTENT
+[72.18]: {{javadoc_uri}}/WebPushController.html#onPushEvent(org.mozilla.geckoview.WebPushSubscription,byte[])
+[72.19]: {{javadoc_uri}}/WebPushSubscription.html
+[72.20]: https://developer.android.com/reference/java/lang/String
+[72.21]: {{javadoc_uri}}/WebExtension.Icon.html
+[72.22]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_STREAM_FAILURE_TEST
+[72.23]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,org.json.JSONObject)
+[72.24]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_XR
+
+## v71
+- Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17].
+ ([bug 1584479]({{bugzilla}}1584479))
+- Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2],
+ [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support
+ scalars in streaming telemetry. ⚠️ As part of this change,
+ `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and
+ [`Metric`][71.5] now takes a type parameter.
+ ([bug 1576730]({{bugzilla}}1576730))
+- Added overloads of [`GeckoSession.loadUri`][71.6] that accept a map of
+ additional HTTP request headers.
+ ([bug 1567549]({{bugzilla}}1567549))
+- Added support for exposing the content blocking log in [`ContentBlockingController`][71.7].
+ ([bug 1580201]({{bugzilla}}1580201))
+- ⚠️ Added `nativeApp` to [`WebExtension.MessageDelegate.onMessage`][71.8] which
+ exposes the native application identifier that was used to send the message.
+ ([bug 1546445]({{bugzilla}}1546445))
+- Added [`GeckoRuntime.ServiceWorkerDelegate`][71.9] set via
+ [`setServiceWorkerDelegate`][71.10] to support [`ServiceWorkerClients.openWindow`][71.11]
+ ([bug 1511033]({{bugzilla}}1511033))
+- Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or
+ not `about:config` should be available.
+ ([bug 1540065]({{bugzilla}}1540065))
+- Added [`GeckoSession.ContentDelegate.onFirstContentfulPaint`][71.13]
+ ([bug 1578947]({{bugzilla}}1578947))
+- Added `setEnhancedTrackingProtectionLevel` to [`ContentBlocking.Settings`][71.14].
+ ([bug 1580854]({{bugzilla}}1580854))
+- ⚠️ Added [`GeckoView.onTouchEventForResult`][71.15] and modified
+ [`PanZoomController.onTouchEvent`][71.16] to return how the touch event was handled. This
+ allows apps to know if an event is handled by touch event listeners in web content. The methods in `PanZoomController` now return `int` instead of `boolean`.
+- Added [`GeckoSession.purgeHistory`][71.17] allowing apps to clear a session's history.
+ ([bug 1583265]({{bugzilla}}1583265))
+- Added [`GeckoRuntimeSettings.Builder#forceUserScalableEnabled`][71.18] to control whether or
+ not to force user scalable zooming.
+ ([bug 1540615]({{bugzilla}}1540615))
+- ⚠️ Moved Autofill related methods from `SessionTextInput` and `GeckoSession.TextInputDelegate`
+ into `GeckoSession` and `AutofillDelegate`.
+- Added [`GeckoSession.getAutofillElements()`][71.19], which is a new method for getting
+ an autofill virtual structure without using `ViewStructure`. It relies on a new class,
+ [`AutofillElement`][71.20], for representing the virtual tree.
+- Added [`GeckoView.setAutofillEnabled`][71.21] for controlling whether or not the `GeckoView`
+ instance participates in Android autofill. When enabled, this connects an `AutofillDelegate`
+ to the session it holds.
+- Changed [`AutofillElement.children`][71.20] interface to `Collection` to provide
+ an efficient way to pre-allocate memory when filling `ViewStructure`.
+- Added [`GeckoSession.PromptDelegate.onSharePrompt`][71.22] to support the WebShare API.
+ ([bug 1402369]({{bugzilla}}1402369))
+- Added [`GeckoDisplay.screenshot`][71.23] allowing apps finer grain control over screenshots.
+ ([bug 1577192]({{bugzilla}}1577192))
+- Added `GeckoView.setDynamicToolbarMaxHeight` to make ICB size static, ICB doesn't include the dynamic toolbar region.
+ ([bug 1586144]({{bugzilla}}1586144))
+
+[71.1]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onBooleanScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.2]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onLongScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.3]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onStringScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.4]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onHistogram(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.5]: {{javadoc_uri}}/RuntimeTelemetry.Metric.html
+[71.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,java.io.File,java.util.Map)
+[71.7]: {{javadoc_uri}}/ContentBlockingController.html
+[71.8]: {{javadoc_uri}}/WebExtension.MessageDelegate.html#onMessage(java.lang.String,java.lang.Object,org.mozilla.geckoview.WebExtension.MessageSender)
+[71.9]: {{javadoc_uri}}/GeckoRuntime.ServiceWorkerDelegate.html
+[71.10]: {{javadoc_uri}}/GeckoRuntime#setServiceWorkerDelegate(org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate)
+[71.11]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow
+[71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean)
+[71.13]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[71.15]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[71.16]: {{javadoc_uri}}/PanZoomController.html#onTouchEvent(android.view.MotionEvent)
+[71.17]: {{javadoc_uri}}/GeckoSession.html#purgeHistory()
+[71.18]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#forceUserScalableEnabled(boolean)
+[71.19]: {{javadoc_uri}}/GeckoSession.html#getAutofillElements()
+[71.20]: {{javadoc_uri}}/AutofillElement.html
+[71.21]: {{javadoc_uri}}/GeckoView.html#setAutofillEnabled(boolean)
+[71.22]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSharePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt)
+[71.23]: {{javadoc_uri}}/GeckoDisplay.html#screenshot()
+
+## v70
+- Added API for session context assignment
+ [`GeckoSessionSettings.Builder.contextId`][70.1] and deletion of data related
+ to a session context [`StorageController.clearDataForSessionContext`][70.2].
+ ([bug 1501108]({{bugzilla}}1501108))
+- Removed `setSession(session, runtime)` from [`GeckoView`][70.5]. With this
+ change, `GeckoView` will no longer manage opening/closing of the
+ [`GeckoSession`][70.6] and instead leave that up to the app. It's also now
+ allowed to call [`setSession`][70.10] with a closed `GeckoSession`.
+ ([bug 1510314]({{bugzilla}}1510314))
+- Added an overload of [`GeckoSession.loadUri()`][70.8] that accepts a
+ referring [`GeckoSession`][70.6]. This should be used when the URI we're
+ loading originates from another page. A common example of this would be long
+ pressing a link and then opening that in a new `GeckoSession`.
+ ([bug 1561079]({{bugzilla}}1561079))
+- Added capture parameter to [`onFilePrompt`][70.9] and corresponding
+ [`CAPTURE_TYPE_*`][70.7] constants.
+ ([bug 1553603]({{bugzilla}}1553603))
+- Removed the obsolete `success` parameter from
+ [`CrashReporter#sendCrashReport(Context, File, File, String)`][70.3] and
+ [`CrashReporter#sendCrashReport(Context, File, Map, String)`][70.4].
+ ([bug 1570789]({{bugzilla}}1570789))
+- Add `GeckoSession.LOAD_FLAGS_REPLACE_HISTORY`.
+ ([bug 1571088]({{bugzilla}}1571088))
+- Complete rewrite of [`PromptDelegate`][70.11].
+ ([bug 1499394]({{bugzilla}}1499394))
+- Added [`RuntimeTelemetry.Delegate`][70.12] that receives streaming telemetry
+ data from GeckoView.
+ ([bug 1566367]({{bugzilla}}1566367))
+- Updated [`ContentBlocking`][70.13] to better report blocked and allowed ETP events.
+ ([bug 1567268]({{bugzilla}}1567268))
+- Added API for controlling Gecko logging [`GeckoRuntimeSettings.debugLogging`][70.14]
+ ([bug 1573304]({{bugzilla}}1573304))
+- Added [`WebNotification`][70.15] and [`WebNotificationDelegate`][70.16] for handling Web Notifications.
+ ([bug 1533057]({{bugzilla}}1533057))
+- Added Social Tracking Protection support to [`ContentBlocking`][70.17].
+ ([bug 1568295]({{bugzilla}}1568295))
+- Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.create`][70.20] calls by WebExtensions.
+ ([bug 1539144]({{bugzilla}}1539144))
+- Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.remove`][70.22] calls by WebExtensions.
+ ([bug 1565782]({{bugzilla}}1565782))
+- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts.
+ ([bug 1621094]({{bugzilla}}1621094))
+- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and
+ [`WebPushSubscription`][70.26].
+- Added [`ContentBlockingController`][70.27], accessible via [`GeckoRuntime.getContentBlockingController`][70.28]
+ to allow modification and inspection of a content blocking exception list.
+
+[70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId(java.lang.String)
+[70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext(java.lang.String)
+[70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.io.File,java.lang.String)
+[70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.util.Map,java.lang.String)
+[70.5]: {{javadoc_uri}}/GeckoView.html
+[70.6]: {{javadoc_uri}}/GeckoSession.html
+[70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE
+[70.8]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int)
+[70.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onFilePrompt(org.mozilla.geckoview.GeckoSession,java.lang.String,int,java.lang.String[],int,org.mozilla.geckoview.GeckoSession.PromptDelegate.FileCallback)
+[70.10]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+[70.11]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html
+[70.12]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html
+[70.13]: {{javadoc_uri}}/ContentBlocking.html
+[70.14]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#debugLogging(boolean)
+[70.15]: {{javadoc_uri}}/WebNotification.html
+[70.16]: {{javadoc_uri}}/WebNotificationDelegate.html
+[70.17]: {{javadoc_uri}}/ContentBlocking.html
+[70.18]: {{javadoc_uri}}/WebExtensionController.html
+[70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html
+[70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create
+[70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession)
+[70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove
+[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[70.24]: {{javadoc_uri}}/WebPushController.html
+[70.25]: {{javadoc_uri}}/WebPushDelegate.html
+[70.26]: {{javadoc_uri}}/WebPushSubscription.html
+[70.27]: {{javadoc_uri}}/ContentBlockingController.html
+[70.28]: {{javadoc_uri}}/GeckoRuntime.html#getContentBlockingController()
+
+## v69
+- Modified behavior of [`setAutomaticFontSizeAdjustment`][69.1] so that it no
+ longer has any effect on [`setFontInflationEnabled`][69.2]
+- Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14]
+- Added [`GeckoResult.accept`][69.3] for consuming a result without
+ transforming it.
+- [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the
+ [`WebExtension`][69.5] that the [`MessageDelegate`][69.4] will receive
+ messages from.
+- Created [`onKill`][69.7] to [`ContentDelegate`][69.11] to differentiate from crashes.
+
+[69.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[69.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontInflationEnabled(boolean)
+[69.3]: {{javadoc_uri}}/GeckoResult.html#accept(org.mozilla.geckoview.GeckoResult.Consumer)
+[69.4]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[69.5]: {{javadoc_uri}}/WebExtension.html
+[69.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onKill(org.mozilla.geckoview.GeckoSession)
+[69.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[69.13]: {{javadoc_uri}}/GeckoSession.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[69.14]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+
+## v68
+- Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device
+ configuration has changed.
+- Added [`onSessionStateChange`][68.29] to [`ProgressDelegate`][68.2] and removed `saveState`.
+- Added [`ContentBlocking#AT_CRYPTOMINING`][68.3] for cryptocurrency miner blocking.
+- Added [`ContentBlocking#AT_DEFAULT`][68.4], [`ContentBlocking#AT_STRICT`][68.5],
+ [`ContentBlocking#CB_DEFAULT`][68.6] and [`ContentBlocking#CB_STRICT`][68.7]
+ for clearer app default selections.
+- Added [`GeckoSession.SessionState.fromString`][68.8]. This can be used to
+ deserialize a `GeckoSession.SessionState` instance previously serialized to
+ a `String` via `GeckoSession.SessionState.toString`.
+- Added [`GeckoRuntimeSettings#setPreferredColorScheme`][68.9] to override
+ the default color theme for web content ("light" or "dark").
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all fields.
+- [`RuntimeTelemetry#getSnapshots`][68.10] returns a [`JSONObject`][68.30] now.
+- Removed all `org.mozilla.gecko` references in the API.
+- Added [`ContentBlocking#AT_FINGERPRINTING`][68.11] to block fingerprinting trackers.
+- Added [`HistoryItem`][68.31] and [`HistoryList`][68.32] interfaces and [`onHistoryStateChange`][68.34] to
+ [`HistoryDelegate`][68.12] and added [`gotoHistoryIndex`][68.33] to [`GeckoSession`][68.13].
+- [`GeckoView`][70.5] will not create a [`GeckoSession`][65.9] anymore when
+ attached to a window without a session.
+- Added [`GeckoRuntimeSettings.Builder#configFilePath`][68.16] to set
+ a path to a configuration file from which GeckoView will read
+ configuration options such as Gecko process arguments, environment
+ variables, and preferences.
+- Added [`unregisterWebExtension`][68.17] to unregister a web extension.
+- Added messaging support for WebExtension. [`setMessageDelegate`][68.18]
+ allows embedders to listen to messages coming from a WebExtension.
+ [`Port`][68.19] allows bidirectional communication between the embedder and
+ the WebExtension.
+- Expose the following prefs in [`GeckoRuntimeSettings`][67.3]:
+ [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21],
+ [`setGlMsaaLevel`][68.22].
+- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.35]
+- Added `setVerticalClipping` to [`GeckoDisplay`][68.24] and
+ [`GeckoView`][68.23] to tell Gecko how much of its vertical space is clipped.
+- Added [`StorageController`][68.25] API for clearing data.
+- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices.
+- Removed redundant constants in [`MediaSource`][68.28]
+
+[68.1]: {{javadoc_uri}}/GeckoRuntime.html#configurationChanged(android.content.res.Configuration)
+[68.2]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html
+[68.3]: {{javadoc_uri}}/ContentBlocking.html#AT_CRYPTOMINING
+[68.4]: {{javadoc_uri}}/ContentBlocking.html#AT_DEFAULT
+[68.5]: {{javadoc_uri}}/ContentBlocking.html#AT_STRICT
+[68.6]: {{javadoc_uri}}/ContentBlocking.html#CB_DEFAULT
+[68.7]: {{javadoc_uri}}/ContentBlocking.html#CB_STRICT
+[68.8]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+[68.9]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setPreferredColorScheme(int)
+[68.10]: {{javadoc_uri}}/RuntimeTelemetry.html#getSnapshots(boolean)
+[68.11]: {{javadoc_uri}}/ContentBlocking.html#AT_FINGERPRINTING
+[68.12]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[68.13]: {{javadoc_uri}}/GeckoSession.html
+[68.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String)
+[68.17]: {{javadoc_uri}}/GeckoRuntime.html#unregisterWebExtension(org.mozilla.geckoview.WebExtension)
+[68.18]: {{javadoc_uri}}/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[68.19]: {{javadoc_uri}}/WebExtension.Port.html
+[68.20]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoZoomEnabled(boolean)
+[68.21]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled(boolean)
+[68.22]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setGlMsaaLevel(int)
+[68.23]: {{javadoc_uri}}/GeckoView.html#setVerticalClipping(int)
+[68.24]: {{javadoc_uri}}/GeckoDisplay.html#setVerticalClipping(int)
+[68.25]: {{javadoc_uri}}/StorageController.html
+[68.26]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html#onRecordingStatusChanged(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice[])
+[68.27]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[68.28]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html
+[68.29]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html#onSessionStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SessionState)
+[68.30]: https://developer.android.com/reference/org/json/JSONObject
+[68.31]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryItem.html
+[68.32]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryList.html
+[68.33]: {{javadoc_uri}}/GeckoSession.html#gotoHistoryIndex(int)
+[68.34]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html#onHistoryStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.HistoryDelegate.HistoryList)
+[68.35]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE
+
+## v67
+- Added [`setAutomaticFontSizeAdjustment`][67.23] to
+ [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings
+ depending on the OS-level font size setting.
+- Added [`setFontSizeFactor`][67.4] to [`GeckoRuntimeSettings`][67.3] for
+ setting a font size scaling factor, and for enabling font inflation for
+ non-mobile-friendly pages.
+- Updated video autoplay API to reflect changes in Gecko. Instead of being a
+ per-video permission in the [`PermissionDelegate`][67.5], it is a [runtime
+ setting][67.6] that either allows or blocks autoplay videos.
+- Change [`ContentBlocking.AT_AD`][67.7] and [`ContentBlocking.SB_ALL`][67.8]
+ values to mirror the actual constants they encompass.
+- Added nested [`ContentBlocking`][67.9] runtime settings.
+- Added [`RuntimeSettings`][67.10] base class to support nested settings.
+- Added [`baseUri`][67.11] to [`ContentDelegate.ContextElement`][65.21] and
+ changed [`linkUri`][67.12] to absolute form.
+- Added [`scrollBy`][67.13] and [`scrollTo`][67.14] to [`PanZoomController`][65.4].
+- Added [`GeckoSession.getDefaultUserAgent`][67.1] to expose the build-time
+ default user agent synchronously.
+- Changed [`WebResponse.body`][67.24] from a [`ByteBuffer`][67.25] to an [`InputStream`][67.26]. Apps that want access
+ to the entire response body will now need to read the stream themselves.
+- Added [`GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`][67.27], which will cause [`GeckoWebExecutor.fetch()`][67.28] to not
+ automatically follow [HTTP redirects][67.29] (e.g., 302).
+- Moved [`GeckoVRManager`][67.2] into the org.mozilla.geckoview package.
+- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15]
+ allows embedders to register a local web extension.
+- Added API to [`GeckoView`][70.5] to take screenshot of the visible page. Calling [`capturePixels`][67.16] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.17] of the current [`Surface`][67.18] contents, or an [`IllegalStateException`][67.19] if the [`GeckoSession`][65.9] is not ready to render content.
+- Added API to capture a screenshot to [`GeckoDisplay`][67.20]. [`capturePixels`][67.21] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.16] of the current [`Surface`][67.17] contents, or an [`IllegalStateException`][67.18] if the [`GeckoSession`][65.9] is not ready to render content.
+- Add missing [`@Nullable`][66.2] annotation to return value for
+ [`GeckoSession.PromptDelegate.ChoiceCallback.onPopupResult()`][67.30]
+- Added `default` implementations for all non-functional `interface`s.
+- Added [`ContentDelegate.onWebAppManifest`][67.22], which will deliver the contents of a parsed
+ and validated Web App Manifest on pages that contain one.
+
+[67.1]: {{javadoc_uri}}/GeckoSession.html#getDefaultUserAgent()
+[67.2]: {{javadoc_uri}}/GeckoVRManager.html
+[67.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[67.4]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontSizeFactor(float)
+[67.5]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html
+[67.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoplayDefault(int)
+[67.7]: {{javadoc_uri}}/ContentBlocking.html#AT_AD
+[67.8]: {{javadoc_uri}}/ContentBlocking.html#SB_ALL
+[67.9]: {{javadoc_uri}}/ContentBlocking.html
+[67.10]: {{javadoc_uri}}/RuntimeSettings.html
+[67.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#baseUri
+[67.12]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#linkUri
+[67.13]: {{javadoc_uri}}/PanZoomController.html#scrollBy(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.14]: {{javadoc_uri}}/PanZoomController.html#scrollTo(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.15]: {{javadoc_uri}}/GeckoRuntime.html#registerWebExtension(org.mozilla.geckoview.WebExtension)
+[67.16]: {{javadoc_uri}}/GeckoView.html#capturePixels()
+[67.17]: https://developer.android.com/reference/android/graphics/Bitmap
+[67.18]: https://developer.android.com/reference/android/view/Surface
+[67.19]: https://developer.android.com/reference/java/lang/IllegalStateException
+[67.20]: {{javadoc_uri}}/GeckoDisplay.html
+[67.21]: {{javadoc_uri}}/GeckoDisplay.html#capturePixels()
+[67.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onWebAppManifest(org.mozilla.geckoview.GeckoSession,org.json.JSONObject)
+[67.23]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[67.24]: {{javadoc_uri}}/WebResponse.html#body
+[67.25]: https://developer.android.com/reference/java/nio/ByteBuffer
+[67.26]: https://developer.android.com/reference/java/io/InputStream
+[67.27]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_NO_REDIRECTS
+[67.28]: {{javadoc_uri}}/GeckoWebExecutor.html#fetch(org.mozilla.geckoview.WebRequest,int)
+[67.29]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
+[67.30]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ChoiceCallback.html
+
+## v66
+- Removed redundant field `trackingMode` from [`SecurityInformation`][66.6].
+ Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked
+ elements during page load.
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs.
+- Added methods for each setting in [`GeckoSessionSettings`][66.3]
+- Added [`GeckoSessionSettings`][66.4] for enabling desktop viewport. Desktop
+ viewport is no longer set by [`USER_AGENT_MODE_DESKTOP`][66.5] and must be set
+ separately.
+- Added [`@UiThread`][65.6] to [`GeckoSession.releaseSession`][66.7] and
+ [`GeckoSession.setSession`][66.8]
+
+[66.1]: https://developer.android.com/reference/android/support/annotation/NonNull
+[66.2]: https://developer.android.com/reference/android/support/annotation/Nullable
+[66.3]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.4]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.5]: {{javadoc_uri}}/GeckoSessionSettings.html#USER_AGENT_MODE_DESKTOP
+[66.6]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html
+[66.7]: {{javadoc_uri}}/GeckoView.html#releaseSession()
+[66.8]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+
+## v65
+- Added experimental ad-blocking category to `GeckoSession.TrackingProtectionDelegate`.
+- Moved [`CompositorController`][65.1], [`DynamicToolbarAnimator`][65.2],
+ [`OverscrollEdgeEffect`][65.3], [`PanZoomController`][65.4] from
+ `org.mozilla.gecko.gfx` to [`org.mozilla.geckoview`][65.5]
+- Added [`@UiThread`][65.6], [`@AnyThread`][65.7] annotations to all APIs
+- Changed `GeckoRuntimeSettings#getLocale` to [`getLocales`][65.8] and related
+ APIs.
+- Merged `org.mozilla.gecko.gfx.LayerSession` into [`GeckoSession`][65.9]
+- Added [`GeckoSession.MediaDelegate`][65.10] and [`MediaElement`][65.11]. This
+ allow monitoring and control of web media elements (play, pause, seek, etc).
+- Removed unused `access` parameter from
+ [`GeckoSession.PermissionDelegate#onContentPermissionRequest`][65.12]
+- Added [`WebMessage`][65.13], [`WebRequest`][65.14], [`WebResponse`][65.15],
+ and [`GeckoWebExecutor`][65.16]. This exposes Gecko networking to apps. It
+ includes speculative connections, name resolution, and a Fetch-like HTTP API.
+- Added [`GeckoSession.HistoryDelegate`][65.17]. This allows apps to implement
+ their own history storage system and provide visited link status.
+- Added [`ContentDelegate#onFirstComposite`][65.18] to get first composite
+ callback after a compositor start.
+- Changed `LoadRequest.isUserTriggered` to [`isRedirect`][65.19].
+- Added [`GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER`][65.20] to bypass the URI
+ classifier.
+- Added a `protected` empty constructor to all field-only classes so that apps
+ can mock these classes in tests.
+- Added [`ContentDelegate.ContextElement`][65.21] to extend the information
+ passed to [`ContentDelegate#onContextMenu`][65.22]. Extended information
+ includes the element's title and alt attributes.
+- Changed [`ContentDelegate.ContextElement`][65.21] `TYPE_` constants to public
+ access.
+- Changed [`ContentDelegate.ContextElement`][65.21],
+ [`GeckoSession.FinderResult`][65.23] to non-final class.
+- Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
+ [`GeckoResult<String>`][65.25].
+
+[65.1]: {{javadoc_uri}}/CompositorController.html
+[65.2]: {{javadoc_uri}}/DynamicToolbarAnimator.html
+[65.3]: {{javadoc_uri}}/OverscrollEdgeEffect.html
+[65.4]: {{javadoc_uri}}/PanZoomController.html
+[65.5]: {{javadoc_uri}}/package-summary.html
+[65.6]: https://developer.android.com/reference/android/support/annotation/UiThread
+[65.7]: https://developer.android.com/reference/android/support/annotation/AnyThread
+[65.8]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getLocales()
+[65.9]: {{javadoc_uri}}/GeckoSession.html
+[65.10]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[65.11]: {{javadoc_uri}}/MediaElement.html
+[65.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,java.lang.String,int,org.mozilla.geckoview.GeckoSession.PermissionDelegate.Callback)
+[65.13]: {{javadoc_uri}}/WebMessage.html
+[65.14]: {{javadoc_uri}}/WebRequest.html
+[65.15]: {{javadoc_uri}}/WebResponse.html
+[65.16]: {{javadoc_uri}}/GeckoWebExecutor.html
+[65.17]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[65.18]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstComposite(org.mozilla.geckoview.GeckoSession)
+[65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect
+[65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER
+[65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html
+[65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu(org.mozilla.geckoview.GeckoSession,int,int,org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement)
+[65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html
+[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,android.os.Bundle,java.lang.String)
+[65.25]: {{javadoc_uri}}/GeckoResult.html
+
+[api-version]: a08a53cd6a57e5b698d1b74d82a7be789926754c
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java
new file mode 100644
index 0000000000..4394d27f72
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java
@@ -0,0 +1,40 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * 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/. */
+
+/**
+ * This package contains the public interfaces for the library.
+ *
+ * <ul>
+ * <li>{@link org.mozilla.geckoview.GeckoRuntime} is the entry point for starting and initializing
+ * Gecko. You can use this to preload Gecko before you need to load a page or to configure
+ * features such as crash reporting.
+ * <li>{@link org.mozilla.geckoview.GeckoSession} is where most interesting work happens, such as
+ * loading pages. It relies on {@link org.mozilla.geckoview.GeckoRuntime} to talk to Gecko.
+ * <li>{@link org.mozilla.geckoview.GeckoView} is the embeddable {@link android.view.View}. This
+ * is the most common way of getting a {@link org.mozilla.geckoview.GeckoSession} onto the
+ * screen.
+ * </ul>
+ *
+ * <p><strong>Permissions</strong>
+ *
+ * <p>This library does not request any dangerous permissions in the manifest, though it's possible
+ * that some web features may require them. For instance, WebRTC video calls would need the {@link
+ * android.Manifest.permission#CAMERA} and {@link android.Manifest.permission#RECORD_AUDIO}
+ * permissions. Declaring these are at the application's discretion. If you want full web
+ * functionality, the following permissions should be declared:
+ *
+ * <ul>
+ * <li>{@link android.Manifest.permission#ACCESS_COARSE_LOCATION}
+ * <li>{@link android.Manifest.permission#ACCESS_FINE_LOCATION}
+ * <li>{@link android.Manifest.permission#READ_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#CAMERA}
+ * <li>{@link android.Manifest.permission#RECORD_AUDIO}
+ * </ul>
+ *
+ * For a detailed change log of the API see: <a href="./doc-files/CHANGELOG"
+ * target="_blank">CHANGELOG</a>.
+ */
+package org.mozilla.geckoview;