summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview/src/main')
-rw-r--r--mobile/android/geckoview/src/main/AndroidManifest.xml75
-rw-r--r--mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja19
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl41
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl36
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl15
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl33
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl17
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl27
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl31
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl21
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl23
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl11
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl7
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java402
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java136
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java516
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java101
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java577
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java2035
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java202
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java329
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java449
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java514
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java548
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java232
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java450
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java308
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java166
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java812
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/HapticFeedbackDelegate.java18
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java99
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java189
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java232
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java16
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java310
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java57
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenOrientationDelegate.java26
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java200
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java180
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java165
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryContract.java317
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java247
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/WakeLockDelegate.java51
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java26
-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.java15
-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.java65
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java93
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java136
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java327
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java73
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java127
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocatorService.java66
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java39
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java54
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java42
-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.java97
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java686
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java457
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java173
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java30
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java167
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java120
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java86
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java168
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java1008
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java335
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java505
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java690
-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.java44
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java38
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java481
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java235
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java328
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java78
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java253
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java164
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java258
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java231
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java99
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java156
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java51
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java41
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java64
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java52
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java516
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java16
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/MinidumpAnalyzer.java31
-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/NativeZip.java84
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java163
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java184
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja15
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java826
-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.java178
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java579
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java137
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java98
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BitmapUtils.java321
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java22
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContentUriUtils.java205
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java55
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java110
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java53
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java427
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java137
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java78
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java1093
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java221
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java160
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java177
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java127
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java125
-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.java84
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java374
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java18
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java219
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java182
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java156
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java52
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StrictModeContext.java92
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java331
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java163
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java27
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java165
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java17
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java708
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java1251
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java437
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java17
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java138
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java1655
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java419
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java361
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java34
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java399
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java2336
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java174
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java726
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java208
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java1008
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java873
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java1200
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java6267
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java108
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java734
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java40
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java904
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java195
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java45
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaElement.java590
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java742
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java210
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java806
-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.java170
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java273
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java189
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java156
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java1034
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java134
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java412
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java18
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java184
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java529
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java2610
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java1286
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java131
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java124
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java27
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java141
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java61
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java176
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java239
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java392
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java198
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md880
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java48
-rw-r--r--mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml11
197 files changed, 58781 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/AndroidManifest.xml b/mobile/android/geckoview/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..a76b6a4754
--- /dev/null
+++ b/mobile/android/geckoview/src/main/AndroidManifest.xml
@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.mozilla.geckoview">
+
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+ <uses-permission android:name="android.permission.INTERNET"/>
+ <uses-permission android:name="android.permission.WAKE_LOCK"/>
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+
+ <uses-feature
+ android:name="android.hardware.location"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.location.gps"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.touchscreen"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera.autofocus"
+ android:required="false"/>
+
+ <uses-feature
+ android:name="android.hardware.audio.low_latency"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.microphone"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera.any"
+ android:required="false"/>
+
+ <!-- GeckoView requires OpenGL ES 2.0 -->
+ <uses-feature
+ android:glEsVersion="0x00020000"
+ android:required="true"/>
+
+ <application>
+ <service
+ android:name="org.mozilla.gecko.media.MediaManager"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":media">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$gmplugin"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":gmplugin">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$socket"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":socket">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.gfx.SurfaceAllocatorService"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false">
+ </service>
+ </application>
+
+</manifest>
diff --git a/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja b/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja
new file mode 100644
index 0000000000..a2bf0efb7f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.mozilla.geckoview">
+ <application>
+ {% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %}
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$tab{{ id }}"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="{{ 'true' if MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS else 'false' }}"
+ android:process=":tab{{ id }}">
+ </service>
+ {% endfor %}
+ </application>
+</manifest>
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl
new file mode 100644
index 0000000000..7a2adfc15b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl
@@ -0,0 +1,41 @@
+/* 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.IGeckoEditableParent;
+
+import android.view.KeyEvent;
+
+// Interface for GeckoEditable calls from parent to child
+interface IGeckoEditableChild {
+ // Transfer this child to a new parent.
+ void transferParent(in IGeckoEditableParent parent);
+
+ // Process a key event.
+ void onKeyEvent(int action, int keyCode, int scanCode, int metaState,
+ int keyPressMetaState, long time, int domPrintableKeyValue,
+ int repeatCount, int flags, boolean isSynthesizedImeKey,
+ in KeyEvent event);
+
+ // Request a callback to parent after performing any pending operations.
+ void onImeSynchronize();
+
+ // Replace part of current text.
+ void onImeReplaceText(int start, int end, String text);
+
+ // Store a composition range.
+ void onImeAddCompositionRange(int start, int end, int rangeType, int rangeStyles,
+ int rangeLineStyle, boolean rangeBoldLine,
+ int rangeForeColor, int rangeBackColor, int rangeLineColor);
+
+ // Change to a new composition using previously added ranges.
+ void onImeUpdateComposition(int start, int end, int flags);
+
+ // Request cursor updates from the child.
+ void onImeRequestCursorUpdates(int requestMode);
+
+ // Commit current composition.
+ void onImeRequestCommit();
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl
new file mode 100644
index 0000000000..4dc0a7ca79
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl
@@ -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;
+
+import android.os.IBinder;
+import android.view.KeyEvent;
+
+import org.mozilla.gecko.IGeckoEditableChild;
+
+// Interface for GeckoEditable calls from child to parent
+interface IGeckoEditableParent {
+ // Set the default child to forward events to, when there is no focused child.
+ void setDefaultChild(IGeckoEditableChild child);
+
+ // Notify an IME event of a type defined in GeckoEditableListener.
+ void notifyIME(IGeckoEditableChild child, int type);
+
+ // Notify a change in editor state or type.
+ void notifyIMEContext(IBinder token, int state, String typeHint, String modeHint,
+ String actionHint, String autocapitalize, int flags);
+
+ // Notify a change in editor selection.
+ void onSelectionChange(IBinder token, int start, int end);
+
+ // Notify a change in editor text.
+ void onTextChange(IBinder token, in CharSequence text,
+ int start, int unboundedOldEnd);
+
+ // Perform the default action associated with a key event.
+ void onDefaultKeyEvent(IBinder token, in KeyEvent event);
+
+ // Update the screen location of current composition.
+ void updateCompositionRects(IBinder token, in RectF[] rects);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl
new file mode 100644
index 0000000000..3fe35450fc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable GeckoSurface; \ No newline at end of file
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl
new file mode 100644
index 0000000000..ecb8df27f3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl
@@ -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.gecko.gfx;
+
+import org.mozilla.gecko.gfx.GeckoSurface;
+import org.mozilla.gecko.gfx.SyncConfig;
+
+interface ISurfaceAllocator {
+ GeckoSurface acquireSurface(in int width, in int height, in boolean singleBufferMode);
+ void releaseSurface(in int handle);
+ void configureSync(in SyncConfig config);
+ void sync(in int handle);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl
new file mode 100644
index 0000000000..59cd09ffdf
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable SyncConfig;
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl
new file mode 100644
index 0000000000..91ce56d463
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable FormatParam; \ No newline at end of file
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl
new file mode 100644
index 0000000000..228b41ed9b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl
@@ -0,0 +1,33 @@
+/* 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;
+
+// Non-default types used in interface.
+import android.os.Bundle;
+import org.mozilla.gecko.gfx.GeckoSurface;
+import org.mozilla.gecko.media.FormatParam;
+import org.mozilla.gecko.media.ICodecCallbacks;
+import org.mozilla.gecko.media.Sample;
+import org.mozilla.gecko.media.SampleBuffer;
+
+interface ICodec {
+ void setCallbacks(in ICodecCallbacks callbacks);
+ boolean configure(in FormatParam format, in GeckoSurface surface, in int flags, in String drmStubId);
+ boolean isAdaptivePlaybackSupported();
+ boolean isHardwareAccelerated();
+ boolean isTunneledPlaybackSupported();
+ void start();
+ void stop();
+ void flush();
+ void release();
+
+ Sample dequeueInput(int size);
+ oneway void queueInput(in Sample sample);
+ SampleBuffer getInputBuffer(int id);
+ SampleBuffer getOutputBuffer(int id);
+
+ void releaseOutput(in Sample sample, in boolean render);
+ oneway void setBitrate(in int bps);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl
new file mode 100644
index 0000000000..58ee1e2b1b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl
@@ -0,0 +1,17 @@
+/* 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;
+
+// Non-default types used in interface.
+import org.mozilla.gecko.media.FormatParam;
+import org.mozilla.gecko.media.Sample;
+
+interface ICodecCallbacks {
+ oneway void onInputQueued(long timestamp);
+ oneway void onInputPending(long timestamp);
+ oneway void onOutputFormatChanged(in FormatParam format);
+ oneway void onOutput(in Sample sample);
+ oneway void onError(boolean fatal);
+} \ No newline at end of file
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl
new file mode 100644
index 0000000000..f5f5e06b08
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl
@@ -0,0 +1,27 @@
+/* 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;
+
+// Non-default types used in interface.
+import org.mozilla.gecko.media.IMediaDrmBridgeCallbacks;
+
+interface IMediaDrmBridge {
+ void setCallbacks(in IMediaDrmBridgeCallbacks callbacks);
+
+ oneway void createSession(int createSessionToken,
+ int promiseId,
+ String initDataType,
+ in byte[] initData);
+
+ oneway void updateSession(int promiseId,
+ String sessionId,
+ in byte[] response);
+
+ oneway void closeSession(int promiseId, String sessionId);
+
+ oneway void release();
+
+ void setServerCertificate(in byte[] cert);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl
new file mode 100644
index 0000000000..b3918417e6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl
@@ -0,0 +1,31 @@
+/* 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;
+
+// Non-default types used in interface.
+import org.mozilla.gecko.media.SessionKeyInfo;
+
+interface IMediaDrmBridgeCallbacks {
+
+ oneway void onSessionCreated(int createSessionToken,
+ int promiseId,
+ in byte[] sessionId,
+ in byte[] request);
+
+ oneway void onSessionUpdated(int promiseId, in byte[] sessionId);
+
+ oneway void onSessionClosed(int promiseId, in byte[] sessionId);
+
+ oneway void onSessionMessage(in byte[] sessionId,
+ int sessionMessageType,
+ in byte[] request);
+
+ oneway void onSessionError(in byte[] sessionId, String message);
+
+ oneway void onSessionBatchedKeyChanged(in byte[] sessionId,
+ in SessionKeyInfo[] keyInfos);
+
+ oneway void onRejectPromise(int promiseId, String message);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl
new file mode 100644
index 0000000000..2cc6d56945
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl
@@ -0,0 +1,21 @@
+/* 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;
+
+// Non-default types used in interface.
+import org.mozilla.gecko.media.ICodec;
+import org.mozilla.gecko.media.IMediaDrmBridge;
+
+interface IMediaManager {
+ /** Creates a remote ICodec object. */
+ ICodec createCodec();
+
+ /** Creates a remote IMediaDrmBridge object. */
+ IMediaDrmBridge createRemoteMediaDrmBridge(in String keySystem,
+ in String stubId);
+
+ /** Called by client to indicate it no longer needs a requested codec or DRM bridge. */
+ oneway void endRequest();
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl
new file mode 100644
index 0000000000..0d55c76fc6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable Sample; \ No newline at end of file
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl
new file mode 100644
index 0000000000..a124c73721
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable SampleBuffer;
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl
new file mode 100644
index 0000000000..1ec8f63c73
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable SessionKeyInfo; \ No newline at end of file
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl
new file mode 100644
index 0000000000..4cd127ab62
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl
@@ -0,0 +1,23 @@
+/* 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.process.IProcessManager;
+
+import android.os.Bundle;
+import android.os.ParcelFileDescriptor;
+
+interface IChildProcess {
+ int getPid();
+ boolean start(in IProcessManager procMan, in String[] args, in Bundle extras, int flags,
+ in String crashHandlerService,
+ in ParcelFileDescriptor prefsPfd,
+ in ParcelFileDescriptor prefMapPfd,
+ in ParcelFileDescriptor ipcPfd,
+ in ParcelFileDescriptor crashReporterPfd,
+ in ParcelFileDescriptor crashAnnotationPfd);
+
+ void crash();
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl
new file mode 100644
index 0000000000..b6bed645f7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl
@@ -0,0 +1,11 @@
+/* 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.IGeckoEditableChild;
+
+interface IProcessManager {
+ void getEditableParent(in IGeckoEditableChild child, long contentId, long tabId);
+}
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl
new file mode 100644
index 0000000000..f4c87dafb3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl
@@ -0,0 +1,7 @@
+/* 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;
+
+parcelable GeckoBundle;
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..dd9ea65588
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java
@@ -0,0 +1,402 @@
+/* -*- 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 java.util.ArrayList;
+import java.util.List;
+import java.util.Timer;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GamepadUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+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;
+
+
+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];
+
+ 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 (KeyEvent ev : pending) {
+ handleKeyEvent(ev);
+ }
+ }
+
+ private static float deadZone(final MotionEvent ev, final int axis) {
+ if (GamepadUtils.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
+ boolean[] valid = new boolean[Axis.values().length];
+ float[] axes = new float[Axis.values().length];
+ boolean anyValidAxes = false;
+ for (Axis axis : Axis.values()) {
+ float value = deadZone(ev, axis.axis);
+ 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 (Trigger trigger : Trigger.values()) {
+ int i = trigger.ordinal();
+ int axis = gamepad.triggerAxes[i];
+ float value = deadZone(ev, axis);
+ if (value != gamepad.triggers[i]) {
+ gamepad.triggers[i] = value;
+ boolean pressed = value > TRIGGER_PRESSED_THRESHOLD;
+ onButtonChange(gamepad.handle, trigger.button, pressed, value);
+ }
+ }
+ }
+ // Map d-pad to buttons.
+ for (DpadAxis dpadaxis : DpadAxis.values()) {
+ 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;
+ }
+
+ 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) {
+ 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 (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;
+ }
+
+ Gamepad gamepad = sGamepads.get(deviceId);
+ boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN;
+ onButtonChange(gamepad.handle, key, pressed, pressed ? 1.0f : 0.0f);
+ return true;
+ }
+
+ private static void scanForGamepads() {
+ int[] deviceIds = InputDevice.getDeviceIds();
+ if (deviceIds == null) {
+ return;
+ }
+ for (int i = 0; i < deviceIds.length; i++) {
+ 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) {
+ 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) {
+ 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..0c70dde3c1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java
@@ -0,0 +1,136 @@
+/* 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;
+
+import android.content.ClipboardManager;
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+
+public final class Clipboard {
+ private final static String HTML_MIME = "text/html";
+ private final static String UNICODE_MIME = "text/unicode";
+ private final static 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()) {
+ ClipData clip = cm.getPrimaryClip();
+ if (clip == null || clip.getItemCount() == 0) {
+ return null;
+ }
+
+ ClipDescription description = clip.getDescription();
+ if (HTML_MIME.equals(mimeType) && description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) {
+ CharSequence data = clip.getItemAt(0).getHtmlText();
+ if (data == null) {
+ return null;
+ }
+ return data.toString();
+ }
+ if (UNICODE_MIME.equals(mimeType)) {
+ return clip.getItemAt(0).coerceToText(context).toString();
+ }
+ }
+ 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 (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 (RuntimeException e) {
+ // If clipData is too large, TransactionTooLargeException occurs.
+ Log.e(LOGTAG, "Couldn't set clip data to clipboard", e);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @return true if the clipboard is nonempty, false otherwise.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static boolean hasData(final Context context, final String mimeType) {
+ if (HTML_MIME.equals(mimeType) || UNICODE_MIME.equals(mimeType)) {
+ return !TextUtils.isEmpty(getData(context, mimeType));
+ }
+ return false;
+ }
+
+ /**
+ * Deletes all text from the clipboard.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ public static void clearText(final Context context) {
+ setText(context, null);
+ }
+}
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..29ec4ea021
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java
@@ -0,0 +1,516 @@
+/* -*- 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 org.json.JSONObject;
+import org.json.JSONException;
+
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoRuntime;
+
+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;
+
+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) {
+ StringWriter sw = new StringWriter();
+ 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 (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.
+ 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_FATAL, true);
+ 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,
+ "--ez", GeckoRuntime.EXTRA_CRASH_FATAL, "true");
+ } 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,
+ "--ez", GeckoRuntime.EXTRA_CRASH_FATAL, "true");
+ }
+
+ 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);
+
+ JSONObject json = new JSONObject();
+ for (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..864b06b3ab
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java
@@ -0,0 +1,101 @@
+/* -*- 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 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;
+
+import android.util.Log;
+
+// 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.
+ KeyStore ks;
+ try {
+ ks = KeyStore.getInstance("AndroidCAStore");
+ } catch (KeyStoreException kse) {
+ Log.e(LOGTAG, "getInstance() failed", kse);
+ return new byte[0][0];
+ }
+ try {
+ ks.load(null);
+ } catch (CertificateException ce) {
+ Log.e(LOGTAG, "load() failed", ce);
+ return new byte[0][0];
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "load() failed", ioe);
+ return new byte[0][0];
+ } catch (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.
+ Enumeration<String> aliases;
+ try {
+ aliases = ks.aliases();
+ } catch (KeyStoreException kse) {
+ Log.e(LOGTAG, "aliases() failed", kse);
+ return new byte[0][0];
+ }
+ ArrayList<byte[]> roots = new ArrayList<byte[]>();
+ while (aliases.hasMoreElements()) {
+ String alias = aliases.nextElement();
+ boolean isCertificate;
+ try {
+ isCertificate = ks.isCertificateEntry(alias);
+ } catch (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:")) {
+ Certificate certificate;
+ try {
+ certificate = ks.getCertificate(alias);
+ } catch (KeyStoreException kse) {
+ Log.e(LOGTAG, "getCertificate() failed", kse);
+ continue;
+ }
+ try {
+ roots.add(certificate.getEncoded());
+ } catch (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..65ef7d75b3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
@@ -0,0 +1,577 @@
+/* -*- 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 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;
+
+import android.os.Handler;
+import androidx.annotation.AnyThread;
+import android.util.Log;
+
+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;
+
+@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.
+ *
+ * Named EventDispatchers can be used to communicate to Gecko's corresponding
+ * named EventDispatcher.
+ *
+ * Messages for named EventDispatcher are queued by default when no listener is present.
+ * Queued messages will be released automatically when a listener is attached.
+ *
+ * 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.
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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).
+ *
+ * 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.
+ *
+ * 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() {
+ listener.handleMessage(type, message, wrappedCallback);
+ }
+ });
+ }
+ 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..e7febbf2a4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
@@ -0,0 +1,2035 @@
+/* -*- 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 java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.net.MalformedURLException;
+import java.net.Proxy;
+import java.net.URLConnection;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.StringTokenizer;
+
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.BitmapUtils;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+import org.mozilla.gecko.util.HardwareUtils;
+import org.mozilla.gecko.util.InputDeviceUtils;
+import org.mozilla.gecko.util.IOUtils;
+import org.mozilla.gecko.util.ProxySelector;
+import org.mozilla.gecko.util.StrictModeContext;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.R;
+
+import android.annotation.SuppressLint;
+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.ImageFormat;
+import android.graphics.PixelFormat;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.hardware.Camera;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+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 androidx.annotation.Nullable;
+import androidx.core.content.res.ResourcesCompat;
+import androidx.collection.SimpleArrayMap;
+import android.telephony.TelephonyManager;
+import android.text.format.DateFormat;
+import android.text.TextUtils;
+import android.util.Log;
+import android.view.ContextThemeWrapper;
+import android.view.Display;
+import android.view.HapticFeedbackConstants;
+import android.view.InputDevice;
+import android.view.WindowManager;
+import android.webkit.MimeTypeMap;
+
+public class GeckoAppShell {
+ private static final String LOGTAG = "GeckoAppShell";
+
+ // We have static members only.
+ private GeckoAppShell() { }
+
+ 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 =
+ GeckoSharedPrefs.forApp(getApplicationContext());
+ 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;
+ }
+
+ public static synchronized boolean isCrashHandlingEnabled() {
+ return sCrashHandler != null;
+ }
+
+ @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;
+
+ // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB.
+ private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768;
+
+ static private int sDensityDpi;
+ static private Float sDensity;
+ static private int sScreenDepth;
+ static private boolean sUseMaxScreenDepth;
+
+ /* 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 gProximitySensor;
+ 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
+ */
+ static public final int WPL_STATE_START = 0x00000001;
+ static public final int WPL_STATE_STOP = 0x00000010;
+ static public final int WPL_STATE_IS_DOCUMENT = 0x00020000;
+ static public final int WPL_STATE_IS_NETWORK = 0x00040000;
+
+ /* Keep in sync with constants found here:
+ http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl
+ */
+ static public final int LINK_TYPE_UNKNOWN = 0;
+ static public final int LINK_TYPE_ETHERNET = 1;
+ static public final int LINK_TYPE_USB = 2;
+ static public final int LINK_TYPE_WIFI = 3;
+ static public final int LINK_TYPE_WIMAX = 4;
+ static public final int LINK_TYPE_2G = 5;
+ static public final int LINK_TYPE_3G = 6;
+ static public final int LINK_TYPE_4G = 7;
+
+ 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) {
+ float radius = location.getAccuracy();
+ return (location.hasAccuracy() && radius > 0) ? radius : 1001;
+ }
+
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static Location getLastKnownLocation(final LocationManager lm) {
+ Location lastKnownLocation = null;
+ List<String> providers = lm.getAllProviders();
+
+ for (String provider : providers) {
+ Location location = lm.getLastKnownLocation(provider);
+ if (location == null) {
+ continue;
+ }
+
+ if (lastKnownLocation == null) {
+ lastKnownLocation = location;
+ continue;
+ }
+
+ long timeDiff = location.getTime() - lastKnownLocation.getTime();
+ if (timeDiff > 0 ||
+ (timeDiff == 0 &&
+ getLocationAccuracy(location) < getLocationAccuracy(lastKnownLocation))) {
+ lastKnownLocation = location;
+ }
+ }
+
+ return lastKnownLocation;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static synchronized boolean enableLocation(final boolean enable) {
+ final LocationManager lm = getLocationManager(getApplicationContext());
+ if (lm == null) {
+ return false;
+ }
+
+ if (!enable) {
+ lm.removeUpdates(getLocationListener());
+ return true;
+ }
+
+ if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER) &&
+ !lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ return false;
+ }
+
+ final Location lastKnownLocation = getLastKnownLocation(lm);
+ if (lastKnownLocation != null) {
+ getLocationListener().onLocationChanged(lastKnownLocation);
+ }
+
+ final Criteria criteria = new Criteria();
+ criteria.setSpeedRequired(false);
+ criteria.setBearingRequired(false);
+ criteria.setAltitudeRequired(false);
+ if (locationHighAccuracyEnabled) {
+ criteria.setAccuracy(Criteria.ACCURACY_FINE);
+ criteria.setCostAllowed(true);
+ criteria.setPowerRequirement(Criteria.POWER_HIGH);
+ } else {
+ criteria.setAccuracy(Criteria.ACCURACY_COARSE);
+ criteria.setCostAllowed(false);
+ criteria.setPowerRequirement(Criteria.POWER_LOW);
+ }
+
+ final String provider = lm.getBestProvider(criteria, true);
+ if (provider == null) {
+ return false;
+ }
+
+ final Looper l = Looper.getMainLooper();
+ lm.requestLocationUpdates(provider, 100, 0.5f, getLocationListener(), l);
+ return true;
+ }
+
+ private static LocationManager getLocationManager(final Context context) {
+ try {
+ return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+ } catch (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, long time);
+
+ private static class DefaultListeners implements SensorEventListener,
+ LocationListener,
+ NotificationListener,
+ ScreenOrientationDelegate,
+ WakeLockDelegate,
+ HapticFeedbackDelegate {
+ @Override
+ public void onAccuracyChanged(final Sensor sensor, final int accuracy) {
+ }
+
+ @Override
+ public void onSensorChanged(final SensorEvent s) {
+ 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 = GeckoHalDefines.SENSOR_ACCELERATION;
+ } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION) {
+ halType = GeckoHalDefines.SENSOR_LINEAR_ACCELERATION;
+ } else {
+ halType = GeckoHalDefines.SENSOR_ORIENTATION;
+ }
+ x = s.values[0];
+ y = s.values[1];
+ z = s.values[2];
+ break;
+
+ case Sensor.TYPE_GYROSCOPE:
+ halType = GeckoHalDefines.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_PROXIMITY:
+ halType = GeckoHalDefines.SENSOR_PROXIMITY;
+ x = s.values[0];
+ z = s.sensor.getMaximumRange();
+ break;
+
+ case Sensor.TYPE_LIGHT:
+ halType = GeckoHalDefines.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 ?
+ GeckoHalDefines.SENSOR_ROTATION_VECTOR :
+ GeckoHalDefines.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.
+
+ double altitude = location.hasAltitude()
+ ? location.getAltitude()
+ : Double.NaN;
+
+ float accuracy = location.hasAccuracy()
+ ? location.getAccuracy()
+ : Float.NaN;
+
+ float altitudeAccuracy = Build.VERSION.SDK_INT >= 26 &&
+ location.hasVerticalAccuracy()
+ ? location.getVerticalAccuracyMeters()
+ : Float.NaN;
+
+ float speed = location.hasSpeed()
+ ? location.getSpeed()
+ : Float.NaN;
+
+ 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, location.getTime());
+ }
+
+ @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) {}
+
+ @Override // NotificationListener
+ public void showNotification(final String name, final String cookie, final String host,
+ final String title, final String text, final String imageUrl) {
+ // Default is to not show the notification, and immediate send close message.
+ GeckoAppShell.onNotificationClose(name, cookie);
+ }
+
+ @Override // NotificationListener
+ public void showPersistentNotification(final String name, final String cookie,
+ final String host, final String title,
+ final String text, final String imageUrl,
+ final String data) {
+ // Default is to not show the notification, and immediate send close message.
+ GeckoAppShell.onNotificationClose(name, cookie);
+ }
+
+ @Override // NotificationListener
+ public void closeNotification(final String name) {
+ // Do nothing.
+ }
+
+ @Override // ScreenOrientationDelegate
+ public boolean setRequestedOrientationForCurrentActivity(
+ final int requestedActivityInfoOrientation) {
+ // Do nothing, and report that the orientation was not set.
+ return false;
+ }
+
+ private SimpleArrayMap<String, PowerManager.WakeLock> mWakeLocks;
+
+ @Override // WakeLockDelegate
+ @SuppressLint("Wakelock") // We keep the wake lock independent from the function
+ // scope, so we need to suppress the linter warning.
+ public void setWakeLockState(final String lock, final int state) {
+ if (mWakeLocks == null) {
+ mWakeLocks = new SimpleArrayMap<>(WakeLockDelegate.LOCKS_COUNT);
+ }
+
+ PowerManager.WakeLock wl = mWakeLocks.get(lock);
+
+ // we should still hold the lock for background audio.
+ if (WakeLockDelegate.LOCK_AUDIO_PLAYING.equals(lock) &&
+ state == WakeLockDelegate.STATE_LOCKED_BACKGROUND) {
+ return;
+ }
+
+ if (state == WakeLockDelegate.STATE_LOCKED_FOREGROUND && wl == null) {
+ final PowerManager pm = (PowerManager)
+ getApplicationContext().getSystemService(Context.POWER_SERVICE);
+
+ if (WakeLockDelegate.LOCK_CPU.equals(lock) ||
+ WakeLockDelegate.LOCK_AUDIO_PLAYING.equals(lock)) {
+ wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, lock);
+ } else if (WakeLockDelegate.LOCK_SCREEN.equals(lock) ||
+ WakeLockDelegate.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();
+ mWakeLocks.put(lock, wl);
+ } else if (state != WakeLockDelegate.STATE_LOCKED_FOREGROUND && wl != null) {
+ wl.release();
+ mWakeLocks.remove(lock);
+ }
+ }
+
+ @Override
+ public void performHapticFeedback(final int effect) {
+ final int[] pattern;
+ // Use default platform values.
+ if (effect == HapticFeedbackConstants.KEYBOARD_TAP) {
+ pattern = new int[] { 40 };
+ } else if (effect == HapticFeedbackConstants.LONG_PRESS) {
+ pattern = new int[] { 0, 1, 20, 21 };
+ } else if (effect == HapticFeedbackConstants.VIRTUAL_KEY) {
+ pattern = new int[] { 0, 10, 20, 30 };
+ } else {
+ return;
+ }
+ vibrateOnHapticFeedbackEnabled(pattern);
+ }
+ }
+
+ private static final DefaultListeners DEFAULT_LISTENERS = new DefaultListeners();
+ private static SensorEventListener sSensorListener = DEFAULT_LISTENERS;
+ private static LocationListener sLocationListener = DEFAULT_LISTENERS;
+ private static NotificationListener sNotificationListener = DEFAULT_LISTENERS;
+ private static WakeLockDelegate sWakeLockDelegate = DEFAULT_LISTENERS;
+ private static HapticFeedbackDelegate sHapticFeedbackDelegate = DEFAULT_LISTENERS;
+
+ /**
+ * A delegate for supporting the Screen Orientation API.
+ */
+ private static ScreenOrientationDelegate sScreenOrientationDelegate = DEFAULT_LISTENERS;
+
+ public static SensorEventListener getSensorListener() {
+ return sSensorListener;
+ }
+
+ public static void setSensorListener(final SensorEventListener listener) {
+ sSensorListener = (listener != null) ? listener : DEFAULT_LISTENERS;
+ }
+
+ public static LocationListener getLocationListener() {
+ return sLocationListener;
+ }
+
+ public static void setLocationListener(final LocationListener listener) {
+ sLocationListener = (listener != null) ? listener : DEFAULT_LISTENERS;
+ }
+
+ public static NotificationListener getNotificationListener() {
+ return sNotificationListener;
+ }
+
+ public static void setNotificationListener(final NotificationListener listener) {
+ sNotificationListener = (listener != null) ? listener : DEFAULT_LISTENERS;
+ }
+
+ public static ScreenOrientationDelegate getScreenOrientationDelegate() {
+ return sScreenOrientationDelegate;
+ }
+
+ public static void setScreenOrientationDelegate(
+ final @Nullable ScreenOrientationDelegate screenOrientationDelegate) {
+ sScreenOrientationDelegate = (screenOrientationDelegate != null) ? screenOrientationDelegate : DEFAULT_LISTENERS;
+ }
+
+ public static WakeLockDelegate getWakeLockDelegate() {
+ return sWakeLockDelegate;
+ }
+
+ public void setWakeLockDelegate(final WakeLockDelegate delegate) {
+ sWakeLockDelegate = (delegate != null) ? delegate : DEFAULT_LISTENERS;
+ }
+
+ public static HapticFeedbackDelegate getHapticFeedbackDelegate() {
+ return sHapticFeedbackDelegate;
+ }
+
+ public static void setHapticFeedbackDelegate(final HapticFeedbackDelegate delegate) {
+ sHapticFeedbackDelegate = (delegate != null) ? delegate : DEFAULT_LISTENERS;
+ }
+
+ @SuppressWarnings("fallthrough")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableSensor(final int aSensortype) {
+ final SensorManager sm = (SensorManager)
+ getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+
+ switch (aSensortype) {
+ case GeckoHalDefines.SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor == null) {
+ gGameRotationVectorSensor = sm.getDefaultSensor(
+ Sensor.TYPE_GAME_ROTATION_VECTOR);
+ }
+ if (gGameRotationVectorSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gGameRotationVectorSensor,
+ SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gGameRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case GeckoHalDefines.SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor == null) {
+ gRotationVectorSensor = sm.getDefaultSensor(
+ Sensor.TYPE_ROTATION_VECTOR);
+ }
+ if (gRotationVectorSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gRotationVectorSensor,
+ SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case GeckoHalDefines.SENSOR_ORIENTATION:
+ if (gOrientationSensor == null) {
+ gOrientationSensor = sm.getDefaultSensor(
+ Sensor.TYPE_ORIENTATION);
+ }
+ if (gOrientationSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gOrientationSensor,
+ SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_ACCELERATION:
+ if (gAccelerometerSensor == null) {
+ gAccelerometerSensor = sm.getDefaultSensor(
+ Sensor.TYPE_ACCELEROMETER);
+ }
+ if (gAccelerometerSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gAccelerometerSensor,
+ SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_PROXIMITY:
+ if (gProximitySensor == null) {
+ gProximitySensor = sm.getDefaultSensor(Sensor.TYPE_PROXIMITY);
+ }
+ if (gProximitySensor != null) {
+ sm.registerListener(getSensorListener(),
+ gProximitySensor,
+ SensorManager.SENSOR_DELAY_NORMAL);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_LIGHT:
+ if (gLightSensor == null) {
+ gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT);
+ }
+ if (gLightSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gLightSensor,
+ SensorManager.SENSOR_DELAY_NORMAL);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor == null) {
+ gLinearAccelerometerSensor = sm.getDefaultSensor(
+ Sensor.TYPE_LINEAR_ACCELERATION);
+ }
+ if (gLinearAccelerometerSensor != null) {
+ sm.registerListener(getSensorListener(),
+ gLinearAccelerometerSensor,
+ SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor == null) {
+ gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+ }
+ if (gGyroscopeSensor != null) {
+ sm.registerListener(getSensorListener(),
+ 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 GeckoHalDefines.SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor != null) {
+ sm.unregisterListener(getSensorListener(), gGameRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case GeckoHalDefines.SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor != null) {
+ sm.unregisterListener(getSensorListener(), gRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case GeckoHalDefines.SENSOR_ORIENTATION:
+ if (gOrientationSensor != null) {
+ sm.unregisterListener(getSensorListener(), gOrientationSensor);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_ACCELERATION:
+ if (gAccelerometerSensor != null) {
+ sm.unregisterListener(getSensorListener(), gAccelerometerSensor);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_PROXIMITY:
+ if (gProximitySensor != null) {
+ sm.unregisterListener(getSensorListener(), gProximitySensor);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_LIGHT:
+ if (gLightSensor != null) {
+ sm.unregisterListener(getSensorListener(), gLightSensor);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor != null) {
+ sm.unregisterListener(getSensorListener(), gLinearAccelerometerSensor);
+ }
+ break;
+
+ case GeckoHalDefines.SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor != null) {
+ sm.unregisterListener(getSensorListener(), 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.
+ }
+
+ @JNITarget
+ static public int getPreferredIconSize() {
+ ActivityManager am = (ActivityManager)
+ getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE);
+ return am.getLauncherLargeIconSize();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String[] getHandlersForMimeType(final String aMimeType, final String aAction) {
+ final GeckoInterface geckoInterface = getGeckoInterface();
+ if (geckoInterface == null) {
+ return new String[] {};
+ }
+ return geckoInterface.getHandlersForMimeType(aMimeType, aAction);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String[] getHandlersForURL(final String aURL, final String aAction) {
+ final GeckoInterface geckoInterface = getGeckoInterface();
+ if (geckoInterface == null) {
+ return new String[] {};
+ }
+ return geckoInterface.getHandlersForURL(aURL, aAction);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Encoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(true /* aIsEncoder */);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Decoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(false /* aIsEncoder */);
+ }
+
+ static List<ResolveInfo> queryIntentActivities(final Intent intent) {
+ final PackageManager pm = getApplicationContext().getPackageManager();
+
+ // Exclude any non-exported activities: we can't open them even if we want to!
+ // Bug 1031569 has some details.
+ final ArrayList<ResolveInfo> list = new ArrayList<>();
+ for (ResolveInfo ri: pm.queryIntentActivities(intent, 0)) {
+ if (ri.activityInfo.exported) {
+ list.add(ri);
+ }
+ }
+
+ return list;
+ }
+
+ @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) {
+ StringTokenizer st = new StringTokenizer(aFileExt, ".,; ");
+ String type = null;
+ String subType = null;
+ while (st.hasMoreElements()) {
+ String ext = st.nextToken();
+ String mt = getMimeTypeFromExtension(ext);
+ if (mt == null)
+ continue;
+ int slash = mt.indexOf('/');
+ String tmpType = mt.substring(0, slash);
+ if (!tmpType.equalsIgnoreCase(type))
+ type = type == null ? tmpType : "*";
+ 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;
+ }
+
+ @SuppressWarnings("try")
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean openUriExternal(final String targetURI,
+ final String mimeType,
+ final String packageName,
+ final String className,
+ final String action,
+ final String title) {
+ final GeckoInterface geckoInterface = getGeckoInterface();
+ if (geckoInterface == null) {
+ return false;
+ }
+ // Bug 1450449 - Downloaded files already are already in a public directory and aren't
+ // really owned exclusively by Firefox, so there's no real benefit to using
+ // content:// URIs here.
+ try (StrictModeContext unused = StrictModeContext.allowAllVmPolicies()) {
+ return geckoInterface.openUriExternal(targetURI, mimeType, packageName, className, action, title);
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void notifyAlertListener(String name, String topic, String cookie);
+
+ /**
+ * Called by the NotificationListener to notify Gecko that a notification has been
+ * shown.
+ */
+ public static void onNotificationShow(final String name, final String cookie) {
+ if (GeckoThread.isRunning()) {
+ notifyAlertListener(name, "alertshow", 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);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void showNotification(final String name, final String cookie, final String title,
+ final String text, final String host,
+ final String imageUrl, final String persistentData) {
+ if (persistentData == null) {
+ getNotificationListener().showNotification(name, cookie, title, text, host, imageUrl);
+ return;
+ }
+
+ getNotificationListener().showPersistentNotification(
+ name, cookie, title, text, host, imageUrl, persistentData);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void closeNotification(final String name) {
+ getNotificationListener().closeNotification(name);
+ }
+
+ 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 = new Float(getApplicationContext().getResources().getDisplayMetrics().density);
+ }
+
+ return sDensity;
+ }
+
+ private static boolean isHighMemoryDevice(final Context context) {
+ return SysInfo.getMemSize(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();
+ 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")
+ 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) {
+ getHapticFeedbackDelegate().performHapticFeedback(
+ aIsLongPress ? HapticFeedbackConstants.LONG_PRESS
+ : HapticFeedbackConstants.VIRTUAL_KEY);
+ 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) {
+ 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;
+ 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 {
+ NetworkInfo info = sConnectivityManager.getActiveNetworkInfo();
+ if (info == null || !info.isConnected())
+ return false;
+ } catch (SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean isNetworkLinkKnown() {
+ ensureConnectivityManager();
+ try {
+ if (sConnectivityManager.getActiveNetworkInfo() == null)
+ return false;
+ } catch (SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getNetworkLinkType() {
+ ensureConnectivityManager();
+ 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:
+ break; // We will handle sub-types after the switch.
+ default:
+ Log.w(LOGTAG, "Ignoring the current network type.");
+ return LINK_TYPE_UNKNOWN;
+ }
+
+ TelephonyManager tm = (TelephonyManager)
+ getApplicationContext().getSystemService(Context.TELEPHONY_SERVICE);
+ if (tm == null) {
+ Log.e(LOGTAG, "Telephony service does not exist");
+ return LINK_TYPE_UNKNOWN;
+ }
+
+ switch (tm.getNetworkType()) {
+ case TelephonyManager.NETWORK_TYPE_IDEN:
+ case TelephonyManager.NETWORK_TYPE_CDMA:
+ case TelephonyManager.NETWORK_TYPE_GPRS:
+ return LINK_TYPE_2G;
+ case TelephonyManager.NETWORK_TYPE_1xRTT:
+ case TelephonyManager.NETWORK_TYPE_EDGE:
+ return LINK_TYPE_2G; // 2.5G
+ case TelephonyManager.NETWORK_TYPE_UMTS:
+ case TelephonyManager.NETWORK_TYPE_EVDO_0:
+ return LINK_TYPE_3G;
+ case TelephonyManager.NETWORK_TYPE_HSPA:
+ case TelephonyManager.NETWORK_TYPE_HSDPA:
+ case TelephonyManager.NETWORK_TYPE_HSUPA:
+ case TelephonyManager.NETWORK_TYPE_EVDO_A:
+ case TelephonyManager.NETWORK_TYPE_EVDO_B:
+ case TelephonyManager.NETWORK_TYPE_EHRPD:
+ return LINK_TYPE_3G; // 3.5G
+ case TelephonyManager.NETWORK_TYPE_HSPAP:
+ return LINK_TYPE_3G; // 3.75G
+ case TelephonyManager.NETWORK_TYPE_LTE:
+ return LINK_TYPE_4G; // 3.9G
+ case TelephonyManager.NETWORK_TYPE_UNKNOWN:
+ default:
+ Log.w(LOGTAG, "Connected to an unknown mobile network!");
+ return LINK_TYPE_UNKNOWN;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String getDNSDomains() {
+ if (Build.VERSION.SDK_INT < 23) {
+ return "";
+ }
+
+ ensureConnectivityManager();
+ Network net = sConnectivityManager.getActiveNetwork();
+ if (net == null) {
+ return "";
+ }
+
+ LinkProperties lp = sConnectivityManager.getLinkProperties(net);
+ if (lp == null) {
+ return "";
+ }
+
+ return lp.getDomains();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int[] getSystemColors() {
+ // attrsAppearance[] must correspond to AndroidSystemColors structure in android/AndroidBridge.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
+ };
+
+ int[] result = new int[attrsAppearance.length];
+
+ final ContextThemeWrapper contextThemeWrapper =
+ new ContextThemeWrapper(getApplicationContext(), android.R.style.TextAppearance);
+
+ final TypedArray appearance = contextThemeWrapper.getTheme().obtainStyledAttributes(attrsAppearance);
+
+ if (appearance != null) {
+ for (int i = 0; i < appearance.getIndexCount(); i++) {
+ int idx = appearance.getIndex(i);
+ int color = appearance.getColor(idx, 0);
+ result[idx] = color;
+ }
+ appearance.recycle();
+ }
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void killAnyZombies() {
+ GeckoProcessesVisitor visitor = new GeckoProcessesVisitor() {
+ @Override
+ public boolean callback(final int pid) {
+ if (pid != android.os.Process.myPid())
+ android.os.Process.killProcess(pid);
+ return true;
+ }
+ };
+
+ EnumerateGeckoProcesses(visitor);
+ }
+
+ interface GeckoProcessesVisitor {
+ boolean callback(int pid);
+ }
+
+ private static void EnumerateGeckoProcesses(final GeckoProcessesVisitor visiter) {
+ int pidColumn = -1;
+ int userColumn = -1;
+
+ Process ps = null;
+ InputStreamReader inputStreamReader = null;
+ BufferedReader in = null;
+ try {
+ // run ps and parse its output
+ ps = Runtime.getRuntime().exec("ps");
+ inputStreamReader = new InputStreamReader(ps.getInputStream());
+ in = new BufferedReader(inputStreamReader, 2048);
+
+ String headerOutput = in.readLine();
+
+ // figure out the column offsets. We only care about the pid and user fields
+ StringTokenizer st = new StringTokenizer(headerOutput);
+
+ int tokenSoFar = 0;
+ while (st.hasMoreTokens()) {
+ String next = st.nextToken();
+ if (next.equalsIgnoreCase("PID"))
+ pidColumn = tokenSoFar;
+ else if (next.equalsIgnoreCase("USER"))
+ userColumn = tokenSoFar;
+ tokenSoFar++;
+ }
+
+ // alright, the rest are process entries.
+ String psOutput = null;
+ while ((psOutput = in.readLine()) != null) {
+ String[] split = psOutput.split("\\s+");
+ if (split.length <= pidColumn || split.length <= userColumn)
+ continue;
+ int uid = android.os.Process.getUidForName(split[userColumn]);
+ if (uid == android.os.Process.myUid() &&
+ !split[split.length - 1].equalsIgnoreCase("ps")) {
+ int pid = Integer.parseInt(split[pidColumn]);
+ boolean keepGoing = visiter.callback(pid);
+ if (keepGoing == false)
+ break;
+ }
+ }
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Failed to enumerate Gecko processes.", e);
+ } finally {
+ IOUtils.safeStreamClose(in);
+ IOUtils.safeStreamClose(inputStreamReader);
+ if (ps != null) {
+ ps.destroy();
+ }
+ }
+ }
+
+ public static String getAppNameByPID(final int pid) {
+ BufferedReader cmdlineReader = null;
+ String path = "/proc/" + pid + "/cmdline";
+ try {
+ File cmdlineFile = new File(path);
+ if (!cmdlineFile.exists())
+ return "";
+ cmdlineReader = new BufferedReader(new FileReader(cmdlineFile));
+ return cmdlineReader.readLine().trim();
+ } catch (Exception ex) {
+ return "";
+ } finally {
+ IOUtils.safeStreamClose(cmdlineReader);
+ }
+ }
+
+ @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);
+ }
+
+ 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 = BitmapUtils.getBitmapFromDrawable(icon);
+ if (bitmap.getWidth() != resolvedIconSize || bitmap.getHeight() != resolvedIconSize) {
+ bitmap = Bitmap.createScaledBitmap(bitmap, resolvedIconSize, resolvedIconSize, true);
+ }
+
+ ByteBuffer buf = ByteBuffer.allocate(resolvedIconSize * resolvedIconSize * 4);
+ bitmap.copyPixelsToBuffer(buf);
+
+ return buf.array();
+ } catch (Exception e) {
+ Log.w(LOGTAG, "getIconForExtension failed.", e);
+ return null;
+ }
+ }
+
+ 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) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ final String mimeType = getMimeTypeFromExtension(aExt);
+ if (mimeType != null && mimeType.length() > 0)
+ intent.setType(mimeType);
+ else
+ return null;
+
+ List<ResolveInfo> list = pm.queryIntentActivities(intent, 0);
+ if (list.size() == 0)
+ return null;
+
+ ResolveInfo resolveInfo = list.get(0);
+
+ if (resolveInfo == null)
+ return null;
+
+ ActivityInfo activityInfo = resolveInfo.activityInfo;
+
+ return activityInfo.loadIcon(pm);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean getShowPasswordSetting() {
+ try {
+ int showPassword =
+ Settings.System.getInt(getApplicationContext().getContentResolver(),
+ Settings.System.TEXT_SHOW_PASSWORD, 1);
+ return (showPassword > 0);
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
+ private static Context sApplicationContext;
+
+ @WrapForJNI
+ public static Context getApplicationContext() {
+ return sApplicationContext;
+ }
+
+ public static void setApplicationContext(final Context context) {
+ sApplicationContext = context;
+ }
+
+ public interface GeckoInterface {
+ public boolean openUriExternal(String targetURI, String mimeType, String packageName, String className, String action, String title);
+
+ public String[] getHandlersForMimeType(String mimeType, String action);
+ public String[] getHandlersForURL(String url, String action);
+ };
+
+ private static GeckoInterface sGeckoInterface;
+
+ public static GeckoInterface getGeckoInterface() {
+ return sGeckoInterface;
+ }
+
+ public static void setGeckoInterface(final GeckoInterface aGeckoInterface) {
+ sGeckoInterface = aGeckoInterface;
+ }
+
+ /* package */ static Camera sCamera;
+
+ private static final int kPreferredFPS = 25;
+ private static byte[] sCameraBuffer;
+
+ private static class CameraCallback implements Camera.PreviewCallback {
+ @WrapForJNI(calledFrom = "gecko")
+ private static native void onFrameData(int camera, byte[] data);
+
+ private final int mCamera;
+
+ public CameraCallback(final int camera) {
+ mCamera = camera;
+ }
+
+ @Override
+ public void onPreviewFrame(final byte[] data, final Camera camera) {
+ onFrameData(mCamera, data);
+
+ if (sCamera != null) {
+ sCamera.addCallbackBuffer(sCameraBuffer);
+ }
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int[] initCamera(final String aContentType, final int aCamera, final int aWidth,
+ final int aHeight) {
+ // [0] = 0|1 (failure/success)
+ // [1] = width
+ // [2] = height
+ // [3] = fps
+ int[] result = new int[4];
+ result[0] = 0;
+
+ if (Camera.getNumberOfCameras() == 0) {
+ return result;
+ }
+
+ try {
+ sCamera = Camera.open(aCamera);
+
+ Camera.Parameters params = sCamera.getParameters();
+ params.setPreviewFormat(ImageFormat.NV21);
+
+ // use the preview fps closest to 25 fps.
+ int fpsDelta = 1000;
+ try {
+ Iterator<Integer> it = params.getSupportedPreviewFrameRates().iterator();
+ while (it.hasNext()) {
+ int nFps = it.next();
+ if (Math.abs(nFps - kPreferredFPS) < fpsDelta) {
+ fpsDelta = Math.abs(nFps - kPreferredFPS);
+ params.setPreviewFrameRate(nFps);
+ }
+ }
+ } catch (Exception e) {
+ params.setPreviewFrameRate(kPreferredFPS);
+ }
+
+ // set up the closest preview size available
+ Iterator<Camera.Size> sit = params.getSupportedPreviewSizes().iterator();
+ int sizeDelta = 10000000;
+ int bufferSize = 0;
+ while (sit.hasNext()) {
+ Camera.Size size = sit.next();
+ if (Math.abs(size.width * size.height - aWidth * aHeight) < sizeDelta) {
+ sizeDelta = Math.abs(size.width * size.height - aWidth * aHeight);
+ params.setPreviewSize(size.width, size.height);
+ bufferSize = size.width * size.height;
+ }
+ }
+
+ sCamera.setParameters(params);
+ sCameraBuffer = new byte[(bufferSize * 12) / 8];
+ sCamera.addCallbackBuffer(sCameraBuffer);
+ sCamera.setPreviewCallbackWithBuffer(new CameraCallback(aCamera));
+ sCamera.startPreview();
+ params = sCamera.getParameters();
+ result[0] = 1;
+ result[1] = params.getPreviewSize().width;
+ result[2] = params.getPreviewSize().height;
+ result[3] = params.getPreviewFrameRate();
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "initCamera RuntimeException.", e);
+ result[0] = result[1] = result[2] = result[3] = 0;
+ }
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized void closeCamera() {
+ if (sCamera != null) {
+ sCamera.stopPreview();
+ sCamera.release();
+ sCamera = null;
+ sCameraBuffer = null;
+ }
+ }
+
+ /*
+ * 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();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void hideProgressDialog() {
+ // unused stub
+ }
+
+ /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */
+ @WrapForJNI(calledFrom = "gecko")
+ @RobocopTarget
+ public static boolean isTablet() {
+ return HardwareUtils.isTablet();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static double[] getCurrentNetworkInformation() {
+ return GeckoNetworkManager.getInstance().getCurrentInformation();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableNetworkNotifications() {
+ ThreadUtils.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ 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;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getScreenAngle() {
+ return GeckoScreenOrientation.getInstance().getAngle();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableScreenOrientationNotifications() {
+ GeckoScreenOrientation.getInstance().enableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableScreenOrientationNotifications() {
+ GeckoScreenOrientation.getInstance().disableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void lockScreenOrientation(final int aOrientation) {
+ // TODO: don't vector through GeckoAppShell.
+ GeckoScreenOrientation.getInstance().lock(aOrientation);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void unlockScreenOrientation() {
+ // TODO: don't vector through GeckoAppShell.
+ GeckoScreenOrientation.getInstance().unlock();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void notifyWakeLockChanged(final String topic, final String state) {
+ final int intState;
+ if ("unlocked".equals(state)) {
+ intState = WakeLockDelegate.STATE_UNLOCKED;
+ } else if ("locked-foreground".equals(state)) {
+ intState = WakeLockDelegate.STATE_LOCKED_FOREGROUND;
+ } else if ("locked-background".equals(state)) {
+ intState = WakeLockDelegate.STATE_LOCKED_BACKGROUND;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ getWakeLockDelegate().setWakeLockState(topic, intState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean unlockProfile() {
+ // Try to kill any zombie Fennec's that might be running
+ GeckoAppShell.killAnyZombies();
+
+ // Then force unlock this profile
+ final GeckoProfile profile = GeckoThread.getActiveProfile();
+ if (profile != null) {
+ File lock = profile.getFile(".parentlock");
+ return lock != null && lock.exists() && lock.delete();
+ }
+ return false;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String getProxyForURI(final String spec, final String scheme, final String host,
+ final int port) {
+ final ProxySelector ps = new ProxySelector();
+
+ 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
+ private static InputStream createInputStream(final URLConnection connection)
+ throws IOException {
+ return connection.getInputStream();
+ }
+
+ private static class BitmapConnection extends URLConnection {
+ private Bitmap mBitmap;
+
+ BitmapConnection(final Bitmap b) throws MalformedURLException, IOException {
+ super(null);
+ mBitmap = b;
+ }
+
+ @Override
+ public void connect() {}
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ return new BitmapInputStream();
+ }
+
+ @Override
+ public String getContentType() {
+ return "image/png";
+ }
+
+ private final class BitmapInputStream extends PipedInputStream {
+ private boolean mHaveConnected = false;
+
+ @Override
+ public synchronized int read(final byte[] buffer, final int byteOffset,
+ final int byteCount) throws IOException {
+ if (mHaveConnected) {
+ return super.read(buffer, byteOffset, byteCount);
+ }
+
+ final PipedOutputStream output = new PipedOutputStream();
+ connect(output);
+
+ ThreadUtils.postToBackgroundThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ mBitmap.compress(Bitmap.CompressFormat.PNG, 100, output);
+ } finally {
+ IOUtils.safeStreamClose(output);
+ }
+ }
+ });
+ mHaveConnected = true;
+ return super.read(buffer, byteOffset, byteCount);
+ }
+ }
+ }
+
+ @WrapForJNI
+ private static URLConnection getConnection(final String url) {
+ try {
+ String spec;
+ if (url.startsWith("android://")) {
+ spec = url.substring(10);
+ } else {
+ spec = url.substring(8);
+ }
+
+ // Check if we are loading a package icon.
+ try {
+ if (spec.startsWith("icon/")) {
+ String[] splits = spec.split("/");
+ if (splits.length != 2) {
+ return null;
+ }
+ final String pkg = splits[1];
+ final PackageManager pm = getApplicationContext().getPackageManager();
+ final Drawable d = pm.getApplicationIcon(pkg);
+ final Bitmap bitmap = BitmapUtils.getBitmapFromDrawable(d);
+ return new BitmapConnection(bitmap);
+ }
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "error", ex);
+ }
+
+ // if the colon got stripped, put it back
+ int colon = spec.indexOf(':');
+ if (colon == -1 || colon > spec.indexOf('/')) {
+ spec = spec.replaceFirst("/", ":/");
+ }
+ } catch (Exception ex) {
+ return null;
+ }
+ return null;
+ }
+
+ @WrapForJNI
+ private static String connectionGetMimeType(final URLConnection connection) {
+ return connection.getContentType();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getMaxTouchPoints() {
+ 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
+ */
+ static private final int NO_POINTER = 0x00000000;
+ static private final int COARSE_POINTER = 0x00000001;
+ static private final int FINE_POINTER = 0x00000002;
+ static private final int HOVER_CAPABLE_POINTER = 0x00000004;
+ private static int getPointerCapabilities(final InputDevice inputDevice) {
+ int result = NO_POINTER;
+ int sources = inputDevice.getSources();
+
+ if (hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHSCREEN) ||
+ hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) {
+ result |= COARSE_POINTER;
+ } else if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE) ||
+ hasInputDeviceSource(sources, InputDevice.SOURCE_STYLUS) ||
+ hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD) ||
+ hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)) {
+ result |= FINE_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 (int deviceId : InputDevice.getDeviceIds()) {
+ InputDevice inputDevice = InputDevice.getDevice(deviceId);
+ if (inputDevice == null ||
+ !InputDeviceUtils.isPointerTypeDevice(inputDevice)) {
+ continue;
+ }
+
+ result |= getPointerCapabilities(inputDevice);
+ }
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ // For pointer and hover media queries features.
+ private static int getPrimaryPointerCapabilities() {
+ int result = NO_POINTER;
+
+ for (int deviceId : InputDevice.getDeviceIds()) {
+ InputDevice inputDevice = InputDevice.getDevice(deviceId);
+ if (inputDevice == null ||
+ !InputDeviceUtils.isPointerTypeDevice(inputDevice)) {
+ continue;
+ }
+
+ result = getPointerCapabilities(inputDevice);
+
+ // We need information only for the primary pointer.
+ // (Assumes that the primary pointer appears first in the list)
+ break;
+ }
+
+ 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;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized Rect getScreenSize() {
+ if (sScreenSizeOverride != null) {
+ return sScreenSizeOverride;
+ }
+ final WindowManager wm = (WindowManager)
+ getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Display disp = wm.getDefaultDisplay();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return new Rect(0, 0, disp.getWidth(), disp.getHeight());
+ }
+ Point size = new Point();
+ disp.getRealSize(size);
+ return new Rect(0, 0, size.x, size.y);
+ }
+
+ @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);
+ }
+
+ static private int sPreviousAudioMode = -2;
+
+ @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");
+ am.startBluetoothSco();
+ am.setBluetoothScoOn(true);
+ } else {
+ Log.e(LOGTAG, "Setting communication mode OFF");
+ am.stopBluetoothSco();
+ am.setBluetoothScoOn(false);
+ }
+ } catch (SecurityException 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();
+ String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ 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;
+ }
+
+ @WrapForJNI
+ public static boolean getIs24HourFormat() {
+ final Context context = getApplicationContext();
+ return DateFormat.is24HourFormat(context);
+ }
+
+ @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);
+ }
+}
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..a1fd58dde9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java
@@ -0,0 +1,202 @@
+/* -*- 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 final static double kDefaultLevel = 1.0;
+ private final static boolean kDefaultCharging = true;
+ private final static double kDefaultRemainingTime = 0.0;
+ private final static 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;
+ }
+
+ boolean previousCharging = isCharging();
+ 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")) {
+ 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.
+ double current = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+ 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.
+ long currentTime = SystemClock.elapsedRealtime();
+ long dt = (currentTime - sLastLevelChange) / 1000;
+ 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..11178d4532
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java
@@ -0,0 +1,329 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.graphics.RectF;
+import android.os.IBinder;
+import android.os.RemoteException;
+import androidx.annotation.Nullable;
+import android.util.Log;
+import android.view.KeyEvent;
+
+/**
+ * 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 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();
+
+ @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) throws RemoteException {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ Log.d(LOGTAG, "onSelectionChange(" + start + ", " + end + ")");
+ }
+ 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);
+ }
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore")
+ private void onTextChange(final CharSequence text, final int start,
+ final int unboundedOldEnd, final int unboundedNewEnd)
+ throws RemoteException {
+ if (DEBUG) {
+ ThreadUtils.assertOnGeckoThread();
+ Log.d(LOGTAG, "onTextChange(" + text + ", " + start + ", " +
+ unboundedOldEnd + ", " + unboundedNewEnd + ")");
+ }
+ 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.
+ mEditableParent.onTextChange(mEditableChild.asBinder(), text, start, unboundedOldEnd);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onDefaultKeyEvent(final KeyEvent event) {
+ if (DEBUG) {
+ // GeckoEditableListener methods should all be called from the Gecko thread
+ ThreadUtils.assertOnGeckoThread();
+ 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) {
+ 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);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java
new file mode 100644
index 0000000000..866ca4653a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.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;
+
+public class GeckoHalDefines {
+ /*
+ * 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;
+};
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..1421a335eb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java
@@ -0,0 +1,449 @@
+/* -*- 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.Looper;
+import android.os.SystemClock;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Queue;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// Bug 1618560: Currently we only profile the Android UI thread. Ideally we should
+// be able to profile multiple threads.
+public class GeckoJavaSampler {
+ private static final String LOGTAG = "GeckoJavaSampler";
+ private static SamplingRunnable sSamplingRunnable;
+ private static ScheduledExecutorService sSamplingScheduler;
+ private static ScheduledFuture<?> sSamplingFuture;
+ 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.
+ */
+ public static boolean isProfilerActive() {
+ // sSamplingRunnable is present if profiler is running and sSamplingFuture
+ // present if profiler is not paused.
+ return sSamplingRunnable != null && sSamplingFuture != 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();
+ }
+
+ private static class Sample {
+ public Frame[] mFrames;
+ public double mTime;
+ public long mJavaTime; // non-zero if Android system time is used
+ public Sample(final StackTraceElement[] aStack) {
+ mFrames = new Frame[aStack.length];
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ mTime = getProfilerTime();
+ }
+ if (mTime == 0.0d) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = SystemClock.elapsedRealtime();
+ }
+ for (int i = 0; i < aStack.length; i++) {
+ mFrames[aStack.length - 1 - i] = new Frame();
+ mFrames[aStack.length - 1 - i].methodName = aStack[i].getMethodName();
+ mFrames[aStack.length - 1 - i].className = aStack[i].getClassName();
+ }
+ }
+ }
+
+ private static class Frame {
+ public String methodName;
+ public String className;
+ }
+
+ private static class Marker extends JNIObject {
+ /**
+ * Name of the marker
+ */
+ private String mMarkerName;
+ /**
+ * Either start time for the duration markers or time for a point-in-time markers.
+ */
+ private 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 long mJavaTime;
+ /**
+ * End time for the duration markers.
+ * It's zero for point-in-time markers.
+ */
+ private 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 long mEndJavaTime;
+ /**
+ * A nullable additional information field for the marker.
+ */
+ private @Nullable 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:
+ *
+ * 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()}.
+ *
+ * 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()}.
+ *
+ * Last parameter is optional and can be given with any combination. This gives users the
+ * ability to add more context into a marker.
+ *
+ * @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(@NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ mMarkerName = aMarkerName;
+ mText = aText;
+ if (aStartTime != null) {
+ // Start time is provided. This is an interval marker.
+ mTime = aStartTime;
+ if (aEndTime != null) {
+ // End time is also provided.
+ mEndTime = aEndTime;
+ } else {
+ // End time is not provided. Get the profiler time now and use it.
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ mEndTime = getProfilerTime();
+ }
+ if (mEndTime == 0.0d) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mEndJavaTime = SystemClock.elapsedRealtime();
+ }
+ }
+ } else {
+ // Start time is not provided. This is point-in-time marker.
+ if (aEndTime != null) {
+ // End time is also provided. Use that to point the time.
+ mTime = aEndTime;
+ } else {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ mTime = getProfilerTime();
+ }
+ if (mTime == 0.0d) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = SystemClock.elapsedRealtime();
+ }
+ }
+ }
+ }
+
+ @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 @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(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);
+ }
+
+ private static class SamplingRunnable implements Runnable {
+ // Sampling interval that is used by start and unpause
+ public final int mInterval;
+ private final int mSampleCount;
+
+ private boolean mBufferOverflowed = false;
+
+ private Thread mMainThread;
+ private Sample[] mSamples;
+ private int mSamplePos;
+
+ public SamplingRunnable(final int aInterval, final int aSampleCount) {
+ // Sanity check of sampling interval.
+ mInterval = Math.max(1, aInterval);
+ mSampleCount = aSampleCount;
+ mSamples = new Sample[mSampleCount];
+ mSamplePos = 0;
+
+ // Find the main thread
+ mMainThread = Looper.getMainLooper().getThread();
+ if (mMainThread == null) {
+ Log.e(LOGTAG, "Main thread not found");
+ }
+ }
+
+ @Override
+ public void run() {
+ synchronized (GeckoJavaSampler.class) {
+ if (mMainThread == null) {
+ return;
+ }
+ final StackTraceElement[] bt = mMainThread.getStackTrace();
+ mSamples[mSamplePos] = new Sample(bt);
+ 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) {
+ 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];
+ }
+ }
+
+ private synchronized static Sample getSample(final int aSampleId) {
+ return sSamplingRunnable.getSample(aSampleId);
+ }
+
+ @WrapForJNI
+ public static Marker pollNextMarker() {
+ return sMarkerStorage.pollNextMarker();
+ }
+
+ @WrapForJNI
+ public synchronized static double getSampleTime(final int aSampleId) {
+ Sample sample = getSample(aSampleId);
+ if (sample != null) {
+ if (sample.mJavaTime != 0) {
+ return (sample.mJavaTime -
+ SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return sample.mTime;
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public synchronized static String getFrameName(final int aSampleId, final int aFrameId) {
+ Sample sample = getSample(aSampleId);
+ if (sample != null && aFrameId < sample.mFrames.length) {
+ Frame frame = sample.mFrames[aFrameId];
+ if (frame == null) {
+ return null;
+ }
+ return frame.className + "." + frame.methodName + "()";
+ }
+ return null;
+ }
+
+
+ private static class MarkerStorage {
+ private volatile Queue<Marker> mMarkers;
+
+ MarkerStorage() {}
+
+ public synchronized void start(final int aMarkerCount) {
+ if (this.mMarkers != null) {
+ return;
+ }
+ this.mMarkers = new LinkedBlockingQueue<>(aMarkerCount);
+ }
+
+ public synchronized void stop() {
+ if (this.mMarkers == null) {
+ return;
+ }
+ this.mMarkers = null;
+ }
+
+ private void addMarker(@NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ Queue<Marker> markersQueue = this.mMarkers;
+ if (markersQueue == null) {
+ // Profiler is not active.
+ return;
+ }
+
+ // It would be good to use `Looper.getMainLooper().isCurrentThread()`
+ // instead but it requires API level 23 and current min is 16.
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ // Bug 1618560: Currently only main thread is being profiled and
+ // this marker doesn't belong to the main thread.
+ throw new AssertionError("Currently only main thread is supported for markers.");
+ }
+
+ Marker newMarker = new Marker(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() {
+ 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(final int aInterval, final int aEntryCount) {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable != null) {
+ return;
+ }
+
+ if (sSamplingFuture != null && !sSamplingFuture.isDone()) {
+ return;
+ }
+
+ // 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.
+ int limitedEntryCount = Math.min(aEntryCount, 120000);
+ sSamplingRunnable = new SamplingRunnable(aInterval, limitedEntryCount);
+ sMarkerStorage.start(limitedEntryCount);
+ sSamplingScheduler = Executors.newSingleThreadScheduledExecutor();
+ sSamplingFuture = sSamplingScheduler.scheduleAtFixedRate(sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ @WrapForJNI
+ public static void pauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ sSamplingFuture.cancel(false /* mayInterruptIfRunning */ );
+ sSamplingFuture = null;
+ }
+ }
+
+ @WrapForJNI
+ public static void unpauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingFuture != null) {
+ return;
+ }
+ sSamplingFuture = sSamplingScheduler.scheduleAtFixedRate(sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS);
+ }
+ }
+
+ @WrapForJNI
+ public static void stop() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable == null) {
+ return;
+ }
+
+ try {
+ sSamplingScheduler.shutdown();
+ // 1s is enough to wait shutdown.
+ sSamplingScheduler.awaitTermination(1000, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Log.e(LOGTAG, "Sampling scheduler isn't terminated. Last sampling data might be broken.");
+ sSamplingScheduler.shutdownNow();
+ }
+ sSamplingScheduler = null;
+ sSamplingRunnable = null;
+ sSamplingFuture = null;
+ sMarkerStorage.stop();
+ }
+ }
+}
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..dcfd7bd3f0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java
@@ -0,0 +1,514 @@
+/* -*- 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 org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+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;
+
+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.WifiInfo;
+import android.net.wifi.WifiManager;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.telephony.TelephonyManager;
+import android.text.format.Formatter;
+import android.util.Log;
+
+/**
+ * Provides connection type, subtype and general network status (up/down).
+ *
+ * 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.
+ *
+ * Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets.
+ *
+ * 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 implements BundleEventListener {
+ 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 enum InfoType {
+ MCC,
+ MNC
+ }
+
+ private GeckoNetworkManager() {
+ EventDispatcher.getInstance().registerUiThreadListener(this,
+ "Wifi:Enable",
+ "Wifi:GetIPAddress");
+ }
+
+ private void onDestroy() {
+ handleManagerEvent(ManagerEvent.stop);
+ EventDispatcher.getInstance().unregisterUiThreadListener(this,
+ "Wifi:Enable",
+ "Wifi:GetIPAddress");
+ }
+
+ 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 {
+ WifiManager mgr = (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (mgr == null) {
+ return 0;
+ }
+
+ @SuppressLint("MissingPermission") DhcpInfo d = mgr.getDhcpInfo();
+ if (d == null) {
+ return 0;
+ }
+
+ return d.gateway;
+
+ } catch (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;
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @Override // BundleEventListener
+ /**
+ * Handles native messages, not part of the state machine flow.
+ */
+ public void handleMessage(final String event, final GeckoBundle message,
+ final EventCallback callback) {
+ final Context applicationContext = GeckoAppShell.getApplicationContext();
+ switch (event) {
+ case "Wifi:Enable":
+ final WifiManager mgr = (WifiManager)
+ applicationContext.getSystemService(Context.WIFI_SERVICE);
+ if (mgr == null) {
+ return;
+ }
+
+ if (!mgr.isWifiEnabled()) {
+ mgr.setWifiEnabled(true);
+ break;
+ }
+
+ // If Wifi is enabled, maybe you need to select a network
+ Intent intent = new Intent(android.provider.Settings.ACTION_WIFI_SETTINGS);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ applicationContext.startActivity(intent);
+ break;
+
+ case "Wifi:GetIPAddress":
+ getWifiIPAddress(callback);
+ break;
+ }
+ }
+
+ // This function only works for IPv4; not part of the state machine flow.
+ private void getWifiIPAddress(final EventCallback callback) {
+ final WifiManager mgr = (WifiManager) GeckoAppShell.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+
+ if (mgr == null) {
+ callback.sendError("Cannot get WifiManager");
+ return;
+ }
+
+ @SuppressLint("MissingPermission") final WifiInfo info = mgr.getConnectionInfo();
+ if (info == null) {
+ callback.sendError("Cannot get connection info");
+ return;
+ }
+
+ int ip = info.getIpAddress();
+ if (ip == 0) {
+ callback.sendError("Cannot get IPv4 address");
+ return;
+ }
+ callback.sendSuccess(Formatter.formatIpAddress(ip));
+ }
+
+ private static int getNetworkOperator(final InfoType type, final Context context) {
+ if (null == context) {
+ return -1;
+ }
+
+ TelephonyManager tel = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
+ if (tel == null) {
+ Log.e(LOGTAG, "Telephony service does not exist");
+ return -1;
+ }
+
+ String networkOperator = tel.getNetworkOperator();
+ if (networkOperator == null || networkOperator.length() <= 3) {
+ return -1;
+ }
+
+ if (type == InfoType.MNC) {
+ return Integer.parseInt(networkOperator.substring(3));
+ }
+
+ if (type == InfoType.MCC) {
+ return Integer.parseInt(networkOperator.substring(0, 3));
+ }
+
+ return -1;
+ }
+
+ /**
+ * These are called from JavaScript ctypes. Avoid letting ProGuard delete them.
+ *
+ * Note that these methods must only be called after GeckoAppShell has been
+ * initialized: they depend on access to the context.
+ *
+ * Not part of the state machine flow.
+ */
+ @JNITarget
+ public static int getMCC() {
+ return getNetworkOperator(InfoType.MCC, GeckoAppShell.getApplicationContext());
+ }
+
+ @JNITarget
+ public static int getMNC() {
+ return getNetworkOperator(InfoType.MNC, GeckoAppShell.getApplicationContext());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
new file mode 100644
index 0000000000..46b80f25d1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfile.java
@@ -0,0 +1,548 @@
+/* -*- 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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.WorkerThread;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoProfileDirectories.NoMozillaDirectoryException;
+import org.mozilla.gecko.GeckoProfileDirectories.NoSuchProfileException;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.INIParser;
+import org.mozilla.gecko.util.INISection;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class GeckoProfile {
+ private static final String LOGTAG = "GeckoProfile";
+
+ // The path in the profile to the file containing the client ID.
+ private static final String CLIENT_ID_FILE_PATH = "datareporting/state.json";
+ // In the client ID file, the attribute title in the JSON object containing the client ID value.
+ private static final String CLIENT_ID_JSON_ATTR = "clientID";
+ private static final String HAD_CANARY_CLIENT_ID_JSON_ATTR = "wasCanary";
+ // Must match the one from TelemetryUtils.jsm
+ private static final String CANARY_CLIENT_ID = "c0ffeec0-ffee-c0ff-eec0-ffeec0ffeec0";
+
+ private static final String TIMES_PATH = "times.json";
+
+ // Only tests should need to do this. We can remove this entirely once we
+ // fix Bug 1069687.
+ private static volatile boolean sAcceptDirectoryChanges = true;
+
+ public static final String DEFAULT_PROFILE = "default";
+ // Profile is using a custom directory outside of the Mozilla directory.
+ public static final String CUSTOM_PROFILE = "";
+
+ private static final ConcurrentHashMap<String, GeckoProfile> sProfileCache =
+ new ConcurrentHashMap<String, GeckoProfile>(
+ /* capacity */ 4, /* load factor */ 0.75f, /* concurrency */ 2);
+ private static String sDefaultProfileName;
+ private static String sIntentArgs;
+
+ private final String mName;
+ private final File mMozillaDir;
+
+ private Object mData;
+
+ /**
+ * Access to this member should be synchronized to avoid
+ * races during creation -- particularly between getDir and GeckoView#init.
+ *
+ * Not final because this is lazily computed.
+ */
+ private File mProfileDir;
+
+ public static GeckoProfile initFromArgs(final Context context, final String args) {
+ String profileName = null;
+ String profilePath = null;
+
+ if (args != null && args.contains("-P")) {
+ final Pattern p = Pattern.compile("(?:-P\\s*)(\\w*)(\\s*)");
+ final Matcher m = p.matcher(args);
+ if (m.find()) {
+ profileName = m.group(1);
+ }
+ }
+
+ if (args != null && args.contains("-profile")) {
+ final Pattern p = Pattern.compile("(?:-profile\\s*)(\\S*)(\\s*)");
+ final Matcher m = p.matcher(args);
+ if (m.find()) {
+ profilePath = m.group(1);
+ }
+ }
+
+ if (TextUtils.isEmpty(profileName) && profilePath == null) {
+ informIfCustomProfileIsUnavailable(profileName, false);
+ // Get the default profile for the Activity.
+ return getDefaultProfile(context);
+ }
+
+ return GeckoProfile.get(context, profileName, profilePath);
+ }
+
+ private static GeckoProfile getDefaultProfile(final Context context) {
+ try {
+ return get(context, getDefaultProfileName(context));
+
+ } catch (final NoMozillaDirectoryException e) {
+ // If this failed, we're screwed.
+ Log.wtf(LOGTAG, "Unable to get default profile name.", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ public static GeckoProfile get(final Context context, final String profileName) {
+ if (profileName != null) {
+ GeckoProfile profile = sProfileCache.get(profileName);
+ if (profile != null)
+ return profile;
+ }
+ return get(context, profileName, (File)null);
+ }
+
+ @RobocopTarget
+ public static GeckoProfile get(final Context context, final String profileName,
+ final String profilePath) {
+ File dir = null;
+ if (!TextUtils.isEmpty(profilePath)) {
+ dir = new File(profilePath);
+ if (!dir.exists() || !dir.isDirectory()) {
+ Log.w(LOGTAG, "requested profile directory missing: " + profilePath);
+ }
+ }
+ return get(context, profileName, dir);
+ }
+
+ // Note that the profile cache respects only the profile name!
+ // If the directory changes, the returned GeckoProfile instance will be mutated.
+ @RobocopTarget
+ public static GeckoProfile get(final Context context, final String profileName,
+ final File profileDir) {
+ if (context == null) {
+ throw new IllegalArgumentException("context must be non-null");
+ }
+
+ // Null name? | Null dir? | Returned profile
+ // ------------------------------------------
+ // Yes | Yes | Active profile or default profile.
+ // No | Yes | Profile with specified name at default dir.
+ // Yes | No | Custom (anonymous) profile with specified dir.
+ // No | No | Profile with specified name at specified dir.
+ //
+ // Empty name?| Null dir? | Returned profile
+ // ------------------------------------------
+ // Yes | Yes | Active profile or default profile
+
+ String resolvedProfileName = profileName;
+ if (TextUtils.isEmpty(profileName) && profileDir == null) {
+ // If no profile info was passed in, look for the active profile or a default profile.
+ final GeckoProfile profile = GeckoThread.getActiveProfile();
+ if (profile != null) {
+ informIfCustomProfileIsUnavailable(profileName, true);
+ return profile;
+ }
+
+ informIfCustomProfileIsUnavailable(profileName, false);
+ return GeckoProfile.initFromArgs(context, sIntentArgs);
+ } else if (profileName == null) {
+ // If only profile dir was passed in, use custom (anonymous) profile.
+ resolvedProfileName = CUSTOM_PROFILE;
+ }
+
+ // We require the profile dir to exist if specified, so create it here if needed.
+ final boolean init = profileDir != null && profileDir.mkdirs();
+ if (init) {
+ Log.d(LOGTAG, "Creating profile directory: " + profileDir);
+ }
+
+ // Actually try to look up the profile.
+ GeckoProfile profile = sProfileCache.get(resolvedProfileName);
+ GeckoProfile newProfile = null;
+
+ if (profile == null) {
+ try {
+ Log.d(LOGTAG, "Loading profile at: " + profileDir + " name: " + resolvedProfileName);
+ newProfile = new GeckoProfile(context, resolvedProfileName, profileDir);
+ } catch (NoMozillaDirectoryException e) {
+ // We're unable to do anything sane here.
+ throw new RuntimeException(e);
+ }
+
+ profile = sProfileCache.putIfAbsent(resolvedProfileName, newProfile);
+ }
+
+ if (profile == null) {
+ profile = newProfile;
+
+ } else if (profileDir != null) {
+ // We have an existing profile but was given an alternate directory.
+ boolean consistent = false;
+ try {
+ consistent = profile.mProfileDir != null &&
+ profile.mProfileDir.getCanonicalPath().equals(profileDir.getCanonicalPath());
+ } catch (final IOException e) {
+ }
+
+ if (!consistent) {
+ if (!sAcceptDirectoryChanges || !profileDir.isDirectory()) {
+ throw new IllegalStateException(
+ "Refusing to reuse profile with a different directory.");
+ }
+ profile.setDir(profileDir);
+ }
+ }
+
+ if (init) {
+ // Initialize the profile directory if we had to create it.
+ profile.enqueueInitialization(profileDir);
+ }
+
+ return profile;
+ }
+
+ /**
+ * Custom profiles are an edge use case (must be passed in via Intent arguments)<br>
+ * Will inform users if the received arguments are invalid and the app fallbacks to use
+ * the currently active or the default Gecko profile.<br>
+ * Only to be called if other conditions than the profile name are already checked.
+ *
+ * @see <a href="http://google.com">Reasoning behind custom profiles</a>
+ *
+ * @param profileName intended profile name. Will be checked against {{@link #CUSTOM_PROFILE}}
+ * to decide if we should inform or not about using the fallback profile.
+ * @param activeOrDefaultProfileFallback true - will fallback to use the currently active Gecko profile
+ * false - will fallback to use the default Gecko profile
+ */
+ private static void informIfCustomProfileIsUnavailable(
+ final String profileName, final boolean activeOrDefaultProfileFallback) {
+ if (CUSTOM_PROFILE.equals(profileName)) {
+ final String fallbackProfileName = activeOrDefaultProfileFallback ? "active" : "default";
+ Log.w(LOGTAG, String.format("Custom profile must have a directory specified! " +
+ "Reverting to use the %s profile", fallbackProfileName));
+ }
+ }
+
+ private GeckoProfile(final Context context, final String profileName, final File profileDir)
+ throws NoMozillaDirectoryException {
+ if (profileName == null) {
+ throw new IllegalArgumentException("Unable to create GeckoProfile for empty profile name.");
+ }
+
+ mName = profileName;
+ mMozillaDir = GeckoProfileDirectories.getMozillaDirectory(context);
+
+ mProfileDir = profileDir;
+ if (profileDir != null) {
+ if (!profileDir.isDirectory()) {
+ throw new IllegalArgumentException("Profile directory must exist if specified: " +
+ profileDir.getPath());
+ }
+
+ // Ensure that we can write to the profile directory.
+ //
+ // We would use `writeFile`, but that function just logs exceptions; we need them to
+ // provide useful feedback.
+ FileWriter fileWriter = null;
+ try {
+ fileWriter = new FileWriter(new File(profileDir, ".can-write-sentinel"), false);
+ fileWriter.write(0);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Profile directory must be writable if specified: " +
+ profileDir.getPath(), e);
+ } finally {
+ try {
+ if (fileWriter != null) {
+ fileWriter.close();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error closing .can-write-sentinel; ignoring", e);
+ }
+ }
+ }
+ }
+
+ private void setDir(final File dir) {
+ if (dir != null && dir.exists() && dir.isDirectory()) {
+ synchronized (this) {
+ mProfileDir = dir;
+ }
+ }
+ }
+
+ @RobocopTarget
+ public String getName() {
+ return mName;
+ }
+
+ public boolean isCustomProfile() {
+ return CUSTOM_PROFILE.equals(mName);
+ }
+
+ /**
+ * Return an Object that can be used with a synchronized statement to allow
+ * exclusive access to the profile.
+ */
+ public Object getLock() {
+ return this;
+ }
+
+ /**
+ * Retrieves the directory backing the profile. This method acts
+ * as a lazy initializer for the GeckoProfile instance.
+ */
+ @RobocopTarget
+ public synchronized File getDir() {
+ forceCreateLocked();
+ return mProfileDir;
+ }
+
+ /**
+ * Forces profile creation. Consider using {@link #getDir()} to initialize the profile instead - it is the
+ * lazy initializer and, for our code reasoning abilities, we should initialize the profile in one place.
+ */
+ private void forceCreateLocked() {
+ if (mProfileDir != null) {
+ return;
+ }
+
+ try {
+ // Check if a profile with this name already exists.
+ try {
+ mProfileDir = findProfileDir();
+ Log.d(LOGTAG, "Found profile dir: " + mProfileDir);
+ } catch (NoSuchProfileException noSuchProfile) {
+ // If it doesn't exist, create it.
+ mProfileDir = createProfileDir();
+ Log.d(LOGTAG, "Creating profile dir: " + mProfileDir);
+ }
+ } catch (IOException ioe) {
+ Log.e(LOGTAG, "Error getting profile dir", ioe);
+ }
+ }
+
+ public File getFile(final String aFile) {
+ File f = getDir();
+ if (f == null)
+ return null;
+
+ return new File(f, aFile);
+ }
+
+ protected static String generateNewClientId() {
+ return UUID.randomUUID().toString();
+ }
+
+ /**
+ * Persists the given client ID to disk. This will overwrite any existing files.
+ */
+ @WorkerThread
+ private void persistNewClientId(@Nullable final String oldClientId,
+ @NonNull final String newClientId) throws IOException {
+ if (!ensureParentDirs(CLIENT_ID_FILE_PATH)) {
+ throw new IOException("Could not create client ID parent directories");
+ }
+
+ final JSONObject obj = new JSONObject();
+ try {
+ obj.put(CLIENT_ID_JSON_ATTR, newClientId);
+ obj.put(HAD_CANARY_CLIENT_ID_JSON_ATTR, isCanaryClientId(oldClientId));
+ } catch (final JSONException e) {
+ throw new IOException("Could not create client ID JSON object", e);
+ }
+
+ // ClientID.jsm overwrites the file to store the client ID so it's okay if we do it too.
+ Log.d(LOGTAG, "Attempting to write new client ID properties");
+ writeFile(CLIENT_ID_FILE_PATH, obj.toString()); // Logs errors within function: ideally we'd throw.
+ }
+
+ private static boolean isCanaryClientId(@Nullable final String clientId) {
+ return CANARY_CLIENT_ID.equals(clientId);
+ }
+
+ /**
+ * Ensures the parent director(y|ies) of the given filename exist by making them
+ * if they don't already exist..
+ *
+ * @param filename The path to the file whose parents should be made directories
+ * @return true if the parent directory exists, false otherwise
+ */
+ @WorkerThread
+ protected boolean ensureParentDirs(final String filename) {
+ final File file = new File(getDir(), filename);
+ final File parentFile = file.getParentFile();
+ return parentFile.mkdirs() || parentFile.isDirectory();
+ }
+
+ public void writeFile(final String filename, final String data) {
+ File file = new File(getDir(), filename);
+ BufferedWriter bufferedWriter = null;
+ try {
+ bufferedWriter = new BufferedWriter(new FileWriter(file, false));
+ bufferedWriter.write(data);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Unable to write to file", e);
+ } finally {
+ try {
+ if (bufferedWriter != null) {
+ bufferedWriter.close();
+ }
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error closing writer while writing to file", e);
+ }
+ }
+ }
+
+ /**
+ * @return the default profile name for this application, or
+ * {@link GeckoProfile#DEFAULT_PROFILE} if none could be found.
+ *
+ * @throws NoMozillaDirectoryException
+ * if the Mozilla directory did not exist and could not be
+ * created.
+ */
+ public static String getDefaultProfileName(final Context context) throws NoMozillaDirectoryException {
+ // Have we read the default profile from the INI already?
+ // Changing the default profile requires a restart, so we don't
+ // need to worry about runtime changes.
+ if (sDefaultProfileName != null) {
+ return sDefaultProfileName;
+ }
+
+ final String profileName = GeckoProfileDirectories.findDefaultProfileName(context);
+ if (profileName == null) {
+ // Note that we don't persist this back to profiles.ini.
+ sDefaultProfileName = DEFAULT_PROFILE;
+ return DEFAULT_PROFILE;
+ }
+
+ sDefaultProfileName = profileName;
+ return sDefaultProfileName;
+ }
+
+ private File findProfileDir() throws NoSuchProfileException {
+ if (isCustomProfile()) {
+ return mProfileDir;
+ }
+ return GeckoProfileDirectories.findProfileDir(mMozillaDir, mName);
+ }
+
+ @WorkerThread
+ private File createProfileDir() throws IOException {
+ if (isCustomProfile()) {
+ // Custom profiles must already exist.
+ return mProfileDir;
+ }
+
+ INIParser parser = GeckoProfileDirectories.getProfilesINI(mMozillaDir);
+
+ // Salt the name of our requested profile
+ String saltedName;
+ File profileDir;
+ do {
+ saltedName = GeckoProfileDirectories.saltProfileName(mName);
+ profileDir = new File(mMozillaDir, saltedName);
+ } while (profileDir.exists());
+
+ // Attempt to create the salted profile dir
+ if (!profileDir.mkdirs()) {
+ throw new IOException("Unable to create profile.");
+ }
+ Log.d(LOGTAG, "Created new profile dir.");
+
+ // Now update profiles.ini
+ // If this is the first time its created, we also add a General section
+ // look for the first profile number that isn't taken yet
+ int profileNum = 0;
+ boolean isDefaultSet = false;
+ INISection profileSection;
+ while ((profileSection = parser.getSection("Profile" + profileNum)) != null) {
+ profileNum++;
+ if (profileSection.getProperty("Default") != null) {
+ isDefaultSet = true;
+ }
+ }
+
+ profileSection = new INISection("Profile" + profileNum);
+ profileSection.setProperty("Name", mName);
+ profileSection.setProperty("IsRelative", 1);
+ profileSection.setProperty("Path", saltedName);
+
+ if (parser.getSection("General") == null) {
+ INISection generalSection = new INISection("General");
+ generalSection.setProperty("StartWithLastProfile", 1);
+ parser.addSection(generalSection);
+ }
+
+ if (!isDefaultSet) {
+ // only set as default if this is the first profile we're creating
+ profileSection.setProperty("Default", 1);
+ }
+
+ parser.addSection(profileSection);
+ parser.write();
+
+ enqueueInitialization(profileDir);
+
+ // Write out profile creation time, mirroring the logic in nsToolkitProfileService.
+ try {
+ FileOutputStream stream = new FileOutputStream(profileDir.getAbsolutePath() + File.separator + TIMES_PATH);
+ OutputStreamWriter writer = new OutputStreamWriter(stream, Charset.forName("UTF-8"));
+ try {
+ writer.append("{\"created\": " + System.currentTimeMillis() + "}\n");
+ } finally {
+ writer.close();
+ }
+ } catch (Exception e) {
+ // Best-effort.
+ Log.w(LOGTAG, "Couldn't write " + TIMES_PATH, e);
+ }
+
+ // Create the client ID file before Gecko starts (we assume this method
+ // is called before Gecko starts). If we let Gecko start, the JS telemetry
+ // code may try to write to the file at the same time Java does.
+ persistNewClientId(null, generateNewClientId());
+
+ return profileDir;
+ }
+
+ /**
+ * This method is called once, immediately before creation of the profile
+ * directory completes.
+ *
+ * It queues up work to be done in the background to prepare the profile,
+ * such as adding default bookmarks.
+ *
+ * This is public for use *from tests only*!
+ */
+ @RobocopTarget
+ public void enqueueInitialization(final File profileDir) {
+ Log.i(LOGTAG, "Enqueuing profile init.");
+
+ final GeckoBundle message = new GeckoBundle(2);
+ message.putString("name", getName());
+ message.putString("path", profileDir.getAbsolutePath());
+ EventDispatcher.getInstance().dispatch("Profile:Create", message);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java
new file mode 100644
index 0000000000..026eac76f3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.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;
+
+import java.io.File;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.INIParser;
+import org.mozilla.gecko.util.INISection;
+
+import android.content.Context;
+
+/**
+ * <code>GeckoProfileDirectories</code> manages access to mappings from profile
+ * names to salted profile directory paths, as well as the default profile name.
+ *
+ * This class will eventually come to encapsulate the remaining logic embedded
+ * in profiles.ini; for now it's a read-only wrapper.
+ */
+public class GeckoProfileDirectories {
+ @SuppressWarnings("serial")
+ public static class NoMozillaDirectoryException extends Exception {
+ public NoMozillaDirectoryException(final Throwable cause) {
+ super(cause);
+ }
+
+ public NoMozillaDirectoryException(final String reason) {
+ super(reason);
+ }
+
+ public NoMozillaDirectoryException(final String reason, final Throwable cause) {
+ super(reason, cause);
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class NoSuchProfileException extends Exception {
+ public NoSuchProfileException(final String detailMessage, final Throwable cause) {
+ super(detailMessage, cause);
+ }
+
+ public NoSuchProfileException(final String detailMessage) {
+ super(detailMessage);
+ }
+ }
+
+ private interface INISectionPredicate {
+ public boolean matches(INISection section);
+ }
+
+ private static final String MOZILLA_DIR_NAME = "mozilla";
+
+ /**
+ * Returns true if the supplied profile entry represents the default profile.
+ */
+ private static final INISectionPredicate sectionIsDefault = new INISectionPredicate() {
+ @Override
+ public boolean matches(final INISection section) {
+ return section.getIntProperty("Default") == 1;
+ }
+ };
+
+ /**
+ * Returns true if the supplied profile entry has a 'Name' field.
+ */
+ private static final INISectionPredicate sectionHasName = new INISectionPredicate() {
+ @Override
+ public boolean matches(final INISection section) {
+ final String name = section.getStringProperty("Name");
+ return name != null;
+ }
+ };
+
+ @RobocopTarget
+ public static INIParser getProfilesINI(final File mozillaDir) {
+ return new INIParser(new File(mozillaDir, "profiles.ini"));
+ }
+
+ /**
+ * Utility method to compute a salted profile name: eight random alphanumeric
+ * characters, followed by a period, followed by the profile name.
+ */
+ public static String saltProfileName(final String name) {
+ if (name == null) {
+ throw new IllegalArgumentException("Cannot salt null profile name.");
+ }
+
+ final String allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789";
+ final int scale = allowedChars.length();
+ final int saltSize = 8;
+
+ final StringBuilder saltBuilder = new StringBuilder(saltSize + 1 + name.length());
+ for (int i = 0; i < saltSize; i++) {
+ saltBuilder.append(allowedChars.charAt((int)(Math.random() * scale)));
+ }
+ saltBuilder.append('.');
+ saltBuilder.append(name);
+ return saltBuilder.toString();
+ }
+
+ /**
+ * Return the Mozilla directory within the files directory of the provided
+ * context. This should always be the same within a running application.
+ *
+ * This method is package-scoped so that new {@link GeckoProfile} instances can
+ * contextualize themselves.
+ *
+ * @return a new File object for the Mozilla directory.
+ * @throws NoMozillaDirectoryException
+ * if the directory did not exist and could not be created.
+ */
+ @RobocopTarget
+ public static File getMozillaDirectory(final Context context)
+ throws NoMozillaDirectoryException {
+ final File mozillaDir = new File(context.getFilesDir(), MOZILLA_DIR_NAME);
+ if (mozillaDir.mkdirs() || mozillaDir.isDirectory()) {
+ return mozillaDir;
+ }
+
+ // Although this leaks a path to the system log, the path is
+ // predictable (unlike a profile directory), so this is fine.
+ throw new NoMozillaDirectoryException("Unable to create mozilla directory at " + mozillaDir.getAbsolutePath());
+ }
+
+ /**
+ * Discover the default profile name by examining profiles.ini.
+ *
+ * Package-scoped because {@link GeckoProfile} needs access to it.
+ *
+ * @return null if there is no "Default" entry in profiles.ini, or the profile
+ * name if there is.
+ * @throws NoMozillaDirectoryException
+ * if the Mozilla directory did not exist and could not be created.
+ */
+ static String findDefaultProfileName(final Context context) throws NoMozillaDirectoryException {
+ final INIParser parser = GeckoProfileDirectories.getProfilesINI(getMozillaDirectory(context));
+ if (parser.getSections() != null) {
+ for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) {
+ final INISection section = e.nextElement();
+ if (section.getIntProperty("Default") == 1) {
+ return section.getStringProperty("Name");
+ }
+ }
+ }
+ return null;
+ }
+
+ static Map<String, String> getDefaultProfile(final File mozillaDir) {
+ return getMatchingProfiles(mozillaDir, sectionIsDefault, true);
+ }
+
+ static Map<String, String> getProfilesNamed(final File mozillaDir, final String name) {
+ final INISectionPredicate predicate = new INISectionPredicate() {
+ @Override
+ public boolean matches(final INISection section) {
+ return name.equals(section.getStringProperty("Name"));
+ }
+ };
+ return getMatchingProfiles(mozillaDir, predicate, true);
+ }
+
+ /**
+ * Calls {@link GeckoProfileDirectories#getMatchingProfiles(File, INISectionPredicate, boolean)}
+ * with a filter to ensure that all profiles are named.
+ */
+ static Map<String, String> getAllProfiles(final File mozillaDir) {
+ return getMatchingProfiles(mozillaDir, sectionHasName, false);
+ }
+
+ /**
+ * Return a mapping from the names of all matching profiles (that is,
+ * profiles appearing in profiles.ini that match the supplied predicate) to
+ * their absolute paths on disk.
+ *
+ * @param mozillaDir
+ * a directory containing profiles.ini.
+ * @param predicate
+ * a predicate to use when evaluating whether to include a
+ * particular INI section.
+ * @param stopOnSuccess
+ * if true, this method will return with the first result that
+ * matches the predicate; if false, all matching results are
+ * included.
+ * @return a {@link Map} from name to path.
+ */
+ public static Map<String, String> getMatchingProfiles(final File mozillaDir,
+ final INISectionPredicate predicate, final boolean stopOnSuccess) {
+ final HashMap<String, String> result = new HashMap<String, String>();
+ final INIParser parser = GeckoProfileDirectories.getProfilesINI(mozillaDir);
+
+ if (parser.getSections() != null) {
+ for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) {
+ final INISection section = e.nextElement();
+ if (predicate == null || predicate.matches(section)) {
+ final String name = section.getStringProperty("Name");
+ final String pathString = section.getStringProperty("Path");
+ final boolean isRelative = section.getIntProperty("IsRelative") == 1;
+ final File path = isRelative ? new File(mozillaDir, pathString) : new File(pathString);
+ result.put(name, path.getAbsolutePath());
+
+ if (stopOnSuccess) {
+ return result;
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ public static File findProfileDir(final File mozillaDir, final String profileName) throws NoSuchProfileException {
+ // Open profiles.ini to find the correct path.
+ final INIParser parser = GeckoProfileDirectories.getProfilesINI(mozillaDir);
+ if (parser.getSections() != null) {
+ for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) {
+ final INISection section = e.nextElement();
+ final String name = section.getStringProperty("Name");
+ if (name != null && name.equals(profileName)) {
+ if (section.getIntProperty("IsRelative") == 1) {
+ return new File(mozillaDir, section.getStringProperty("Path"));
+ }
+ return new File(section.getStringProperty("Path"));
+ }
+ }
+ }
+ throw new NoSuchProfileException("No profile " + profileName);
+ }
+}
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..304676b5f3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java
@@ -0,0 +1,450 @@
+/* -*- 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.pm.ActivityInfo;
+import android.content.res.Configuration;
+import android.util.Log;
+import android.view.Surface;
+import android.view.WindowManager;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/*
+ * 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),
+ DEFAULT(1 << 4);
+
+ public final short value;
+
+ private ScreenOrientation(final int value) {
+ this.value = (short)value;
+ }
+
+ private final static ScreenOrientation[] sValues = ScreenOrientation.values();
+
+ public static ScreenOrientation get(final int value) {
+ for (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;
+ // Whether the update should notify Gecko about screen orientation changes.
+ private boolean mShouldNotify = true;
+
+ 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);
+ }
+
+ /*
+ * Enable Gecko screen orientation events on update.
+ */
+ public void enableNotifications() {
+ update();
+ mShouldNotify = true;
+ }
+
+ /*
+ * Disable Gecko screen orientation events on update.
+ */
+ public void disableNotifications() {
+ mShouldNotify = false;
+ }
+
+ /*
+ * Update screen orientation.
+ * Retrieve orientation and rotation via GeckoAppShell.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update() {
+ final Context appContext = GeckoAppShell.getApplicationContext();
+ if (appContext == null) {
+ return false;
+ }
+ Configuration config = appContext.getResources().getConfiguration();
+ return update(config.orientation);
+ }
+
+ /*
+ * 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()));
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void onOrientationChange(short screenOrientation, short angle);
+
+ /*
+ * 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.
+ 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);
+ if (mShouldNotify) {
+ if (aScreenOrientation == ScreenOrientation.NONE) {
+ return false;
+ }
+
+ if (GeckoThread.isRunning()) {
+ onOrientationChange(screenOrientation.value, getAngle());
+ } else {
+ GeckoThread.queueNativeCall(GeckoScreenOrientation.class, "onOrientationChange",
+ screenOrientation.value, getAngle());
+ }
+ }
+ ScreenManagerHelper.refreshScreenInfo();
+ return true;
+ }
+
+ private void notifyListeners(final ScreenOrientation newOrientation) {
+ final Runnable notifier = new Runnable() {
+ @Override
+ public void run() {
+ for (OrientationChangeListener listener : mListeners) {
+ listener.onScreenOrientationChanged(newOrientation);
+ }
+ }
+ };
+
+ if (ThreadUtils.isOnUiThread()) {
+ notifier.run();
+ } else {
+ ThreadUtils.runOnUiThread(notifier);
+ }
+ }
+
+ /*
+ * @return The Android orientation (Configuration.orientation).
+ */
+ public int getAndroidOrientation() {
+ return screenOrientationToAndroidOrientation(getScreenOrientation());
+ }
+
+ /*
+ * @return The Gecko screen orientation derived from Android orientation and
+ * rotation.
+ */
+ public ScreenOrientation getScreenOrientation() {
+ return mScreenOrientation;
+ }
+
+ /**
+ * Lock screen orientation given the Gecko screen orientation.
+ *
+ * @param aGeckoOrientation
+ * The Gecko orientation provided.
+ */
+ public void lock(final int aGeckoOrientation) {
+ lock(ScreenOrientation.get(aGeckoOrientation));
+ }
+
+ /**
+ * Lock screen orientation given the Gecko screen orientation.
+ *
+ * @param aScreenOrientation
+ * Gecko screen orientation derived from Android orientation and
+ * rotation.
+ *
+ * @return Whether the locking was successful.
+ */
+ public boolean lock(final ScreenOrientation aScreenOrientation) {
+ Log.d(LOGTAG, "locking to " + aScreenOrientation);
+ final ScreenOrientationDelegate delegate = GeckoAppShell.getScreenOrientationDelegate();
+ final int activityInfoOrientation = screenOrientationToActivityInfoOrientation(aScreenOrientation);
+ synchronized (this) {
+ if (delegate.setRequestedOrientationForCurrentActivity(activityInfoOrientation)) {
+ update(aScreenOrientation);
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Unlock and update screen orientation.
+ *
+ * @return Whether the unlocking was successful.
+ */
+ public boolean unlock() {
+ Log.d(LOGTAG, "unlocking");
+ final ScreenOrientationDelegate delegate = GeckoAppShell.getScreenOrientationDelegate();
+ final int activityInfoOrientation = screenOrientationToActivityInfoOrientation(ScreenOrientation.DEFAULT);
+ synchronized (this) {
+ if (delegate.setRequestedOrientationForCurrentActivity(activityInfoOrientation)) {
+ update();
+ return true;
+ } else {
+ return false;
+ }
+ }
+ }
+
+ /*
+ * 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) {
+ boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90;
+ if (aAndroidOrientation == Configuration.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 == Configuration.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;
+ }
+
+ /*
+ * @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() {
+ final Context appContext = GeckoAppShell.getApplicationContext();
+ if (appContext == null) {
+ return DEFAULT_ROTATION;
+ }
+ final WindowManager windowManager =
+ (WindowManager) appContext.getSystemService(Context.WINDOW_SERVICE);
+ return windowManager.getDefaultDisplay().getRotation();
+ }
+
+ /*
+ * Retrieve the screen orientation from an array string.
+ *
+ * @param aArray
+ * String containing comma-delimited strings.
+ *
+ * @return First parsed Gecko screen orientation.
+ */
+ public static ScreenOrientation screenOrientationFromArrayString(final String aArray) {
+ List<String> orientations = Arrays.asList(aArray.split(","));
+ if ("".equals(aArray) || orientations.size() == 0) {
+ // If nothing is listed, return default.
+ Log.w(LOGTAG, "screenOrientationFromArrayString: no orientation in string");
+ return ScreenOrientation.DEFAULT;
+ }
+
+ // We don't support multiple orientations yet. To avoid developer
+ // confusion, just take the first one listed.
+ return screenOrientationFromString(orientations.get(0));
+ }
+
+ /*
+ * Retrieve the screen orientation from a string.
+ *
+ * @param aStr
+ * String hopefully containing a screen orientation name.
+ * @return Gecko screen orientation if matched, DEFAULT_SCREEN_ORIENTATION
+ * otherwise.
+ */
+ public static ScreenOrientation screenOrientationFromString(final String aStr) {
+ switch (aStr) {
+ case "portrait":
+ return ScreenOrientation.PORTRAIT;
+ case "landscape":
+ return ScreenOrientation.LANDSCAPE;
+ case "portrait-primary":
+ return ScreenOrientation.PORTRAIT_PRIMARY;
+ case "portrait-secondary":
+ return ScreenOrientation.PORTRAIT_SECONDARY;
+ case "landscape-primary":
+ return ScreenOrientation.LANDSCAPE_PRIMARY;
+ case "landscape-secondary":
+ return ScreenOrientation.LANDSCAPE_SECONDARY;
+ }
+
+ Log.w(LOGTAG, "screenOrientationFromString: unknown orientation string: " + aStr);
+ return ScreenOrientation.DEFAULT;
+ }
+
+ /*
+ * Convert Gecko screen orientation to Android orientation.
+ *
+ * @param aScreenOrientation
+ * Gecko screen orientation.
+ * @return Android orientation. This conversion is lossy, the Android
+ * orientation does not differentiate between primary and secondary
+ * orientations.
+ */
+ public static int screenOrientationToAndroidOrientation(
+ final ScreenOrientation aScreenOrientation) {
+ switch (aScreenOrientation) {
+ case PORTRAIT:
+ case PORTRAIT_PRIMARY:
+ case PORTRAIT_SECONDARY:
+ return Configuration.ORIENTATION_PORTRAIT;
+ case LANDSCAPE:
+ case LANDSCAPE_PRIMARY:
+ case LANDSCAPE_SECONDARY:
+ return Configuration.ORIENTATION_LANDSCAPE;
+ case NONE:
+ case DEFAULT:
+ default:
+ return Configuration.ORIENTATION_UNDEFINED;
+ }
+ }
+
+ /*
+ * Convert Gecko screen orientation to Android ActivityInfo orientation.
+ * This is yet another orientation used by Android, but it's more detailed
+ * than the Android orientation.
+ * It is required for screen orientation locking and unlocking.
+ *
+ * @param aScreenOrientation
+ * Gecko screen orientation.
+ * @return Android ActivityInfo orientation.
+ */
+ public static int screenOrientationToActivityInfoOrientation(
+ final ScreenOrientation aScreenOrientation) {
+ switch (aScreenOrientation) {
+ case PORTRAIT:
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ case PORTRAIT_PRIMARY:
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ case PORTRAIT_SECONDARY:
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ case LANDSCAPE:
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ case LANDSCAPE_PRIMARY:
+ return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ case LANDSCAPE_SECONDARY:
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ case DEFAULT:
+ case NONE:
+ return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+ default:
+ return ActivityInfo.SCREEN_ORIENTATION_NOSENSOR;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java
new file mode 100644
index 0000000000..ade2a89526
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java
@@ -0,0 +1,308 @@
+/* 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.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.StrictModeContext;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.preference.PreferenceManager;
+import android.util.Log;
+
+/**
+ * {@code GeckoSharedPrefs} provides scoped SharedPreferences instances.
+ * You should use this API instead of using Context.getSharedPreferences()
+ * directly. There are four methods to get scoped SharedPreferences instances:
+ *
+ * forApp()
+ * Use it for app-wide, cross-profile pref keys.
+ * forCrashReporter()
+ * For the crash reporter, which runs in its own process.
+ * forProfile()
+ * Use it to fetch and store keys for the current profile.
+ * forProfileName()
+ * Use it to fetch and store keys from/for a specific profile.
+ *
+ * {@code GeckoSharedPrefs} has a notion of migrations. Migrations can used to
+ * migrate keys from one scope to another. You can trigger a new migration by
+ * incrementing PREFS_VERSION and updating migrateIfNecessary() accordingly.
+ *
+ * Migration history:
+ * 1: Move all PreferenceManager keys to app/profile scopes
+ * 2: Move the crash reporter's private preferences into their own scope
+ */
+@RobocopTarget
+public final class GeckoSharedPrefs {
+ private static final String LOGTAG = "GeckoSharedPrefs";
+
+ // Increment it to trigger a new migration
+ public static final int PREFS_VERSION = 2;
+
+ // Name for app-scoped prefs
+ public static final String APP_PREFS_NAME = "GeckoApp";
+
+ // Name for crash reporter prefs
+ public static final String CRASH_PREFS_NAME = "CrashReporter";
+
+ // Used when fetching profile-scoped prefs.
+ public static final String PROFILE_PREFS_NAME_PREFIX = "GeckoProfile-";
+
+ // The prefs key that holds the current migration
+ private static final String PREFS_VERSION_KEY = "gecko_shared_prefs_migration";
+
+ // For disabling migration when getting a SharedPreferences instance
+ private static final EnumSet<Flags> disableMigrations = EnumSet.of(Flags.DISABLE_MIGRATIONS);
+
+ // The keys that have to be moved from ProfileManager's default
+ // shared prefs to the profile from version 0 to 1.
+ private static final String[] PROFILE_MIGRATIONS_0_TO_1 = {
+ "home_panels",
+ "home_locale"
+ };
+
+ // The keys that have to be moved from the app prefs
+ // into the crash reporter's own prefs.
+ private static final String[] PROFILE_MIGRATIONS_1_TO_2 = {
+ "sendReport",
+ "includeUrl",
+ "allowContact",
+ "contactEmail"
+ };
+
+ // For optimizing the migration check in subsequent get() calls
+ private static volatile boolean migrationDone;
+
+ public enum Flags {
+ DISABLE_MIGRATIONS
+ }
+
+ public static SharedPreferences forApp(final Context context) {
+ return forApp(context, EnumSet.noneOf(Flags.class));
+ }
+
+ /**
+ * Returns an app-scoped SharedPreferences instance. You can disable
+ * migrations by using the DISABLE_MIGRATIONS flag.
+ */
+ public static SharedPreferences forApp(final Context context, final EnumSet<Flags> flags) {
+ if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) {
+ migrateIfNecessary(context);
+ }
+
+ return context.getSharedPreferences(APP_PREFS_NAME, 0);
+ }
+
+ public static SharedPreferences forCrashReporter(final Context context) {
+ return forCrashReporter(context, EnumSet.noneOf(Flags.class));
+ }
+
+ /**
+ * Returns a crash-reporter-scoped SharedPreferences instance. You can disable
+ * migrations by using the DISABLE_MIGRATIONS flag.
+ */
+ public static SharedPreferences forCrashReporter(final Context context,
+ final EnumSet<Flags> flags) {
+ if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) {
+ migrateIfNecessary(context);
+ }
+
+ return context.getSharedPreferences(CRASH_PREFS_NAME, 0);
+ }
+
+ public static SharedPreferences forProfileName(final Context context,
+ final String profileName) {
+ return forProfileName(context, profileName, EnumSet.noneOf(Flags.class));
+ }
+
+ /**
+ * Returns an SharedPreferences instance scoped to the given profile name.
+ * You can disable migrations by using the DISABLE_MIGRATION flag.
+ */
+ public static SharedPreferences forProfileName(final Context context, final String profileName,
+ final EnumSet<Flags> flags) {
+ if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) {
+ migrateIfNecessary(context);
+ }
+
+ final String prefsName = PROFILE_PREFS_NAME_PREFIX + profileName;
+ return context.getSharedPreferences(prefsName, 0);
+ }
+
+ /**
+ * Returns the current version of the prefs.
+ */
+ public static int getVersion(final Context context) {
+ return forApp(context, disableMigrations).getInt(PREFS_VERSION_KEY, 0);
+ }
+
+ /**
+ * Resets migration flag. Should only be used in tests.
+ */
+ public static synchronized void reset() {
+ migrationDone = false;
+ }
+
+ /**
+ * Performs all prefs migrations in the background thread to avoid StrictMode
+ * exceptions from reading/writing in the UI thread. This method will block
+ * the current thread until the migration is finished.
+ */
+ @SuppressWarnings("try")
+ private static synchronized void migrateIfNecessary(final Context context) {
+ // FIXME(emilio): What do we want to do about this?
+ if (true) {
+ return;
+ }
+
+ if (migrationDone) {
+ return;
+ }
+
+ // We deliberately perform the migration in the current thread (which
+ // is likely the UI thread) as this is actually cheaper than enforcing a
+ // context switch to another thread (see bug 940575).
+ // Avoid strict mode warnings when doing so.
+ try (StrictModeContext unused = StrictModeContext.allowDiskWrites()) {
+ performMigration(context);
+ }
+
+ migrationDone = true;
+ }
+
+ private static void performMigration(final Context context) {
+ final SharedPreferences appPrefs = forApp(context, disableMigrations);
+
+ final int currentVersion = appPrefs.getInt(PREFS_VERSION_KEY, 0);
+ Log.d(LOGTAG, "Current version = " + currentVersion + ", prefs version = " + PREFS_VERSION);
+
+ if (currentVersion == PREFS_VERSION) {
+ return;
+ }
+
+ Log.d(LOGTAG, "Performing migration");
+
+ final Editor appEditor = appPrefs.edit();
+
+ // The migration always moves prefs to the default profile, not
+ // the current one. We might have to revisit this if we ever support
+ // multiple profiles.
+ final String defaultProfileName;
+ try {
+ defaultProfileName = GeckoProfile.getDefaultProfileName(context);
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to get default profile name for migration");
+ }
+
+ final Editor profileEditor = forProfileName(context, defaultProfileName, disableMigrations).edit();
+ final Editor crashEditor = forCrashReporter(context, disableMigrations).edit();
+
+ List<String> profileKeys;
+ Editor pmEditor = null;
+
+ for (int v = currentVersion + 1; v <= PREFS_VERSION; v++) {
+ Log.d(LOGTAG, "Migrating to version = " + v);
+
+ switch (v) {
+ case 1:
+ profileKeys = Arrays.asList(PROFILE_MIGRATIONS_0_TO_1);
+ pmEditor = migrateFromPreferenceManager(context, appEditor, profileEditor, profileKeys);
+ break;
+ case 2:
+ profileKeys = Arrays.asList(PROFILE_MIGRATIONS_1_TO_2);
+ migrateCrashReporterSettings(appPrefs, appEditor, crashEditor, profileKeys);
+ break;
+ }
+ }
+
+ // Update prefs version accordingly.
+ appEditor.putInt(PREFS_VERSION_KEY, PREFS_VERSION);
+
+ appEditor.apply();
+ profileEditor.apply();
+ crashEditor.apply();
+ if (pmEditor != null) {
+ pmEditor.apply();
+ }
+
+ Log.d(LOGTAG, "All keys have been migrated");
+ }
+
+ /**
+ * Moves all preferences stored in PreferenceManager's default prefs
+ * to either app or profile scopes. The profile-scoped keys are defined
+ * in given profileKeys list, all other keys are moved to the app scope.
+ */
+ private static Editor migrateFromPreferenceManager(final Context context,
+ final Editor appEditor,
+ final Editor profileEditor,
+ final List<String> profileKeys) {
+ Log.d(LOGTAG, "Migrating from PreferenceManager");
+
+ final SharedPreferences pmPrefs =
+ PreferenceManager.getDefaultSharedPreferences(context);
+
+ for (Map.Entry<String, ?> entry : pmPrefs.getAll().entrySet()) {
+ final String key = entry.getKey();
+
+ final Editor to;
+ if (profileKeys.contains(key)) {
+ to = profileEditor;
+ } else {
+ to = appEditor;
+ }
+
+ putEntry(to, key, entry.getValue());
+ }
+
+ // Clear PreferenceManager's prefs once we're done
+ // and return the Editor to be committed.
+ return pmPrefs.edit().clear();
+ }
+
+ /**
+ * Moves the crash reporter's preferences from the app-wide prefs
+ * into its own shared prefs to avoid cross-process pref accesses.
+ */
+ private static void migrateCrashReporterSettings(final SharedPreferences appPrefs,
+ final Editor appEditor,
+ final Editor crashEditor,
+ final List<String> profileKeys) {
+ Log.d(LOGTAG, "Migrating crash reporter settings");
+
+ for (Map.Entry<String, ?> entry : appPrefs.getAll().entrySet()) {
+ final String key = entry.getKey();
+
+ if (profileKeys.contains(key)) {
+ putEntry(crashEditor, key, entry.getValue());
+ appEditor.remove(key);
+ }
+ }
+ }
+
+ private static void putEntry(final Editor to, final String key, final Object value) {
+ Log.d(LOGTAG, "Migrating key = " + key + " with value = " + value);
+
+ if (value instanceof String) {
+ to.putString(key, (String) value);
+ } else if (value instanceof Boolean) {
+ to.putBoolean(key, (Boolean) value);
+ } else if (value instanceof Long) {
+ to.putLong(key, (Long) value);
+ } else if (value instanceof Float) {
+ to.putFloat(key, (Float) value);
+ } else if (value instanceof Integer) {
+ to.putInt(key, (Integer) value);
+ } else {
+ throw new IllegalStateException("Unrecognized value type for key: " + key);
+ }
+ }
+}
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..ca835607b2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java
@@ -0,0 +1,166 @@
+/* -*- 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 androidx.annotation.RequiresApi;
+import android.util.Log;
+import android.view.InputDevice;
+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;
+ ContentResolver contentResolver = sApplicationContext.getContentResolver();
+ 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);
+
+ 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.
+ *
+ * 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;
+ }
+
+ 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) {
+ 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) {
+ 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..2bddba9278
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java
@@ -0,0 +1,812 @@
+/* -*- 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 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;
+
+import android.app.ActivityManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.os.Bundle;
+import android.os.Debug;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.MessageQueue;
+import android.os.Process;
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.text.TextUtils;
+import android.util.Log;
+
+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;
+
+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();
+ }
+ }
+
+ 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();
+ 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 static final String EXTRA_PREFS_FD = "prefsFd";
+ private static final String EXTRA_PREF_MAP_FD = "prefMapFd";
+ private static final String EXTRA_IPC_FD = "ipcFd";
+ private static final String EXTRA_CRASH_FD = "crashFd";
+ private static final String EXTRA_CRASH_ANNOTATION_FD = "crashAnnotationFd";
+
+ private boolean mInitialized;
+ private InitInfo mInitInfo;
+
+ public static class InitInfo {
+ public GeckoProfile profile;
+ public String[] args;
+ public Bundle extras;
+ public int flags;
+ public Map<String, Object> prefs;
+
+ public int prefsFd;
+ public int prefMapFd;
+ public int ipcFd;
+ public int crashFd;
+ public int crashAnnotationFd;
+ }
+
+ 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.extras.getInt(EXTRA_IPC_FD, -1) != -1;
+ }
+
+ 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;
+
+ mInitInfo.extras = (info.extras != null) ? new Bundle(info.extras) : new Bundle(3);
+
+ if (info.prefsFd > 0) {
+ mInitInfo.extras.putInt(EXTRA_PREFS_FD, info.prefsFd);
+ }
+
+ if (info.prefMapFd > 0) {
+ mInitInfo.extras.putInt(EXTRA_PREF_MAP_FD, info.prefMapFd);
+ }
+
+ if (info.ipcFd > 0) {
+ mInitInfo.extras.putInt(EXTRA_IPC_FD, info.ipcFd);
+ }
+
+ if (info.crashFd > 0) {
+ mInitInfo.extras.putInt(EXTRA_CRASH_FD, info.crashFd);
+ }
+
+ if (info.crashAnnotationFd > 0) {
+ mInitInfo.extras.putInt(EXTRA_CRASH_ANNOTATION_FD, info.crashAnnotationFd);
+ }
+
+ 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);
+ Configuration config = res.getConfiguration();
+ config.locale = mappedLocale;
+ res.updateConfiguration(config, null);
+ }
+
+ GeckoSystemStateListener.getInstance().initialize(context);
+
+ loadGeckoLibs(context);
+ }
+
+ private String[] getMainProcessArgs() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final ArrayList<String> args = new ArrayList<String>();
+
+ // argv[0] is the program name, which for us is the package name.
+ args.add(context.getPackageName());
+ args.add("-greomni");
+ args.add(context.getPackageResourcePath());
+
+ final GeckoProfile profile = getProfile();
+ if (profile.isCustomProfile()) {
+ args.add("-profile");
+ args.add(profile.getDir().getAbsolutePath());
+ } else {
+ profile.getDir(); // Make sure the profile dir exists.
+ args.add("-P");
+ args.add(profile.getName());
+ }
+
+ if (mInitInfo.args != null) {
+ args.addAll(Arrays.asList(mInitInfo.args));
+ }
+
+ final String extraArgs = mInitInfo.extras.getString(EXTRA_ARGS, null);
+ if (extraArgs != null) {
+ final StringTokenizer st = new StringTokenizer(extraArgs);
+ while (st.hasMoreTokens()) {
+ final String token = st.nextToken();
+ if ("-P".equals(token) || "-profile".equals(token)) {
+ // Skip -P and -profile arguments because we added them above.
+ if (st.hasMoreTokens()) {
+ st.nextToken();
+ }
+ continue;
+ }
+ args.add(token);
+ }
+ }
+
+ return args.toArray(new String[args.size()]);
+ }
+
+ @RobocopTarget
+ public static @Nullable GeckoProfile getActiveProfile() {
+ return INSTANCE.getProfile();
+ }
+
+ public synchronized @Nullable GeckoProfile getProfile() {
+ if (!mInitialized) {
+ return null;
+ }
+ if (isChildProcess()) {
+ throw new UnsupportedOperationException(
+ "Cannot access profile from child process");
+ }
+ if (mInitInfo.profile == null) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ mInitInfo.profile = GeckoProfile.initFromArgs(context,
+ mInitInfo.extras.getString(EXTRA_ARGS, null));
+ }
+ return mInitInfo.profile;
+ }
+
+ 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<>();
+ }
+
+ ArrayList<String> result = new ArrayList<>();
+ if (extras != null) {
+ String env = extras.getString("env0");
+ for (int c = 1; env != null; c++) {
+ if (BuildConfig.DEBUG) {
+ 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;
+ 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");
+ }
+
+ // Very early -- before we load mozglue -- wait for Java debuggers. This allows to connect
+ // a dual/hybrid debugger as well, allowing to debug child processes -- including the
+ // mozglue loading process.
+ maybeWaitForJavaDebugger(context, env);
+
+ // 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);
+
+ GeckoLoader.setupGeckoEnvironment(context, context.getFilesDir().getPath(), env, mInitInfo.prefs);
+
+ 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.extras.getInt(EXTRA_PREFS_FD, -1),
+ mInitInfo.extras.getInt(EXTRA_PREF_MAP_FD, -1),
+ mInitInfo.extras.getInt(EXTRA_IPC_FD, -1),
+ mInitInfo.extras.getInt(EXTRA_CRASH_FD, -1),
+ mInitInfo.extras.getInt(EXTRA_CRASH_ANNOTATION_FD, -1));
+
+ // 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);
+ }
+
+ private static void maybeWaitForJavaDebugger(final @NonNull Context context, final @NonNull List<String> env) {
+ for (final String e : env) {
+ if (e == null) {
+ continue;
+ }
+
+ if (e.equals("MOZ_DEBUG_WAIT_FOR_JAVA_DEBUGGER=1")) {
+ if (!isChildProcess()) {
+ final String processName = getProcessName(context);
+ waitForJavaDebugger(processName);
+ }
+ }
+
+ if (e.startsWith("MOZ_DEBUG_CHILD_WAIT_FOR_JAVA_DEBUGGER=")) {
+ String filter = e.substring("MOZ_DEBUG_CHILD_WAIT_FOR_JAVA_DEBUGGER=".length());
+ if (isChildProcess()) {
+ final String processName = getProcessName(context);
+ if (processName == null || processName.endsWith(filter)) {
+ waitForJavaDebugger(processName);
+ }
+ }
+ }
+ }
+ }
+
+ // 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=";
+ 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;
+
+ // 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.
+ 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
+ String value = envItem.substring(intervalEnv.length());
+
+ try {
+ int intValue = Integer.parseInt(value);
+ interval = Math.max(intValue, interval);
+ } catch (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
+ String value = envItem.substring(capacityEnv.length());
+
+ try {
+ int intValue = Integer.parseInt(value);
+ // See `scMinimumBufferEntries` variable for this value on the platform side.
+ capacity = Math.max(intValue, minCapacity);
+ } catch (NumberFormatException err) {
+ // Failed to parse. Do nothing and just use the default value.
+ }
+ }
+ }
+
+ if (isStartupProfiling) {
+ GeckoJavaSampler.start(interval, capacity);
+ }
+ }
+
+ private static @Nullable String getProcessName(final @NonNull Context context) {
+ final int pid = Process.myPid();
+ final ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+
+ // This can be quite slow, and it can return null.
+ List<ActivityManager.RunningAppProcessInfo> processInfos = manager.getRunningAppProcesses();
+
+ if (processInfos == null) {
+ return null;
+ }
+
+ for (ActivityManager.RunningAppProcessInfo processInfo : processInfos) {
+ if (processInfo.pid == pid) {
+ return processInfo.processName;
+ }
+ }
+
+ return null;
+ }
+
+ private static void waitForJavaDebugger(final @Nullable String processName) {
+ final int pid = Process.myPid();
+ final String processIdentification = (isChildProcess() ? "Child process " : "Main process ") +
+ (processName != null ? processName : "<unknown>") +
+ " (" + pid + ")";
+
+ if (Debug.isDebuggerConnected()) {
+ Log.i(LOGTAG, processIdentification + ": Waiting for Java debugger ... " + " already connected");
+ return;
+ }
+
+ Log.w(LOGTAG, processIdentification + ": Waiting for Java debugger ...");
+ Debug.waitForDebugger();
+ Log.w(LOGTAG, processIdentification + ": Waiting for Java debugger ... connected");
+ }
+
+ @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/HapticFeedbackDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/HapticFeedbackDelegate.java
new file mode 100644
index 0000000000..0e93b00b6e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/HapticFeedbackDelegate.java
@@ -0,0 +1,18 @@
+/* -*- 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;
+
+/**
+ * A <code>HapticFeedbackDelegate</code> is responsible for performing haptic feedback.
+ */
+public interface HapticFeedbackDelegate {
+ /**
+ * Perform a haptic feedback effect. Called from the Gecko thread.
+ *
+ * @param effect Effect to perform from <code>android.view.HapticFeedbackConstants</code>.
+ */
+ void performHapticFeedback(int effect);
+}
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..40855e720d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java
@@ -0,0 +1,99 @@
+/* -*- 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 java.util.Collection;
+
+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;
+
+final public 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) {
+ String inputMethod = Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD);
+ return (inputMethod != null ? inputMethod : "");
+ }
+
+ public static InputMethodInfo getInputMethodInfo(final Context context,
+ final String inputMethod) {
+ InputMethodManager imm = getInputMethodManager(context);
+ Collection<InputMethodInfo> infos = imm.getEnabledInputMethodList();
+ for (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) {
+ 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/MultiMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
new file mode 100644
index 0000000000..14f2bc499e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
@@ -0,0 +1,189 @@
+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;
+ }
+
+ List<T> values = mMap.get(key);
+ 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..379819a2cc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java
@@ -0,0 +1,232 @@
+/* -*- 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/NotificationListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java
new file mode 100644
index 0000000000..883de38e9d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java
@@ -0,0 +1,16 @@
+/* -*- 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;
+
+public interface NotificationListener {
+ void showNotification(String name, String cookie, String title, String text,
+ String host, String imageUrl);
+
+ void showPersistentNotification(String name, String cookie, String title, String text,
+ String host, String imageUrl, String data);
+
+ void closeNotification(String name);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java
new file mode 100644
index 0000000000..420de4834d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java
@@ -0,0 +1,310 @@
+/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import androidx.collection.SimpleArrayMap;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+
+/**
+ * Helper class to get/set gecko prefs.
+ */
+public final class PrefsHelper {
+ private static final String LOGTAG = "GeckoPrefsHelper";
+
+ // Map pref name to ArrayList for multiple observers or PrefHandler for single observer.
+ private static final SimpleArrayMap<String, Object> OBSERVERS = new SimpleArrayMap<>();
+ private static final HashSet<String> INT_TO_STRING_PREFS = new HashSet<>(8);
+ private static final HashSet<String> INT_TO_BOOL_PREFS = new HashSet<>(2);
+
+ static {
+ INT_TO_STRING_PREFS.add("browser.chrome.titlebarMode");
+ INT_TO_STRING_PREFS.add("network.cookie.cookieBehavior");
+ INT_TO_STRING_PREFS.add("home.sync.updateMode");
+ INT_TO_STRING_PREFS.add("browser.image_blocking");
+ INT_TO_STRING_PREFS.add("media.autoplay.default");
+ INT_TO_BOOL_PREFS.add("browser.display.use_document_fonts");
+ }
+
+ @WrapForJNI
+ private static final int PREF_INVALID = -1;
+ @WrapForJNI
+ private static final int PREF_FINISH = 0;
+ @WrapForJNI
+ private static final int PREF_BOOL = 1;
+ @WrapForJNI
+ private static final int PREF_INT = 2;
+ @WrapForJNI
+ private static final int PREF_STRING = 3;
+
+ @WrapForJNI(stubName = "GetPrefs", dispatchTo = "gecko")
+ private static native void nativeGetPrefs(String[] prefNames, PrefHandler handler);
+ @WrapForJNI(stubName = "SetPref", dispatchTo = "gecko")
+ private static native void nativeSetPref(String prefName, boolean flush, int type,
+ boolean boolVal, int intVal, String strVal);
+ @WrapForJNI(stubName = "AddObserver", dispatchTo = "gecko")
+ private static native void nativeAddObserver(String[] prefNames, PrefHandler handler,
+ String[] prefsToObserve);
+ @WrapForJNI(stubName = "RemoveObserver", dispatchTo = "gecko")
+ private static native void nativeRemoveObserver(String[] prefToUnobserve);
+
+ @RobocopTarget
+ public static void getPrefs(final String[] prefNames, final PrefHandler callback) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeGetPrefs(prefNames, callback);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeGetPrefs",
+ String[].class, prefNames, PrefHandler.class, callback);
+ }
+ }
+
+ public static void getPref(final String prefName, final PrefHandler callback) {
+ getPrefs(new String[] { prefName }, callback);
+ }
+
+ public static void getPrefs(final ArrayList<String> prefNames, final PrefHandler callback) {
+ getPrefs(prefNames.toArray(new String[prefNames.size()]), callback);
+ }
+
+ @RobocopTarget
+ public static void setPref(final String pref, final Object value, final boolean flush) {
+ final int type;
+ boolean boolVal = false;
+ int intVal = 0;
+ String strVal = null;
+
+ if (INT_TO_STRING_PREFS.contains(pref)) {
+ // When sending to Java, we normalized special preferences that use integers
+ // and strings to represent booleans. Here, we convert them back to their
+ // actual types so we can store them.
+ type = PREF_INT;
+ intVal = Integer.parseInt(String.valueOf(value));
+ } else if (INT_TO_BOOL_PREFS.contains(pref)) {
+ type = PREF_INT;
+ intVal = (Boolean) value ? 1 : 0;
+ } else if (value instanceof Boolean) {
+ type = PREF_BOOL;
+ boolVal = (Boolean) value;
+ } else if (value instanceof Integer) {
+ type = PREF_INT;
+ intVal = (Integer) value;
+ } else {
+ type = PREF_STRING;
+ strVal = String.valueOf(value);
+ }
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeSetPref(pref, flush, type, boolVal, intVal, strVal);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeSetPref",
+ String.class, pref, flush, type, boolVal, intVal, String.class, strVal);
+ }
+ }
+
+ public static void setPref(final String pref, final Object value) {
+ setPref(pref, value, /* flush */ false);
+ }
+
+ @RobocopTarget
+ public synchronized static void addObserver(final String[] prefNames,
+ final PrefHandler handler) {
+ List<String> prefsToObserve = null;
+
+ for (String pref : prefNames) {
+ final Object existing = OBSERVERS.get(pref);
+
+ if (existing == null) {
+ // Not observing yet, so add observer.
+ if (prefsToObserve == null) {
+ prefsToObserve = new ArrayList<>(prefNames.length);
+ }
+ prefsToObserve.add(pref);
+ OBSERVERS.put(pref, handler);
+
+ } else if (existing instanceof PrefHandler) {
+ // Already observing one, so turn it into an array.
+ final List<PrefHandler> handlerList = new ArrayList<>(2);
+ handlerList.add((PrefHandler) existing);
+ handlerList.add(handler);
+ OBSERVERS.put(pref, handlerList);
+
+ } else {
+ // Already observing multiple, so add to existing array.
+ @SuppressWarnings("unchecked")
+ final List<PrefHandler> handlerList = (List) existing;
+ handlerList.add(handler);
+ }
+ }
+
+ final String[] namesToObserve = prefsToObserve == null ? null :
+ prefsToObserve.toArray(new String[prefsToObserve.size()]);
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeAddObserver(prefNames, handler, namesToObserve);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeAddObserver",
+ String[].class, prefNames, PrefHandler.class, handler,
+ String[].class, namesToObserve);
+ }
+ }
+
+ @RobocopTarget
+ public synchronized static void removeObserver(final PrefHandler handler) {
+ List<String> prefsToUnobserve = null;
+
+ for (int i = OBSERVERS.size() - 1; i >= 0; i--) {
+ final Object existing = OBSERVERS.valueAt(i);
+ boolean removeObserver = false;
+
+ if (existing == handler) {
+ removeObserver = true;
+
+ } else if (!(existing instanceof PrefHandler)) {
+ // Removing existing handler from list.
+ @SuppressWarnings("unchecked")
+ final List<PrefHandler> handlerList = (List) existing;
+ if (handlerList.remove(handler) && handlerList.isEmpty()) {
+ removeObserver = true;
+ }
+ }
+
+ if (removeObserver) {
+ // Removed last handler, so remove observer.
+ if (prefsToUnobserve == null) {
+ prefsToUnobserve = new ArrayList<>();
+ }
+ prefsToUnobserve.add(OBSERVERS.keyAt(i));
+ OBSERVERS.removeAt(i);
+ }
+ }
+
+ if (prefsToUnobserve == null) {
+ return;
+ }
+
+ final String[] namesToUnobserve =
+ prefsToUnobserve.toArray(new String[prefsToUnobserve.size()]);
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeRemoveObserver(namesToUnobserve);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeRemoveObserver",
+ String[].class, namesToUnobserve);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void callPrefHandler(final PrefHandler handler, final int originalType,
+ final String pref, final boolean originalBoolVal,
+ final int intVal, final String originalStrVal) {
+ // Some Gecko preferences use integers or strings to reference state instead of
+ // directly representing the value. Since the Java UI uses the type to determine
+ // which ui elements to show and how to handle them, we need to normalize these
+ // preferences to the correct type.
+ int type = originalType;
+ String strVal = originalStrVal;
+ boolean boolVal = originalBoolVal;
+
+ if (INT_TO_STRING_PREFS.contains(pref)) {
+ type = PREF_STRING;
+ strVal = String.valueOf(intVal);
+ } else if (INT_TO_BOOL_PREFS.contains(pref)) {
+ type = PREF_BOOL;
+ boolVal = intVal == 1;
+ }
+
+ switch (type) {
+ case PREF_FINISH:
+ handler.finish();
+ return;
+ case PREF_BOOL:
+ handler.prefValue(pref, boolVal);
+ return;
+ case PREF_INT:
+ handler.prefValue(pref, intVal);
+ return;
+ case PREF_STRING:
+ handler.prefValue(pref, strVal);
+ return;
+ }
+ throw new IllegalArgumentException();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private synchronized static void onPrefChange(final String pref, final int type,
+ final boolean boolVal, final int intVal,
+ final String strVal) {
+ final Object existing = OBSERVERS.get(pref);
+
+ if (existing == null) {
+ return;
+ }
+
+ final Iterator<PrefHandler> itor;
+ PrefHandler handler;
+
+ if (existing instanceof PrefHandler) {
+ itor = null;
+ handler = (PrefHandler) existing;
+ } else {
+ @SuppressWarnings("unchecked")
+ final List<PrefHandler> handlerList = (List) existing;
+ if (handlerList.isEmpty()) {
+ return;
+ }
+ itor = handlerList.iterator();
+ handler = itor.next();
+ }
+
+ do {
+ callPrefHandler(handler, type, pref, boolVal, intVal, strVal);
+ handler.finish();
+
+ handler = itor != null && itor.hasNext() ? itor.next() : null;
+ } while (handler != null);
+ }
+
+ public interface PrefHandler {
+ void prefValue(String pref, boolean value);
+ void prefValue(String pref, int value);
+ void prefValue(String pref, String value);
+ void finish();
+ }
+
+ public static abstract class PrefHandlerBase implements PrefHandler {
+ @Override
+ public void prefValue(final String pref, final boolean value) {
+ throw new UnsupportedOperationException(
+ "Unhandled boolean pref " + pref + "; wrong type?");
+ }
+
+ @Override
+ public void prefValue(final String pref, final int value) {
+ throw new UnsupportedOperationException(
+ "Unhandled int pref " + pref + "; wrong type?");
+ }
+
+ @Override
+ public void prefValue(final String pref, final String value) {
+ throw new UnsupportedOperationException(
+ "Unhandled String pref " + pref + "; wrong type?");
+ }
+
+ @Override
+ public void finish() {
+ }
+ }
+}
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..1c5304a210
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java
@@ -0,0 +1,57 @@
+/* -*- 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 {
+
+ /**
+ * The following display types use the same definition in nsIScreen.idl
+ */
+ final static int DISPLAY_PRIMARY = 0; // primary screen
+ final static int DISPLAY_EXTERNAL = 1; // wired displays, such as HDMI, DisplayPort, etc.
+ final static int DISPLAY_VIRTUAL = 2; // wireless displays, such as Chromecast, WiFi-Display, etc.
+
+ /**
+ * 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 native static void nativeRefreshScreenInfo();
+
+ /**
+ * Add a new nsScreen when a new display in Android is available.
+ *
+ * @param displayType the display type of the nsScreen would be added
+ * @param width the width of the new nsScreen
+ * @param height the height of the new nsScreen
+ * @param density the density of the new nsScreen
+ *
+ * @return return the ID of the added nsScreen
+ */
+ @WrapForJNI
+ public native static int addDisplay(int displayType,
+ int width,
+ int height,
+ float density);
+
+ /**
+ * Remove the nsScreen by the specific screen ID.
+ *
+ * @param screenId the ID of the screen would be removed.
+ */
+ @WrapForJNI
+ public native static void removeDisplay(int screenId);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenOrientationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenOrientationDelegate.java
new file mode 100644
index 0000000000..0731abf397
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenOrientationDelegate.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;
+
+/**
+ * A <code>ScreenOrientationDelegate</code> is responsible for setting the screen orientation.
+ * <p>
+ * A browser that wants to support the <a href="https://w3c.github.io/screen-orientation/">Screen
+ * Orientation API</a> MUST implement these methods. A GeckoView consumer MAY implement these
+ * methods.
+ * <p> To implement, consider registering an
+ * {@link android.app.Application.ActivityLifecycleCallbacks} handler to track the current
+ * foreground {@link android.app.Activity}.
+ */
+public interface ScreenOrientationDelegate {
+ /**
+ * If possible, set the current screen orientation.
+ *
+ * @param requestedActivityInfoOrientation An orientation constant as used in {@link android.content.pm.ActivityInfo#screenOrientation}.
+ * @return true if screen orientation could be set; false otherwise.
+ */
+ boolean setRequestedOrientationForCurrentActivity(int requestedActivityInfoOrientation);
+}
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..57c8be31dd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java
@@ -0,0 +1,200 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+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;
+
+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() {
+ TextToSpeech tss = getTTS();
+ Locale defaultLocale = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2
+ ? tss.getDefaultLanguage()
+ : tss.getLanguage();
+ for (Locale locale : getAvailableLanguages()) {
+ final Set<String> features = tss.getFeatures(locale);
+ boolean isLocal = features != null && features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS);
+ 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.
+ return getTTS().getAvailableLanguages();
+ }
+ Set<Locale> locales = new HashSet<Locale>();
+ for (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) {
+ 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;
+ }
+
+ 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);
+ TextToSpeech tss = (TextToSpeech) sTTS;
+ tss.setLanguage(new Locale(uri.substring("moz-tts:android:".length())));
+ tss.setSpeechRate(rate);
+ tss.setPitch(pitch);
+ 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..d3d7efd3a1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java
@@ -0,0 +1,180 @@
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.graphics.Color;
+import android.graphics.PixelFormat;
+import android.graphics.SurfaceTexture;
+import android.util.Log;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.TextureView;
+import android.view.View;
+
+/** 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 SurfaceView(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;
+ }
+
+ public Surface getSurface() {
+ if (mSurfaceView != null) {
+ return mSurfaceView.getHolder().getSurface();
+ }
+
+ return mListenerWrapper.mSurface;
+ }
+
+ public View getView() {
+ return mView;
+ }
+
+ /**
+ * Translates SurfaceTextureListener and SurfaceHolder.Callback into a common interface
+ * SurfaceViewWrapper.Listener
+ */
+ private static 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, 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, 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, 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(), width, height);
+ }
+ }
+
+ @Override
+ public void surfaceDestroyed(final SurfaceHolder holder) {
+ if (mListener != null) {
+ mListener.onSurfaceDestroyed();
+ }
+ }
+ }
+
+ public interface Listener {
+ void onSurfaceChanged(Surface surface, int width, int height);
+ void onSurfaceDestroyed();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java
new file mode 100644
index 0000000000..9fe71f9ac2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java
@@ -0,0 +1,165 @@
+/* -*- 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.app.ActivityManager;
+import android.app.ActivityManager.MemoryInfo;
+import android.content.Context;
+import android.util.Log;
+
+import org.mozilla.gecko.util.StrictModeContext;
+
+import java.io.File;
+import java.io.FileFilter;
+
+import java.util.regex.Pattern;
+
+/**
+ * A collection of system info values, broadly mirroring a subset of
+ * nsSystemInfo. See also the constants in org.mozilla.geckoview.BuildConfig,
+ * which reflect much of nsIXULAppInfo.
+ */
+// Normally, we'd annotate with @RobocopTarget. Since SysInfo is compiled
+// before RobocopTarget, we instead add o.m.g.SysInfo directly to the Proguard
+// configuration.
+public final class SysInfo {
+ private static final String LOG_TAG = "GeckoSysInfo";
+
+ // Number of bytes of /proc/meminfo to read in one go.
+ private static final int MEMINFO_BUFFER_SIZE_BYTES = 256;
+
+ // We don't mind an instant of possible duplicate work, we only wish to
+ // avoid inconsistency, so we don't bother with synchronization for
+ // these.
+ private static volatile int cpuCount = -1;
+
+ private static volatile int totalRAM = -1;
+
+ /**
+ * Get the number of cores on the device.
+ *
+ * We can't use a nice tidy API call, <a
+ * href="https://stackoverflow.com/q/7962155">because they're all
+ * wrong</a>. This method is based on that code.
+ *
+ * @return the number of CPU cores, or 1 if the number could not be
+ * determined.
+ */
+ @SuppressWarnings("try")
+ public static int getCPUCount() {
+ if (cpuCount > 0) {
+ return cpuCount;
+ }
+
+ // Avoid a strict mode warning.
+ try (StrictModeContext unused = StrictModeContext.allowDiskReads()) {
+ return readCPUCount();
+ }
+ }
+
+ private static int readCPUCount() {
+ class CpuFilter implements FileFilter {
+ @Override
+ public boolean accept(final File pathname) {
+ return Pattern.matches("cpu[0-9]+", pathname.getName());
+ }
+ }
+ try {
+ final File dir = new File("/sys/devices/system/cpu/");
+ return cpuCount = dir.listFiles(new CpuFilter()).length;
+ } catch (Exception e) {
+ Log.w(LOG_TAG, "Assuming 1 CPU; got exception.", e);
+ return cpuCount = 1;
+ }
+ }
+
+ /**
+ * Fetch the total memory of the device in MB.
+ *
+ * NB: This cannot be called before GeckoAppShell has been
+ * initialized.
+ *
+ * @return Memory size in MB.
+ */
+ public static int getMemSize(final Context context) {
+ if (totalRAM >= 0) {
+ return totalRAM;
+ }
+
+ final MemoryInfo memInfo = new MemoryInfo();
+
+ final ActivityManager am = (ActivityManager) context
+ .getSystemService(Context.ACTIVITY_SERVICE);
+ am.getMemoryInfo(memInfo);
+
+ // `getMemoryInfo()` returns a value in B. Convert to MB.
+ totalRAM = (int)(memInfo.totalMem / (1024 * 1024));
+
+ Log.d(LOG_TAG, "System memory: " + totalRAM + "MB.");
+
+ return totalRAM;
+ }
+
+ /**
+ * @return the SDK version supported by this device, such as '16'.
+ */
+ public static int getVersion() {
+ return android.os.Build.VERSION.SDK_INT;
+ }
+
+ /**
+ * @return the release version string, such as "4.1.2".
+ */
+ public static String getReleaseVersion() {
+ return android.os.Build.VERSION.RELEASE;
+ }
+
+ /**
+ * @return the kernel version string, such as "3.4.10-geb45596".
+ */
+ public static String getKernelVersion() {
+ return System.getProperty("os.version", "");
+ }
+
+ /**
+ * @return the device manufacturer, such as "HTC".
+ */
+ public static String getManufacturer() {
+ return android.os.Build.MANUFACTURER;
+ }
+
+ /**
+ * @return the device name, such as "HTC One".
+ */
+ public static String getDevice() {
+ // No, not android.os.Build.DEVICE.
+ return android.os.Build.MODEL;
+ }
+
+ /**
+ * @return the Android "hardware" identifier, such as "m7".
+ */
+ public static String getHardware() {
+ return android.os.Build.HARDWARE;
+ }
+
+ /**
+ * @return the system OS name. Hardcoded to "Android".
+ */
+ public static String getName() {
+ // We deliberately differ from PR_SI_SYSNAME, which is "Linux".
+ return "Android";
+ }
+
+ /**
+ * @return the Android architecture string, including ABI.
+ */
+ public static String getArchABI() {
+ // Android likes to include the ABI, too ("armeabiv7"), so we
+ // differ to add value.
+ return android.os.Build.CPU_ABI;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryContract.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryContract.java
new file mode 100644
index 0000000000..b5ea0b1e5e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryContract.java
@@ -0,0 +1,317 @@
+/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget;
+
+/**
+ * Holds data definitions for our UI Telemetry implementation.
+ *
+ * Note that enum values of "_TEST*" are reserved for testing and
+ * should not be changed without changing the associated tests.
+ *
+ * See mobile/android/base/docs/index.rst for a full dictionary.
+ */
+@RobocopTarget
+public interface TelemetryContract {
+
+ /**
+ * Holds event names. Intended for use with
+ * Telemetry.sendUIEvent() as the "action" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Event {
+ // Generic action, usually for tracking menu and toolbar actions.
+ ACTION("action.1"),
+
+ // Cancel a state, action, etc.
+ CANCEL("cancel.1"),
+
+ // Start casting a video.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ CAST("cast.1"),
+
+ // Editing an item.
+ EDIT("edit.1"),
+
+ // Launching (opening) an external application.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ LAUNCH("launch.1"),
+
+ // Loading a URL.
+ LOAD_URL("loadurl.1"),
+
+ LOCALE_BROWSER_RESET("locale.browser.reset.1"),
+ LOCALE_BROWSER_SELECTED("locale.browser.selected.1"),
+ LOCALE_BROWSER_UNSELECTED("locale.browser.unselected.1"),
+
+ // Hide a built-in home panel.
+ PANEL_HIDE("panel.hide.1"),
+
+ // Move a home panel up or down.
+ PANEL_MOVE("panel.move.1"),
+
+ // Remove a custom home panel.
+ PANEL_REMOVE("panel.remove.1"),
+
+ // Set default home panel.
+ PANEL_SET_DEFAULT("panel.setdefault.1"),
+
+ // Show a hidden built-in home panel.
+ PANEL_SHOW("panel.show.1"),
+
+ // Pinning an item.
+ PIN("pin.1"),
+
+ // Outcome of data policy notification: can be true or false.
+ POLICY_NOTIFICATION_SUCCESS("policynotification.success.1"),
+
+ // Sanitizing private data.
+ SANITIZE("sanitize.1"),
+
+ // Saving a resource (reader, bookmark, etc) for viewing later.
+ SAVE("save.1"),
+
+ // Perform a search -- currently used when starting a search in the search activity.
+ SEARCH("search.1"),
+
+ // Remove a search engine.
+ SEARCH_REMOVE("search.remove.1"),
+
+ // Restore default search engines.
+ SEARCH_RESTORE_DEFAULTS("search.restoredefaults.1"),
+
+ // Set default search engine.
+ SEARCH_SET_DEFAULT("search.setdefault.1"),
+
+ // Searches initiated from the widget.
+ SEARCH_WIDGET("search.widget.1"),
+
+ // Sharing content.
+ SHARE("share.1"),
+
+ // Show a UI element.
+ SHOW("show.1"),
+
+ // Undoing a user action.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ UNDO("undo.1"),
+
+ // Unpinning an item.
+ UNPIN("unpin.1"),
+
+ // Stop holding a resource (reader, bookmark, etc) for viewing later.
+ UNSAVE("unsave.1"),
+
+ // When the user performs actions on the in-content network error page.
+ NETERROR("neterror.1"),
+
+ // User actions related to a Progressive Web Application
+ PWA("pwa.1"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_event_1.1"),
+ _TEST2("_test_event_2.1"),
+ _TEST3("_test_event_3.1"),
+ _TEST4("_test_event_4.1"),
+ ;
+
+ private final String mString;
+
+ Event(final String string) {
+ mString = string;
+ }
+
+ @Override
+ public String toString() {
+ return mString;
+ }
+ }
+
+ /**
+ * Holds event methods. Intended for use in
+ * Telemetry.sendUIEvent() as the "method" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Method {
+ // Action triggered from the action bar (including the toolbar).
+ ACTIONBAR("actionbar"),
+
+ // Action triggered by hitting the Android back button.
+ BACK("back"),
+
+ // Action triggered from a button.
+ BUTTON("button"),
+
+ // Action taken from a content page -- for example, a search results web page.
+ CONTENT("content"),
+
+ // Action occurred via a context menu.
+ CONTEXT_MENU("contextmenu"),
+
+ // Action triggered from a dialog.
+ DIALOG("dialog"),
+
+ // Action triggered from a doorhanger popup prompt.
+ DOORHANGER("doorhanger"),
+
+ // Action triggered from a view grid item, like a thumbnail.
+ GRID_ITEM("griditem"),
+
+ // Action occurred via an intent.
+ INTENT("intent"),
+
+ // Action occurred via a homescreen launcher.
+ HOMESCREEN("homescreen"),
+
+ // Action triggered from a list.
+ LIST("list"),
+
+ // Action triggered from a view list item, like a row of a list.
+ LIST_ITEM("listitem"),
+
+ // Action occurred via the main menu.
+ MENU("menu"),
+
+ // No method is specified.
+ NONE(null),
+
+ // Action triggered from a notification in the Android notification bar.
+ NOTIFICATION("notification"),
+
+ // Action triggered from a pageaction in the URLBar.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ PAGEACTION("pageaction"),
+
+ // Action triggered from one of a series of views, such as ViewPager.
+ PANEL("panel"),
+
+ // Action triggered by a background service / automatic system making a decision.
+ SERVICE("service"),
+
+ // Action triggered from a settings screen.
+ SETTINGS("settings"),
+
+ // Actions triggered from the share overlay.
+ SHARE_OVERLAY("shareoverlay"),
+
+ // Action triggered from a suggestion provided to the user.
+ SUGGESTION("suggestion"),
+
+ // Action triggered from an OS system action.
+ SYSTEM("system"),
+
+ // Action triggered from a SuperToast.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ TOAST("toast"),
+
+ // Action triggerred by pressing a SearchWidget button
+ WIDGET("widget"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_method_1"),
+ _TEST2("_test_method_2"),
+ ;
+
+ private final String mString;
+
+ Method(final String string) {
+ mString = string;
+ }
+
+ @Override
+ public String toString() {
+ return mString;
+ }
+ }
+
+ /**
+ * Holds session names. Intended for use with
+ * Telemetry.startUISession() as the "sessionName" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Session {
+ // Started whenever the activity stream panel is visible. Stopped as soon as the panel is
+ // not visible anymore.
+ ACTIVITY_STREAM("activitystream.1"),
+
+ // Awesomescreen (including frecency search) is active.
+ AWESOMESCREEN("awesomescreen.1"),
+
+ // Used to tag experiments being run.
+ EXPERIMENT("experiment.1"),
+
+ // Started the very first time we believe the application has been launched.
+ FIRSTRUN("firstrun.1"),
+
+ // Awesomescreen frecency search is active.
+ FRECENCY("frecency.1"),
+
+ // Started when a user enters a given home panel.
+ // Session name is dynamic, encoded as "homepanel.1:<panel_id>"
+ HOME_PANEL("homepanel.1"),
+
+ // Started when a Reader viewer becomes active in the foreground.
+ // Note: Only used in JavaScript for now, but here for completeness.
+ READER("reader.1"),
+
+ // Started when the search activity launches.
+ SEARCH_ACTIVITY("searchactivity.1"),
+
+ // Settings activity is active.
+ SETTINGS("settings.1"),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST_STARTED_TWICE("_test_session_started_twice.1"),
+ _TEST_STOPPED_TWICE("_test_session_stopped_twice.1"),
+ ;
+
+ private final String mString;
+
+ Session(final String string) {
+ mString = string;
+ }
+
+ @Override
+ public String toString() {
+ return mString;
+ }
+ }
+
+ /**
+ * Holds reasons for stopping a session. Intended for use in
+ * Telemetry.stopUISession() as the "reason" parameter.
+ *
+ * Please keep this list sorted.
+ */
+ public enum Reason {
+ // Changes were committed.
+ COMMIT("commit"),
+
+ // No reason is specified.
+ NONE(null),
+
+ // VALUES BELOW THIS LINE ARE EXCLUSIVE TO TESTING.
+ _TEST1("_test_reason_1"),
+ _TEST2("_test_reason_2"),
+ _TEST_IGNORED("_test_reason_ignored"),
+ ;
+
+ private final String mString;
+
+ Reason(final String string) {
+ mString = string;
+ }
+
+ @Override
+ public String toString() {
+ return mString;
+ }
+ }
+}
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..d8d70f0c7c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java
@@ -0,0 +1,247 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.TelemetryContract.Event;
+import org.mozilla.gecko.TelemetryContract.Method;
+import org.mozilla.gecko.TelemetryContract.Reason;
+import org.mozilla.gecko.TelemetryContract.Session;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+/**
+ * All telemetry times are relative to one of two clocks:
+ *
+ * * 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!
+ *
+ * The majority of methods in this class are defined in terms of real time.
+ */
+public class TelemetryUtils {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "TelemetryUtils";
+
+ @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko")
+ private static native void nativeAddHistogram(String name, int value);
+ @WrapForJNI(stubName = "AddKeyedHistogram", dispatchTo = "gecko")
+ private static native void nativeAddKeyedHistogram(String name, String key, 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 static void addToKeyedHistogram(final String name, final String key, final int value) {
+ if (GeckoThread.isRunning()) {
+ nativeAddKeyedHistogram(name, key, value);
+ } else {
+ GeckoThread.queueNativeCall(TelemetryUtils.class, "nativeAddKeyedHistogram",
+ String.class, name, String.class, key, 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 RealtimeTimer extends Timer {
+ public RealtimeTimer(final String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return TelemetryUtils.realtime();
+ }
+ }
+
+ public static class UptimeTimer extends Timer {
+ public UptimeTimer(final String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return TelemetryUtils.uptime();
+ }
+ }
+
+ @WrapForJNI(stubName = "StartUISession", dispatchTo = "gecko")
+ private static native void nativeStartUiSession(String name, long timestamp);
+ @WrapForJNI(stubName = "StopUISession", dispatchTo = "gecko")
+ private static native void nativeStopUiSession(String name, String reason, long timestamp);
+ @WrapForJNI(stubName = "AddUIEvent", dispatchTo = "gecko")
+ private static native void nativeAddUiEvent(String action, String method,
+ long timestamp, String extras);
+
+ public static void startUISession(final Session session, final String sessionNameSuffix) {
+ final String sessionName = getSessionName(session, sessionNameSuffix);
+
+ Log.d(LOGTAG, "StartUISession: " + sessionName);
+ if (GeckoThread.isRunning()) {
+ nativeStartUiSession(sessionName, realtime());
+ } else {
+ GeckoThread.queueNativeCall(TelemetryUtils.class, "nativeStartUiSession",
+ String.class, sessionName, realtime());
+ }
+ }
+
+ public static void startUISession(final Session session) {
+ startUISession(session, null);
+ }
+
+ public static void stopUISession(final Session session, final String sessionNameSuffix,
+ final Reason reason) {
+ final String sessionName = getSessionName(session, sessionNameSuffix);
+
+ Log.d(LOGTAG, "StopUISession: " + sessionName + ", reason=" + reason);
+ if (GeckoThread.isRunning()) {
+ nativeStopUiSession(sessionName, reason.toString(), realtime());
+ } else {
+ GeckoThread.queueNativeCall(TelemetryUtils.class, "nativeStopUiSession",
+ String.class, sessionName,
+ String.class, reason.toString(), realtime());
+ }
+ }
+
+ public static void stopUISession(final Session session, final Reason reason) {
+ stopUISession(session, null, reason);
+ }
+
+ public static void stopUISession(final Session session, final String sessionNameSuffix) {
+ stopUISession(session, sessionNameSuffix, Reason.NONE);
+ }
+
+ public static void stopUISession(final Session session) {
+ stopUISession(session, null, Reason.NONE);
+ }
+
+ private static String getSessionName(final Session session, final String sessionNameSuffix) {
+ if (sessionNameSuffix != null) {
+ return session.toString() + ":" + sessionNameSuffix;
+ } else {
+ return session.toString();
+ }
+ }
+
+ /**
+ * @param method A non-null method (if null is desired, consider using Method.NONE)
+ */
+ /* package */ static void sendUIEvent(final String eventName, final Method method,
+ final long timestamp, final String extras) {
+ if (method == null) {
+ throw new IllegalArgumentException("Expected non-null method - use Method.NONE?");
+ }
+
+ if (DEBUG) {
+ final String logString = "SendUIEvent: event = " + eventName + " method = " + method + " timestamp = " +
+ timestamp + " extras = " + extras;
+ Log.d(LOGTAG, logString);
+ }
+ if (GeckoThread.isRunning()) {
+ nativeAddUiEvent(eventName, method.toString(), timestamp, extras);
+ } else {
+ GeckoThread.queueNativeCall(TelemetryUtils.class, "nativeAddUiEvent",
+ String.class, eventName, String.class, method.toString(),
+ timestamp, String.class, extras);
+ }
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final long timestamp,
+ final String extras) {
+ sendUIEvent(event.toString(), method, timestamp, extras);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final long timestamp) {
+ sendUIEvent(event, method, timestamp, null);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method, final String extras) {
+ sendUIEvent(event, method, realtime(), extras);
+ }
+
+ public static void sendUIEvent(final Event event, final Method method) {
+ sendUIEvent(event, method, realtime(), null);
+ }
+
+ public static void sendUIEvent(final Event event) {
+ sendUIEvent(event, Method.NONE, realtime(), null);
+ }
+
+ /**
+ * Sends a UIEvent with the given status appended to the event name.
+ *
+ * This method is a slight bend of the Telemetry framework so chances
+ * are that you don't want to use this: please think really hard before you do.
+ *
+ * Intended for use with data policy notifications.
+ */
+ public static void sendUIEvent(final Event event, final boolean eventStatus) {
+ final String eventName = event + ":" + eventStatus;
+ sendUIEvent(eventName, Method.NONE, realtime(), null);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java
new file mode 100644
index 0000000000..41a71dfa5f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java
@@ -0,0 +1,14 @@
+/* -*- 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.view.MotionEvent;
+import android.view.View;
+
+public interface TouchEventInterceptor extends View.OnTouchListener {
+ /** Override this method for a chance to consume events before the view or its children */
+ public boolean onInterceptTouchEvent(View view, MotionEvent event);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/WakeLockDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/WakeLockDelegate.java
new file mode 100644
index 0000000000..7088124a16
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/WakeLockDelegate.java
@@ -0,0 +1,51 @@
+/* -*- 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;
+
+/**
+ * A <code>WakeLockDelegate</code> is responsible for acquiring and release wake-locks.
+ */
+public interface WakeLockDelegate {
+ /**
+ * Wake-lock for the CPU.
+ */
+ final String LOCK_CPU = "cpu";
+ /**
+ * Wake-lock for the screen.
+ */
+ final String LOCK_SCREEN = "screen";
+ /**
+ * Wake-lock for the audio-playing, eqaul to LOCK_CPU.
+ */
+ final String LOCK_AUDIO_PLAYING = "audio-playing";
+ /**
+ * Wake-lock for the video-playing, eqaul to LOCK_SCREEN..
+ */
+ final String LOCK_VIDEO_PLAYING = "video-playing";
+
+ final int LOCKS_COUNT = 2;
+
+ /**
+ * No one holds the wake-lock.
+ */
+ final int STATE_UNLOCKED = 0;
+ /**
+ * The wake-lock is held by a foreground window.
+ */
+ final int STATE_LOCKED_FOREGROUND = 1;
+ /**
+ * The wake-lock is held by a background window.
+ */
+ final int STATE_LOCKED_BACKGROUND = 2;
+
+ /**
+ * Set a wake-lock to a specified state. Called from the Gecko thread.
+ *
+ * @param lock Wake-lock to set from one of the LOCK_* constants.
+ * @param state New wake-lock state from one of the STATE_* constants.
+ */
+ void setWakeLockState(String lock, int state);
+}
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..f333b869b7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java
@@ -0,0 +1,26 @@
+/* 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..e151306748
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.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.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..e0239175c1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java
@@ -0,0 +1,65 @@
+/* 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..138b4eea55
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java
@@ -0,0 +1,93 @@
+/* -*- 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.content.Context;
+import android.hardware.display.DisplayManager;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import androidx.annotation.RequiresApi;
+import android.view.Choreographer;
+import android.view.Display;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.GeckoAppShell;
+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() {
+ 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;
+ }
+
+ /**
+ * Gets the refresh rate of default display in frames per second.
+ *
+ * The {@link DisplayManager} used by this method to determine the refresh rate
+ * was introduced in API level 17.
+ *
+ * @return the refresh rate of default display in frames per second.
+ **/
+ @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1)
+ @WrapForJNI
+ public float getRefreshRate() {
+ DisplayManager dm = (DisplayManager)
+ GeckoAppShell.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+ return dm.getDisplay(Display.DEFAULT_DISPLAY).getRefreshRate();
+ }
+}
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..65e7a3f1c5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java
@@ -0,0 +1,136 @@
+/* -*- 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.Parcel;
+import android.os.Parcelable;
+import android.view.Surface;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import static org.mozilla.geckoview.BuildConfig.DEBUG_BUILD;
+
+public final class GeckoSurface extends Surface {
+ private static final String LOGTAG = "GeckoSurface";
+
+ private int mHandle;
+ private boolean mIsSingleBuffer;
+ private volatile boolean mIsAvailable;
+ private boolean mOwned = true;
+
+ 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.readInt();
+ 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.writeInt(mHandle);
+ out.writeByte((byte) (mIsSingleBuffer ? 1 : 0));
+ out.writeByte((byte) (mIsAvailable ? 1 : 0));
+ out.writeInt(mMyPid);
+ }
+
+ @Override
+ public void release() {
+ if (mSyncSurface != null) {
+ mSyncSurface.release();
+ GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(mSyncSurface.getHandle());
+ if (gst != null) {
+ gst.decrementUse();
+ }
+ mSyncSurface = null;
+ }
+
+ if (mOwned) {
+ super.release();
+ }
+ }
+
+ @WrapForJNI
+ public int getHandle() {
+ return mHandle;
+ }
+
+ @WrapForJNI
+ public boolean getAvailable() {
+ return mIsAvailable;
+ }
+
+ @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.");
+ }
+ 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..56ff587c04
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java
@@ -0,0 +1,327 @@
+/* -*- 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 androidx.annotation.RequiresApi;
+import android.util.Log;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.HashMap;
+import java.util.LinkedList;
+
+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 volatile int sNextHandle = 1;
+ private static final HashMap<Integer, GeckoSurfaceTexture> sSurfaceTextures = new HashMap<Integer, GeckoSurfaceTexture>();
+
+
+ private static HashMap<Long, LinkedList<GeckoSurfaceTexture>> sUnusedTextures =
+ new HashMap<Long, LinkedList<GeckoSurfaceTexture>>();
+
+ private int mHandle;
+ private boolean mIsSingleBuffer;
+
+ private long mAttachedContext;
+ private int mTexName;
+
+ private GeckoSurfaceTexture.Callbacks mListener;
+ private AtomicInteger mUseCount;
+ private boolean mFinalized;
+
+ private int mUpstream;
+ private NativeGLBlitHelper mBlitter;
+
+ private GeckoSurfaceTexture(final int handle) {
+ super(0);
+ init(handle, false);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.KITKAT)
+ private GeckoSurfaceTexture(final int 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 int handle, final boolean singleBufferMode) {
+ mHandle = handle;
+ mIsSingleBuffer = singleBufferMode;
+ mUseCount = new AtomicInteger(1);
+
+ // Start off detached
+ detachFromGLContext();
+ }
+
+ @WrapForJNI
+ public int 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 (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 (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 (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() {
+ 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) {
+ LinkedList<GeckoSurfaceTexture> list;
+ synchronized (sUnusedTextures) {
+ list = sUnusedTextures.remove(context);
+ }
+
+ if (list == null) {
+ return;
+ }
+
+ for (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 (Throwable t) {
+ Log.e(LOGTAG, "Failed to finalize SurfaceTexture", t);
+ }
+ } catch (Exception e) {
+ Log.e(LOGTAG, "Failed to destroy SurfaceTexture", e);
+ }
+ }
+ }
+
+ public static GeckoSurfaceTexture acquire(final boolean singleBufferMode, final int 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;
+ }
+
+ int resolvedHandle = handle;
+ if (resolvedHandle == 0) {
+ // Generate new handle value when none specified.
+ resolvedHandle = sNextHandle++;
+ }
+
+ final GeckoSurfaceTexture gst;
+ if (isSingleBufferSupported()) {
+ gst = new GeckoSurfaceTexture(resolvedHandle, singleBufferMode);
+ } else {
+ gst = new GeckoSurfaceTexture(resolvedHandle);
+ }
+
+ if (sSurfaceTextures.containsKey(resolvedHandle)) {
+ gst.release();
+ throw new IllegalArgumentException("Already have a GeckoSurfaceTexture with that handle");
+ }
+
+ sSurfaceTextures.put(resolvedHandle, gst);
+ return gst;
+ }
+ }
+
+ @WrapForJNI
+ public static GeckoSurfaceTexture lookup(final int handle) {
+ synchronized (sSurfaceTextures) {
+ return sSurfaceTextures.get(handle);
+ }
+ }
+
+ /* package */ synchronized void track(final int 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 int textureHandle,
+ final GeckoSurface targetSurface,
+ final int width,
+ final int height) {
+ NativeGLBlitHelper helper = nativeCreate(textureHandle, targetSurface, width, height);
+ helper.mTargetSurface = targetSurface; // Take ownership of surface.
+ return helper;
+ }
+
+ public native static NativeGLBlitHelper nativeCreate(final int 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..b8a4715672
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java
@@ -0,0 +1,73 @@
+/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget;
+
+import android.os.SystemClock;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.List;
+
+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/SurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java
new file mode 100644
index 0000000000..d16849cb4a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java
@@ -0,0 +1,127 @@
+/* -*- 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.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.GeckoAppShell;
+
+/* package */ final class SurfaceAllocator {
+ private static final String LOGTAG = "SurfaceAllocator";
+
+ private static SurfaceAllocatorConnection sConnection;
+
+ private static synchronized void ensureConnection() throws Exception {
+ if (sConnection != null) {
+ return;
+ }
+
+ sConnection = new SurfaceAllocatorConnection();
+ Intent intent = new Intent();
+ intent.setClassName(GeckoAppShell.getApplicationContext(),
+ "org.mozilla.gecko.gfx.SurfaceAllocatorService");
+
+ // FIXME: may not want to auto create
+ if (!GeckoAppShell.getApplicationContext().bindService(intent, sConnection, Context.BIND_AUTO_CREATE)) {
+ throw new Exception("Failed to connect to surface allocator service!");
+ }
+ }
+
+ @WrapForJNI
+ public static GeckoSurface acquireSurface(final int width, final int height,
+ final boolean singleBufferMode) {
+ try {
+ ensureConnection();
+
+ if (singleBufferMode && !GeckoSurfaceTexture.isSingleBufferSupported()) {
+ return null;
+ }
+ ISurfaceAllocator allocator = sConnection.getAllocator();
+ GeckoSurface surface = allocator.acquireSurface(width, height, singleBufferMode);
+ if (surface != null && !surface.inProcess()) {
+ allocator.configureSync(surface.initSyncSurface(width, height));
+ }
+ return surface;
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI
+ public static void disposeSurface(final GeckoSurface surface) {
+ try {
+ ensureConnection();
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Failed to dispose surface, no connection");
+ return;
+ }
+
+ // Release the SurfaceTexture on the other side
+ try {
+ sConnection.getAllocator().releaseSurface(surface.getHandle());
+ } catch (RemoteException e) {
+ Log.w(LOGTAG, "Failed to release surface texture", e);
+ }
+
+ // And now our Surface
+ try {
+ surface.release();
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Failed to release surface", e);
+ }
+ }
+
+ public static void sync(final int upstream) {
+ try {
+ ensureConnection();
+ } catch (Exception e) {
+ Log.w(LOGTAG, "Failed to sync texture, no connection");
+ return;
+ }
+
+ // Release the SurfaceTexture on the other side
+ try {
+ sConnection.getAllocator().sync(upstream);
+ } catch (RemoteException e) {
+ Log.w(LOGTAG, "Failed to sync texture", e);
+ }
+ }
+
+ private static final class SurfaceAllocatorConnection implements ServiceConnection {
+ private ISurfaceAllocator mAllocator;
+
+ public synchronized ISurfaceAllocator getAllocator() {
+ while (mAllocator == null) {
+ try {
+ this.wait();
+ } catch (InterruptedException e) { }
+ }
+
+ return mAllocator;
+ }
+
+ @Override
+ public synchronized void onServiceConnected(final ComponentName name,
+ final IBinder service) {
+ mAllocator = ISurfaceAllocator.Stub.asInterface(service);
+ this.notifyAll();
+ }
+
+ @Override
+ public synchronized void onServiceDisconnected(final ComponentName name) {
+ mAllocator = null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocatorService.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocatorService.java
new file mode 100644
index 0000000000..d2eb579c30
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocatorService.java
@@ -0,0 +1,66 @@
+/* -*- 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.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.IBinder;
+
+public final class SurfaceAllocatorService extends Service {
+
+ private static final String LOGTAG = "SurfaceAllocatorService";
+
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ return Service.START_STICKY;
+ }
+
+ private Binder mBinder = new ISurfaceAllocator.Stub() {
+ public GeckoSurface acquireSurface(final int width, final int height,
+ final boolean singleBufferMode) {
+ GeckoSurfaceTexture gst = GeckoSurfaceTexture.acquire(singleBufferMode, 0);
+
+ if (gst == null) {
+ return null;
+ }
+
+ if (width > 0 && height > 0) {
+ gst.setDefaultBufferSize(width, height);
+ }
+
+ return new GeckoSurface(gst);
+ }
+
+ public void releaseSurface(final int handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.decrementUse();
+ }
+ }
+
+ public void configureSync(final SyncConfig config) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(config.sourceTextureHandle);
+ if (gst != null) {
+ gst.configureSnapshot(config.targetSurface, config.width, config.height);
+ }
+ }
+
+ public void sync(final int handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.takeSnapshot();
+ }
+ }
+ };
+
+ public IBinder onBind(final Intent intent) {
+ return mBinder;
+ }
+
+ public boolean onUnbind(final Intent intent) {
+ return false;
+ }
+}
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..d196754dc8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java
@@ -0,0 +1,39 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+import android.graphics.SurfaceTexture;
+
+/* 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..ed12791a9f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java
@@ -0,0 +1,54 @@
+package org.mozilla.gecko.gfx;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+/* package */ final class SyncConfig implements Parcelable {
+ final int sourceTextureHandle;
+ final GeckoSurface targetSurface;
+ final int width;
+ final int height;
+
+ /* package */ SyncConfig(final int 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.readInt();
+ 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.writeInt(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..a08735f956
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java
@@ -0,0 +1,42 @@
+/* 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 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..a28129c9c0
--- /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..62183d41e8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java
@@ -0,0 +1,97 @@
+/* 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..2333dc7397
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
@@ -0,0 +1,686 @@
+/* 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) {
+ 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) {
+ 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 (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private synchronized void onBuffer(final int index) {
+ if (mStopped || !isValidBuffer(index)) {
+ return;
+ }
+
+ if (!mHasInputCapacitySet) {
+ 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 (IllegalStateException e) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "invalid input buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private void feedSampleToBuffer() {
+ while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) {
+ int index = mAvailableInputBuffers.poll();
+ if (!isValidBuffer(index)) {
+ continue;
+ }
+ int len = 0;
+ final Sample sample = mInputSamples.poll().sample;
+ long pts = sample.info.presentationTimeUs;
+ int flags = sample.info.flags;
+ MediaCodec.CryptoInfo cryptoInfo = sample.cryptoInfo;
+ if (!sample.isEOS() && sample.bufferId != Sample.NO_BUFFER) {
+ len = sample.info.size;
+ ByteBuffer buf = mCodec.getInputBuffer(index);
+ try {
+ mSamplePool.getInputBuffer(sample.bufferId).
+ writeToByteBuffer(buf, sample.info.offset, len);
+ } catch (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 (RemoteException e) {
+ e.printStackTrace();
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ return;
+ }
+ }
+ reportPendingInputs();
+ }
+
+ private void reportPendingInputs() {
+ try {
+ for (Input i : mInputSamples) {
+ if (!i.reported) {
+ i.reported = true;
+ mCallbacks.onInputPending(i.sample.info.presentationTimeUs);
+ }
+ }
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private synchronized void reset() {
+ for (Input i : mInputSamples) {
+ if (!i.sample.isEOS()) {
+ mSamplePool.recycleInput(i.sample);
+ }
+ }
+ mInputSamples.clear();
+
+ for (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 {
+ Sample output = obtainOutputSample(index, info);
+ mSentOutputs.add(new Output(output, index));
+ output.session = mSession;
+ mCallbacks.onOutput(output);
+ } catch (Exception e) {
+ e.printStackTrace();
+ mCodec.releaseOutputBuffer(index, false);
+ }
+
+ 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 (IllegalStateException e) {
+ if (DEBUG) {
+ Log.e(LOGTAG, "invalid buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) {
+ Sample sample = mSamplePool.obtainOutput(info);
+
+ if (mRenderToSurface) {
+ return sample;
+ }
+
+ ByteBuffer output = mCodec.getOutputBuffer(index);
+ if (!mHasOutputCapacitySet) {
+ 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 (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 (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 (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;
+ 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 (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 (NullPointerException ne) {
+ // mCallbacks has been disposed by release().
+ } catch (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 (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 (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized Sample dequeueInput(final int size) throws RemoteException {
+ try {
+ return mInputProcessor.onAllocate(size);
+ } catch (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 (Exception e) {
+ throw new RemoteException(e.getMessage());
+ }
+ }
+
+ @Override
+ public synchronized void setBitrate(final int bps) {
+ try {
+ mCodec.setBitrate(bps);
+ } catch (Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void releaseOutput(final Sample sample, final boolean render) {
+ try {
+ mOutputProcessor.onRelease(sample, render);
+ } catch (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 (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..43ba58cd51
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java
@@ -0,0 +1,457 @@
+/* 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.DeadObjectException;
+import android.os.RemoteException;
+import android.util.Log;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.GeckoSurface;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+// 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 Map<Integer, SampleBuffer> mInputBuffers = new HashMap<>();
+ private Map<Integer, SampleBuffer> mOutputBuffers = new HashMap<>();
+
+ 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;
+ }
+
+ 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 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 (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ mRemote = remote;
+ return true;
+ }
+
+ boolean deinit() {
+ try {
+ mRemote.stop();
+ mRemote.release();
+ mRemote = null;
+ return true;
+ } catch (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 (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 (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 (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;
+ }
+
+ boolean eos = info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM;
+
+ if (eos) {
+ return sendInput(Sample.EOS);
+ }
+
+ try {
+ Sample s = mRemote.dequeueInput(info.size);
+ fillInputBuffer(s.bufferId, bytes, info.offset, info.size);
+ mSession = s.session;
+ return sendInput(s.set(info, cryptoInfo));
+ } catch (RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "fail to dequeue input buffer", e);
+ } catch (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) {
+ 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 (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 (DeadObjectException e) {
+ return false;
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+
+ private void resetBuffers() {
+ for (SampleBuffer b : mInputBuffers.values()) {
+ b.dispose();
+ }
+ mInputBuffers.clear();
+ for (SampleBuffer b : mOutputBuffers.values()) {
+ b.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 (Sample s : mSurfaceOutputs) {
+ mRemote.releaseOutput(s, true);
+ }
+ } catch (RemoteException e) {
+ e.printStackTrace();
+ }
+ mSurfaceOutputs.clear();
+ }
+
+ resetBuffers();
+
+ try {
+ RemoteManager.getInstance().releaseCodec(this);
+ } catch (DeadObjectException e) {
+ return false;
+ } catch (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 (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 (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 (Exception e) {
+ Log.e(LOGTAG, "cannot get buffer#" + id, e);
+ return null;
+ }
+ if (buffer != null) {
+ mOutputBuffers.put(id, buffer);
+ }
+
+ return buffer;
+ }
+}
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..9cae492f73
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java
@@ -0,0 +1,173 @@
+/* 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>
+ * <li>{@link MediaFormat#KEY_WIDTH}</li>
+ * <li>{@link MediaFormat#KEY_HEIGHT}</li>
+ * <li>{@link MediaFormat#KEY_CHANNEL_COUNT}</li>
+ * <li>{@link MediaFormat#KEY_SAMPLE_RATE}</li>
+ * <li>{@link MediaFormat#KEY_BIT_RATE}</li>
+ * <li>{@link MediaFormat#KEY_BITRATE_MODE}</li>
+ * <li>{@link MediaFormat#KEY_COLOR_FORMAT}</li>
+ * <li>{@link MediaFormat#KEY_FRAME_RATE}</li>
+ * <li>{@link MediaFormat#KEY_I_FRAME_INTERVAL}</li>
+ * <li>"csd-0"</li>
+ * <li>"csd-1"</li>
+ * </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) {
+ 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));
+ }
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeBundle(toBundle());
+ }
+
+ private Bundle toBundle() {
+ 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)) {
+ ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0);
+ bundle.putByteArray(KEY_CONFIG_0,
+ Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_1)) {
+ 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));
+ }
+ 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..a0a65daba3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java
@@ -0,0 +1,30 @@
+/* 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 {
+ final public byte[] codecSpecificData;
+ final public int rate;
+ final public int channels;
+ final public int bitDepth;
+ final public int profile;
+ final public long duration;
+ final public 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..34e4630072
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java
@@ -0,0 +1,167 @@
+/* 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.geckoview.BuildConfig;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+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);
+ 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);
+ 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);
+ 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 (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..8b33ad0768
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java
@@ -0,0 +1,120 @@
+/* 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.geckoview.BuildConfig;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+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 (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..ad92864f31
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java
@@ -0,0 +1,86 @@
+/* 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+public final class GeckoHLSSample {
+ public static final GeckoHLSSample EOS;
+ static {
+ 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
+ final public int formatIndex;
+
+ @WrapForJNI
+ public long duration;
+
+ @WrapForJNI
+ final public BufferInfo info;
+
+ @WrapForJNI
+ final public 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";
+ }
+
+ 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..40d18a11f8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java
@@ -0,0 +1,168 @@
+/* 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 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;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+
+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.
+ */
+ String mimeType = format.sampleMimeType;
+ if (!MimeTypes.isAudio(mimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+ List<MediaCodecInfo> decoderInfos = null;
+ try {
+ MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (decoderInfos == null || decoderInfos.isEmpty()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ }
+ 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.
+ */
+ 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) {
+ int size = bufferForRead.data.limit();
+ byte[] realData = new byte[size];
+ bufferForRead.data.get(realData, 0, size);
+ ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ CryptoInfo cryptoInfo = bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ 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.
+ 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..6781bcae60
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java
@@ -0,0 +1,1008 @@
+/* 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 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;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.geckoview.BuildConfig;
+
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicInteger;
+
+@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());
+ if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) {
+ // Don't report non-media URLs.
+ 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);
+ 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 (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 + "]");
+
+ 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++) {
+ TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex);
+ TrackSelection trackSelection = trackSelections.get(rendererIndex);
+ if (rendererTrackGroups.length > 0) {
+ Log.d(LOGTAG, " Renderer:" + rendererIndex + " [");
+ for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) {
+ TrackGroup trackGroup = rendererTrackGroups.get(groupIndex);
+ 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++) {
+ String status = getTrackStatusString(trackSelection, trackGroup, trackIndex);
+ 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.
+ 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 + " [");
+ TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex);
+ for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) {
+ String status = getTrackStatusString(false);
+ 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++) {
+ TrackGroup tg = ignored.get(j);
+ for (int i = 0; i < tg.length; i++) {
+ 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.
+ Timeline.Window window = new Timeline.Window();
+ mIsTimelineStatic = !timeline.isEmpty()
+ && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic;
+
+ int periodCount = timeline.getPeriodCount();
+ int windowCount = timeline.getWindowCount();
+ if (DEBUG) {
+ Log.d(LOGTAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount);
+ }
+ 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());
+
+ Context ctx = GeckoAppShell.getApplicationContext();
+ mComponentListener = new ComponentListener();
+ mComponentEventDispatcher = new ComponentEventDispatcher();
+ mDurationUs = 0;
+
+ // Prepare trackSelector
+ 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;
+
+ 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);
+
+ 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.
+ 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) {
+ 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;
+ }
+ }
+ 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) {
+ 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.
+ byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0);
+ 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 (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 (Exception e) {
+ if (mDemuxerCallbacks != null) {
+ mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code());
+ }
+ return false;
+ }
+ return true;
+ });
+ }
+
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public synchronized long getNextKeyFrameTime() {
+ 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 {
+ FutureTask<T> wait = new FutureTask<T>(task);
+ mMainHandler.post(wait);
+ return wait.get();
+ } catch (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..3797a98d5e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java
@@ -0,0 +1,335 @@
+/* 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.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.decoder.DecoderInputBuffer;
+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 java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.Iterator;
+
+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;
+ }
+
+ Iterator<GeckoHLSSample> iter = mDemuxedInputSamples.iterator();
+ long firstPTS = 0;
+ if (iter.hasNext()) {
+ GeckoHLSSample sample = iter.next();
+ firstPTS = sample.info.presentationTimeUs;
+ }
+ long lastPTS = firstPTS;
+ while (iter.hasNext()) {
+ 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);
+ 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) {
+ ConcurrentLinkedQueue<GeckoHLSSample> samples =
+ new ConcurrentLinkedQueue<GeckoHLSSample>();
+
+ GeckoHLSSample sample = null;
+ 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) {
+ Object oldDrmInit = oldFormat == null ? null : oldFormat.drmInitData;
+ 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 (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 (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 (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();
+ 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..ef314042de
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java
@@ -0,0 +1,505 @@
+/* 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 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.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.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+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 {
+ MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (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 (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 (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.
+ 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 (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");
+ }
+ Format currentFormat = mFormats.get(mFormats.size() - 1);
+ for (int i = 0; i < currentFormat.initializationData.size(); i++) {
+ 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;
+ GeckoHLSSample sample = GeckoHLSSample.EOS;
+ calculatDuration(sample);
+ }
+
+ @Override
+ protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) {
+ int csdInfoSize = mCSDInfo != null ? mCSDInfo.length : 0;
+ int dataSize = bufferForRead.data.limit();
+ int size = bufferForRead.isKeyFrame() ? csdInfoSize + dataSize : dataSize;
+ 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);
+ }
+ ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ CryptoInfo cryptoInfo = bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ 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.
+ 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) {
+ 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++) {
+ 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);
+ }
+ int sizeOfNoDura = mDemuxedNoDurationSamples.size();
+ // A calculation window we've ever found suitable for both HLS TS & FMP4.
+ int range = sizeOfNoDura >= 17 ? 17 : sizeOfNoDura;
+ GeckoHLSSample[] inputArray =
+ mDemuxedNoDurationSamples.toArray(new GeckoHLSSample[sizeOfNoDura]);
+ if (range >= 17 && !mInputStreamEnded) {
+ calculateSamplesWithin(inputArray, range);
+
+ 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 (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 (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.
+ int maxPixels;
+ 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..e8f285bfcd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.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 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..3ba59bfd67
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java
@@ -0,0 +1,690 @@
+/* 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.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.util.HashMap;
+import java.util.HashSet;
+import java.util.UUID;
+import java.util.ArrayDeque;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.media.DeniedByServerException;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.media.NotProvisionedException;
+import android.util.Log;
+
+import org.mozilla.gecko.util.StringUtils;
+import org.mozilla.gecko.util.ProxySelector;
+
+@TargetApi(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};
+
+ 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 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;
+ }
+ }
+
+ 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("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 (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 {
+ 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;
+ }
+
+ 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(), StringUtils.UTF_8) + " is put into mSessionIds ");
+ } catch (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;
+ }
+
+ ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(StringUtils.UTF_8));
+ if (!sessionExists(session)) {
+ onRejectPromise(promiseId, "Invalid session during updateSession.");
+ return;
+ }
+
+ try {
+ final byte [] keySetId = mDrm.provideKeyResponse(session.array(), response);
+ if (DEBUG) {
+ HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array());
+ for (String strKey : infoMap.keySet()) {
+ 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;
+ }
+
+ ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(StringUtils.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;
+ }
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (pendingData != null) {
+ onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions.");
+ }
+ }
+ mPendingCreateSessionDataQueue = null;
+
+ if (mDrm != null) {
+ for (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) {
+ 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) {
+ // Now provisioning.
+ return null;
+ }
+
+ try {
+ HashMap<String, String> optionalParameters = new HashMap<String, String>();
+ return mDrm.getKeyRequest(aSession.array(),
+ data,
+ mimeType,
+ MediaDrm.KEY_TYPE_STREAMING,
+ optionalParameters);
+ } catch (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;
+ }
+ 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");
+ // No need to handle here if we're not in privacy mode.
+ break;
+ case MediaDrm.EVENT_KEY_EXPIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + new String(session.array(), StringUtils.UTF_8));
+ break;
+ case MediaDrm.EVENT_VENDOR_DEFINED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + new String(session.array(), StringUtils.UTF_8));
+ break;
+ default:
+ if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event);
+ return;
+ }
+ }
+ }
+
+ private ByteBuffer openSession() throws android.media.NotProvisionedException {
+ try {
+ 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 (android.media.NotProvisionedException e) {
+ // Throw NotProvisionedException so that we can startProvisioning().
+ throw e;
+ } catch (java.lang.RuntimeException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage());
+ release();
+ return null;
+ } catch (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 {
+ 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();
+
+ int responseCode = urlConnection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), StringUtils.UTF_8));
+ String inputLine;
+ StringBuffer response = new StringBuffer();
+
+ while ((inputLine = in.readLine()) != null) {
+ response.append(inputLine);
+ }
+ in.close();
+ mResponseBody = String.valueOf(response).getBytes(StringUtils.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 (IOException e) {
+ Log.e(LOGTAG, "Got exception during posting provisioning request ...", e);
+ } catch (URISyntaxException e) {
+ Log.e(LOGTAG, "Got exception during creating uri ...", e);
+ } finally {
+ if (urlConnection != null) {
+ urlConnection.disconnect();
+ }
+ try {
+ if (in != null) {
+ in.close();
+ }
+ } catch (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 (android.media.DeniedByServerException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ } catch (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()) {
+ 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 (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() {
+ processPendingCreateSessionData();
+ }
+ });
+ }
+
+ // 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;
+ MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest();
+ mProvisionTask =
+ new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData());
+ mProvisionTask.execute();
+ } catch (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;
+ 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, StringUtils.UTF_8));
+ return true;
+ } else {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme.");
+ return false;
+ }
+ } catch (android.media.MediaCryptoException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage());
+ release();
+ return false;
+ } catch (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.
+ 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..0be7a5b92c
--- /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 android.annotation.TargetApi;
+
+import static android.os.Build.VERSION_CODES.M;
+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;
+ }
+ SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()];
+ for (int i = 0; i < keyInformation.size(); i++) {
+ 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..01e7c5c793
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java
@@ -0,0 +1,44 @@
+/* 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 androidx.annotation.NonNull;
+import android.util.Log;
+
+import java.util.ArrayList;
+
+public final class GeckoPlayerFactory {
+ public static final ArrayList<BaseHlsPlayer> sPlayerList = new ArrayList<BaseHlsPlayer>();
+
+ synchronized static BaseHlsPlayer getPlayer() {
+ try {
+ final Class<?> cls = Class.forName("org.mozilla.gecko.media.GeckoHlsPlayer");
+ BaseHlsPlayer player = (BaseHlsPlayer) cls.newInstance();
+ sPlayerList.add(player);
+ return player;
+ } catch (Exception e) {
+ Log.e("GeckoPlayerFactory", "Class GeckoHlsPlayer not found or failed to create", e);
+ }
+ return null;
+ }
+
+ synchronized static BaseHlsPlayer getPlayer(final int id) {
+ for (BaseHlsPlayer player : sPlayerList) {
+ if (player.getId() == id) {
+ return player;
+ }
+ }
+ Log.w("GeckoPlayerFactory", "No player found with id : " + id);
+ return null;
+ }
+
+ synchronized static void removePlayer(final @NonNull BaseHlsPlayer player) {
+ 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..2a06df3aec
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java
@@ -0,0 +1,38 @@
+/* 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 {
+ final public byte[] codecSpecificData;
+ final public byte[] extraData;
+ final public int displayWidth;
+ final public int displayHeight;
+ final public int pictureWidth;
+ final public int pictureHeight;
+ final public int rotation;
+ final public int stereoMode;
+ final public long duration;
+ final public 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..8c5010b173
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
@@ -0,0 +1,481 @@
+/* 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.util.HardwareCodecCapabilityUtils;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+// 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;
+ }
+
+ 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;
+ }
+
+ 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 (IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ }
+
+ return true;
+ }
+
+ private void pollInputBuffer() {
+ 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;
+ MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ 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;
+ }
+ 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 (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) {
+ 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)) {
+ 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 (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 (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 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..fe288a916b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java
@@ -0,0 +1,235 @@
+/* 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.util.HardwareCodecCapabilityUtils;
+
+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 androidx.annotation.NonNull;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+@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 (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 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) {
+ 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..5e2daad4f6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java
@@ -0,0 +1,328 @@
+/* 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.ArrayList;
+import java.util.UUID;
+
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import android.annotation.SuppressLint;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.os.Build;
+import android.util.Log;
+
+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) {
+ 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();
+ IMediaDrmBridge remoteBridge =
+ RemoteManager.getInstance().createRemoteMediaDrmBridge(keySystem, mDrmStubId);
+ mImpl = new RemoteMediaDrmBridge(remoteBridge);
+ mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks));
+ sProxyList.add(this);
+ } catch (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 (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 (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..746464ae8c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java
@@ -0,0 +1,78 @@
+/* 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.geckoview.BuildConfig;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+
+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 {
+ 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..675c44f3aa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java
@@ -0,0 +1,253 @@
+/* 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.GeckoAppShell;
+import org.mozilla.gecko.TelemetryUtils;
+
+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.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 synchronized static 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 (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() {
+ 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 (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 (InterruptedException e) {
+ if (DEBUG) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ private synchronized void unlink() {
+ if (mRemote == null) {
+ return;
+ }
+ try {
+ mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0);
+ } catch (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 {
+ ICodec remote = mRemote.createCodec();
+ CodecProxy proxy = CodecProxy.createCodecProxy(isEncoder, format, surface, callbacks, drmStubId);
+ if (proxy.init(remote)) {
+ mCodecs.add(proxy);
+ return proxy;
+ } else {
+ return null;
+ }
+ } catch (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 {
+ IMediaDrmBridge remoteBridge =
+ mRemote.createRemoteMediaDrmBridge(keySystem, stubId);
+ mDrmBridges.add(remoteBridge);
+ return remoteBridge;
+ } catch (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 (CodecProxy proxy : mCodecs) {
+ proxy.reportError(fatal);
+ }
+ }
+
+ private synchronized boolean recoverRemoteCodec() {
+ if (DEBUG) Log.d(LOGTAG, "recover codec");
+ boolean ok = true;
+ try {
+ for (CodecProxy proxy : mCodecs) {
+ ok &= proxy.init(mRemote.createCodec());
+ }
+ return ok;
+ } catch (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 (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();
+ 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 (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..019092e52b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
@@ -0,0 +1,164 @@
+/* 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 (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 (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 (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 (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 (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 (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..131027a02d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
@@ -0,0 +1,258 @@
+/* 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.ArrayList;
+
+import android.media.MediaCrypto;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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 (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..5532878066
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java
@@ -0,0 +1,231 @@
+/* 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+import java.nio.ByteBuffer;
+
+// Parcelable carrying input/output sample data and info cross process.
+public final class Sample implements Parcelable {
+ public static final Sample EOS;
+ static {
+ 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) {
+ int offset = in.readInt();
+ int size = in.readInt();
+ long pts = in.readLong();
+ int flags = in.readInt();
+
+ info.set(offset, size, pts, flags);
+ }
+
+ private void readCrypto(final Parcel in) {
+ int hasCryptoInfo = in.readInt();
+ if (hasCryptoInfo == 0) {
+ cryptoInfo = null;
+ return;
+ }
+
+ byte[] iv = in.createByteArray();
+ byte[] key = in.createByteArray();
+ int mode = in.readInt();
+ int[] numBytesOfClearData = in.createIntArray();
+ int[] numBytesOfEncryptedData = in.createIntArray();
+ 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) {
+ 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();
+ }
+ int length = Math.min(offset + size, buffer.capacity()) - offset;
+ byte[] bytes = new byte[length];
+ buffer.position(offset);
+ buffer.get(bytes);
+ return bytes;
+ }
+
+ @Override
+ public String toString() {
+ if (isEOS()) {
+ return "EOS sample";
+ }
+
+ 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..3238185bb0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java
@@ -0,0 +1,99 @@
+/* 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;
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+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 (NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private native static 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 (NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private native static 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..c023704276
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
@@ -0,0 +1,156 @@
+/* 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 org.mozilla.gecko.mozglue.SharedMemory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+
+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 Map<Integer, SampleBuffer> mBuffers = new HashMap<>();
+
+ 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 (NoSuchMethodException | IOException e) {
+ mBuffers.remove(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 (Sample s : mRecycledSamples) {
+ disposeSample(s);
+ }
+ mRecycledSamples.clear();
+
+ for (SampleBuffer b: mBuffers.values()) {
+ b.dispose();
+ }
+ mBuffers.clear();
+ }
+
+ private void disposeSample(final Sample sample) {
+ if (sample.bufferId != Sample.NO_BUFFER) {
+ mBuffers.remove(sample.bufferId).dispose();
+ }
+ 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) {
+ Sample input = mInputs.obtain(size);
+ input.info.set(0, 0, 0, 0);
+ return input;
+ }
+
+ /* package */ Sample obtainOutput(final MediaCodec.BufferInfo info) {
+ 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..fb0e35bcf3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java
@@ -0,0 +1,51 @@
+/* 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..d13d6560d8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java
@@ -0,0 +1,41 @@
+/* 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() {
+ Thread t = Thread.currentThread();
+ return t.getId();
+ }
+
+ public static String getThreadSignature() {
+ Thread t = Thread.currentThread();
+ long l = t.getId();
+ String name = t.getName();
+ long p = t.getPriority();
+ String gname = t.getThreadGroup().getName();
+ return (name
+ + ":(id)" + l
+ + ":(priority)" + p
+ + ":(group)" + gname);
+ }
+
+ public static void logThreadSignature() {
+ Log.d("ThreadUtils", getThreadSignature());
+ }
+
+ private final static char[] hexArray = "0123456789ABCDEF".toCharArray();
+ public static String bytesToHex(final byte[] bytes) {
+ char[] hexChars = new char[bytes.length * 2];
+ for ( int j = 0; j < bytes.length; j++ ) {
+ 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/ByteBufferInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java
new file mode 100644
index 0000000000..b576e816b6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java
@@ -0,0 +1,64 @@
+/* -*- 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;
+
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+class ByteBufferInputStream extends InputStream {
+
+ protected ByteBuffer mBuf;
+ // Reference to a native object holding the data backing the ByteBuffer.
+ private final NativeReference mNativeRef;
+
+ protected ByteBufferInputStream(final ByteBuffer buffer, final NativeReference ref) {
+ mBuf = buffer;
+ mNativeRef = ref;
+ }
+
+ @Override
+ public int available() {
+ return mBuf.remaining();
+ }
+
+ @Override
+ public void close() {
+ // Do nothing, we need to keep the native references around for child
+ // buffers.
+ }
+
+ @Override
+ public int read() {
+ if (!mBuf.hasRemaining() || mNativeRef.isReleased()) {
+ return -1;
+ }
+
+ return mBuf.get() & 0xff; // Avoid sign extension
+ }
+
+ @Override
+ public int read(final byte[] buffer, final int offset, final int length) {
+ if (!mBuf.hasRemaining() || mNativeRef.isReleased()) {
+ return -1;
+ }
+
+ int remainingLength = Math.min(length, mBuf.remaining());
+ mBuf.get(buffer, offset, remainingLength);
+ return length;
+ }
+
+ @Override
+ public long skip(final long byteCount) {
+ if (byteCount < 0 || mNativeRef.isReleased()) {
+ return 0;
+ }
+
+ long remainingByteCount = Math.min(byteCount, mBuf.remaining());
+ mBuf.position(mBuf.position() + (int) remainingByteCount);
+ return remainingByteCount;
+ }
+
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java
new file mode 100644
index 0000000000..c8baa0506c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java
@@ -0,0 +1,52 @@
+/* -*- 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 java.nio.ByteBuffer;
+
+//
+// We must manually allocate direct buffers in JNI to work around a bug where Honeycomb's
+// ByteBuffer.allocateDirect() grossly overallocates the direct buffer size.
+// https://code.google.com/p/android/issues/detail?id=16941
+//
+
+public final class DirectBufferAllocator {
+ private DirectBufferAllocator() {}
+
+ public static ByteBuffer allocate(final int size) {
+ if (size <= 0) {
+ throw new IllegalArgumentException("Invalid size " + size);
+ }
+
+ ByteBuffer directBuffer = nativeAllocateDirectBuffer(size);
+ if (directBuffer == null) {
+ throw new OutOfMemoryError("allocateDirectBuffer() returned null");
+ }
+
+ if (!directBuffer.isDirect()) {
+ throw new AssertionError("allocateDirectBuffer() did not return a direct buffer");
+ }
+
+ return directBuffer;
+ }
+
+ public static ByteBuffer free(final ByteBuffer buffer) {
+ if (buffer == null) {
+ return null;
+ }
+
+ if (!buffer.isDirect()) {
+ throw new IllegalArgumentException("buffer must be direct");
+ }
+
+ nativeFreeDirectBuffer(buffer);
+ return null;
+ }
+
+ // These JNI methods are implemented in mozglue/android/nsGeckoUtils.cpp.
+ private static native ByteBuffer nativeAllocateDirectBuffer(long size);
+ private static native void nativeFreeDirectBuffer(ByteBuffer buf);
+}
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..e279827adb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java
@@ -0,0 +1,516 @@
+/* -*- 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 org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.HardwareUtils;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+import android.util.Log;
+
+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;
+
+public final class GeckoLoader {
+ private static final String LOGTAG = "GeckoLoader";
+
+ private static File sCacheFile;
+ 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 getCacheDir(final Context context) {
+ if (sCacheFile == null) {
+ sCacheFile = context.getCacheDir();
+ }
+ return sCacheFile;
+ }
+
+ 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 (Exception e) {
+ Log.w(LOGTAG, "No download directory found.", e);
+ }
+ }
+
+ private static void delTree(final File file) {
+ if (file.isDirectory()) {
+ File children[] = file.listFiles();
+ for (File child : children) {
+ delTree(child);
+ }
+ }
+ file.delete();
+ }
+
+ private static File getTmpDir(final Context context) {
+ File tmpDir = context.getDir("tmpdir", Context.MODE_PRIVATE);
+ // check if the old tmp dir is there
+ File oldDir = new File(tmpDir.getParentFile(), "app_tmp");
+ if (oldDir.exists()) {
+ delTree(oldDir);
+ }
+ return tmpDir;
+ }
+
+ 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 synchronized static void setupGeckoEnvironment(final Context context,
+ final String profilePath,
+ final Collection<String> env,
+ final Map<String, Object> prefs) {
+ for (final String e : env) {
+ putenv(e);
+ }
+
+ try {
+ final File dataDir = new File(context.getApplicationInfo().dataDir);
+ putenv("MOZ_ANDROID_DATA_DIR=" + dataDir.getCanonicalPath());
+ } catch (final java.io.IOException e) {
+ Log.e(LOGTAG, "Failed to resolve app data directory");
+ }
+
+ putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName());
+
+ setupDownloadEnvironment(context);
+
+ // profile home path
+ putenv("HOME=" + profilePath);
+
+ // setup the tmp path
+ File f = getTmpDir(context);
+ if (!f.exists()) {
+ f.mkdirs();
+ }
+ putenv("TMPDIR=" + f.getPath());
+
+ // setup the downloads path
+ 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) {
+ 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);
+ }
+ }
+
+ 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);
+
+ setupInitialPrefs(prefs);
+
+ // env from extras could have reset out linker flags; set them again.
+ loadLibsSetupLocked(context);
+ }
+
+ private static void loadLibsSetupLocked(final Context context) {
+ putenv("GRE_HOME=" + getGREDir(context).getPath());
+ putenv("MOZ_ANDROID_LIBDIR=" + context.getApplicationInfo().nativeLibraryDir);
+ }
+
+ @RobocopTarget
+ public synchronized static void loadSQLiteLibs(final Context context) {
+ if (sSQLiteLibsLoaded) {
+ return;
+ }
+
+ loadMozGlue(context);
+ loadLibsSetupLocked(context);
+ loadSQLiteLibsNative();
+ sSQLiteLibsLoaded = true;
+ }
+
+ public synchronized static 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.
+ 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) {
+ String[] abis = Build.SUPPORTED_ABIS;
+ for (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 (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 (Exception e) {
+ Log.e(LOGTAG, "Failed to extract lib from APK.", e);
+ return false;
+ }
+ }
+
+ private static String getLoadDiagnostics(final Context context, final String lib) {
+ final String androidPackageName = context.getPackageName();
+
+ final StringBuilder message = new StringBuilder("LOAD ");
+ message.append(lib);
+
+ final String packageDataDir = context.getApplicationInfo().dataDir;
+
+ // These might differ. If so, we know why the library won't load!
+ HardwareUtils.init(context);
+ message.append(": ABI: " + HardwareUtils.getLibrariesABI() + ", " + getCPUABI());
+ message.append(": Data: " + packageDataDir);
+
+ try {
+ final boolean appLibExists = new File("/data/app-lib/" + androidPackageName + "/lib" + lib + ".so").exists();
+ final boolean dataDataExists = new File(packageDataDir + "/lib/lib" + lib + ".so").exists();
+ message.append(", ax=" + appLibExists);
+ message.append(", ddx=" + dataDataExists);
+ } catch (Throwable e) {
+ message.append(": ax/ddx fail, ");
+ }
+
+ try {
+ final String dashOne = packageDataDir + "-1";
+ final String dashTwo = packageDataDir + "-2";
+ final boolean dashOneExists = new File(dashOne).exists();
+ final boolean dashTwoExists = new File(dashTwo).exists();
+ message.append(", -1x=" + dashOneExists);
+ message.append(", -2x=" + dashTwoExists);
+ } catch (Throwable e) {
+ message.append(", dash fail, ");
+ }
+
+ try {
+ final String nativeLibPath = context.getApplicationInfo().nativeLibraryDir;
+ final boolean nativeLibDirExists = new File(nativeLibPath).exists();
+ final boolean nativeLibLibExists = new File(nativeLibPath + "/lib" + lib + ".so").exists();
+
+ message.append(", nativeLib: " + nativeLibPath);
+ message.append(", dirx=" + nativeLibDirExists);
+ message.append(", libx=" + nativeLibLibExists);
+ } catch (Throwable e) {
+ message.append(", nativeLib fail.");
+ }
+
+ return message.toString();
+ }
+
+ private static boolean attemptLoad(final String path) {
+ try {
+ System.load(path);
+ return true;
+ } catch (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.
+ *
+ * Returns null or the cause exception.
+ */
+ private static Throwable doLoadLibraryExpected(final Context context, final String lib) {
+ try {
+ // Attempt 1: the way that should work.
+ System.loadLibrary(lib);
+ return null;
+ } catch (Throwable e) {
+ Log.wtf(LOGTAG, "Couldn't load " + lib + ". Trying native library dir.");
+
+ // Attempt 2: use nativeLibraryDir, which should also work.
+ final String libDir = context.getApplicationInfo().nativeLibraryDir;
+ final String libPath = libDir + "/lib" + lib + ".so";
+
+ // Does it even exist?
+ if (new File(libPath).exists()) {
+ if (attemptLoad(libPath)) {
+ // Success!
+ return null;
+ }
+ Log.wtf(LOGTAG, "Library exists but couldn't load!");
+ } else {
+ Log.wtf(LOGTAG, "Library doesn't exist when it should.");
+ }
+
+ // We failed. Return the original cause.
+ return e;
+ }
+ }
+
+ @SuppressLint("SdCardPath")
+ public static void doLoadLibrary(final Context context, final String lib) {
+ final Throwable e = doLoadLibraryExpected(context, lib);
+ if (e == null) {
+ // Success.
+ return;
+ }
+
+ // If we're in a mismatched UID state (Bug 1042935 Comment 16) there's really
+ // nothing we can do.
+ final String nativeLibPath = context.getApplicationInfo().nativeLibraryDir;
+ if (nativeLibPath.contains("mismatched_uid")) {
+ throw new RuntimeException("Fatal: mismatched UID: cannot load.");
+ }
+
+ // Attempt 3: try finding the path the pseudo-supported way using .dataDir.
+ final String dataLibPath = context.getApplicationInfo().dataDir + "/lib/lib" + lib + ".so";
+ if (attemptLoad(dataLibPath)) {
+ return;
+ }
+
+ // Attempt 4: use /data/app-lib directly. This is a last-ditch effort.
+ final String androidPackageName = context.getPackageName();
+ if (attemptLoad("/data/app-lib/" + androidPackageName + "/lib" + lib + ".so")) {
+ return;
+ }
+
+ // Attempt 5: even more optimistic.
+ if (attemptLoad("/data/data/" + androidPackageName + "/lib/lib" + lib + ".so")) {
+ return;
+ }
+
+ // Look in our files directory, copying from the APK first if necessary.
+ final String filesLibDir = context.getFilesDir() + "/lib";
+ final String filesLibPath = filesLibDir + "/lib" + lib + ".so";
+ if (new File(filesLibPath).exists()) {
+ if (attemptLoad(filesLibPath)) {
+ return;
+ }
+ } else {
+ // Try copying.
+ if (extractLibrary(context, lib, filesLibDir)) {
+ // Let's try it!
+ if (attemptLoad(filesLibPath)) {
+ return;
+ }
+ }
+ }
+
+ // Give up loudly, leaking information to debug the failure.
+ final String message = getLoadDiagnostics(context, lib);
+ Log.e(LOGTAG, "Load diagnostics: " + message);
+
+ // Throw the descriptive message, using the original library load
+ // failure as the cause.
+ throw new RuntimeException(message, e);
+ }
+
+ public synchronized static void loadMozGlue(final Context context) {
+ if (sMozGlueLoaded) {
+ return;
+ }
+
+ doLoadLibrary(context, "mozglue");
+ sMozGlueLoaded = true;
+ }
+
+ public synchronized static 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);
+ public static native boolean verifyCRCs(String apkName);
+
+ // 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);
+ private static native void loadGeckoLibsNative();
+ private static native void loadSQLiteLibsNative();
+ private static native void loadNSSLibsNative();
+ public static native boolean neonCompatible();
+ 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..fbf04d664c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java
@@ -0,0 +1,16 @@
+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/MinidumpAnalyzer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/MinidumpAnalyzer.java
new file mode 100644
index 0000000000..63498ac33c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/MinidumpAnalyzer.java
@@ -0,0 +1,31 @@
+/* -*- 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;
+
+/**
+ * JNI wrapper for accessing the minidump analyzer tool. This is used to
+ * generate stack traces and other process information from a crash minidump.
+ */
+public final class MinidumpAnalyzer {
+ private MinidumpAnalyzer() {
+ // prevent instantiation
+ }
+
+ /**
+ * Generate the stacks from the minidump file specified in minidumpPath
+ * and adds the StackTraces annotation to the associated .extra file.
+ * If fullStacks is false then only the stack trace for the crashing thread
+ * will be generated, otherwise stacks will be generated for all threads.
+ *
+ * This JNI method is implemented in mozglue/android/nsGeckoUtils.cpp.
+ *
+ * @param minidumpPath The path to the minidump file to be analyzed.
+ * @param fullStacks Specifies if stacks must be generated for all threads.
+ * @return <code>true</code> if the operation was successful,
+ * <code>false</code> otherwise.
+ */
+ public static native boolean GenerateStacks(String minidumpPath, boolean fullStacks);
+}
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..5f5f3a7abe
--- /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/NativeZip.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeZip.java
new file mode 100644
index 0000000000..699ae3e350
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeZip.java
@@ -0,0 +1,84 @@
+/* -*- 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;
+
+import androidx.annotation.Keep;
+import org.mozilla.gecko.annotation.JNITarget;
+
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.zip.Inflater;
+import java.util.zip.InflaterInputStream;
+
+public class NativeZip implements NativeReference {
+ private static final int DEFLATE = 8;
+ private static final int STORE = 0;
+
+ private volatile long mObj;
+ @Keep
+ private InputStream mInput;
+
+ public NativeZip(final String path) {
+ mObj = getZip(path);
+ }
+
+ public NativeZip(final InputStream input) {
+ if (!(input instanceof ByteBufferInputStream)) {
+ throw new IllegalArgumentException("Got " + input.getClass()
+ + ", but expected ByteBufferInputStream!");
+ }
+ ByteBufferInputStream bbinput = (ByteBufferInputStream)input;
+ mObj = getZipFromByteBuffer(bbinput.mBuf);
+ mInput = input;
+ }
+
+ @Override
+ protected void finalize() {
+ release();
+ }
+
+ @Override
+ public void release() {
+ if (mObj != 0) {
+ _release(mObj);
+ mObj = 0;
+ }
+ mInput = null;
+ }
+
+ @Override
+ public boolean isReleased() {
+ return (mObj == 0);
+ }
+
+ public InputStream getInputStream(final String path) {
+ if (isReleased()) {
+ throw new IllegalStateException("Can't get path \"" + path
+ + "\" because NativeZip is closed!");
+ }
+ return _getInputStream(mObj, path);
+ }
+
+ private static native long getZip(String path);
+ private static native long getZipFromByteBuffer(ByteBuffer buffer);
+ private static native void _release(long obj);
+ private native InputStream _getInputStream(long obj, String path);
+
+ @JNITarget
+ private InputStream createInputStream(final ByteBuffer buffer, final int compression) {
+ if (compression != STORE && compression != DEFLATE) {
+ throw new IllegalArgumentException("Unexpected compression: " + compression);
+ }
+
+ InputStream input = new ByteBufferInputStream(buffer, this);
+ if (compression == DEFLATE) {
+ Inflater inflater = new Inflater(true);
+ input = new InflaterInputStream(input, inflater);
+ }
+
+ return input;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java
new file mode 100644
index 0000000000..9e6e169a3b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.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/.
+ */
+
+// This should be in util/, but is here because of build dependency issues.
+package org.mozilla.gecko.mozglue;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import android.util.Log;
+
+import java.util.ArrayList;
+
+/**
+ * External applications can pass values into Intents that can cause us to crash: in defense,
+ * we wrap {@link Intent} and catch the exceptions they may force us to throw. See bug 1090385
+ * for more.
+ */
+public class SafeIntent {
+ private static final String LOGTAG = "Gecko" + SafeIntent.class.getSimpleName();
+
+ private final Intent mIntent;
+
+ public SafeIntent(final Intent intent) {
+ stripDataUri(intent);
+ mIntent = intent;
+ }
+
+ public boolean hasExtra(final String name) {
+ try {
+ return mIntent.hasExtra(name);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't determine if intent had an extra: OOM. Malformed?");
+ return false;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't determine if intent had an extra.", e);
+ return false;
+ }
+ }
+
+ public @Nullable Bundle getExtras() {
+ try {
+ return mIntent.getExtras();
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent extras.", e);
+ return null;
+ }
+ }
+
+ public boolean getBooleanExtra(final String name, final boolean defaultValue) {
+ try {
+ return mIntent.getBooleanExtra(name, defaultValue);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?");
+ return defaultValue;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent extras.", e);
+ return defaultValue;
+ }
+ }
+
+ public int getIntExtra(final String name, final int defaultValue) {
+ try {
+ return mIntent.getIntExtra(name, defaultValue);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?");
+ return defaultValue;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent extras.", e);
+ return defaultValue;
+ }
+ }
+
+ public String getStringExtra(final String name) {
+ try {
+ return mIntent.getStringExtra(name);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent extras.", e);
+ return null;
+ }
+ }
+
+ public Bundle getBundleExtra(final String name) {
+ try {
+ return mIntent.getBundleExtra(name);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent extras.", e);
+ return null;
+ }
+ }
+
+ public String getAction() {
+ return mIntent.getAction();
+ }
+
+ public String getDataString() {
+ try {
+ return mIntent.getDataString();
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent data string: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent data string.", e);
+ return null;
+ }
+ }
+
+ public ArrayList<String> getStringArrayListExtra(final String name) {
+ try {
+ return mIntent.getStringArrayListExtra(name);
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent data string: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent data string.", e);
+ return null;
+ }
+ }
+
+ public Uri getData() {
+ try {
+ return mIntent.getData();
+ } catch (OutOfMemoryError e) {
+ Log.w(LOGTAG, "Couldn't get intent data: OOM. Malformed?");
+ return null;
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't get intent data.", e);
+ return null;
+ }
+ }
+
+ public Intent getUnsafe() {
+ return mIntent;
+ }
+
+ private static void stripDataUri(final Intent intent) {
+ // We should limit intent filters and check incoming intents against white-list
+ // But for now we just strip 'about:reader?url='
+ if (intent != null && intent.getData() != null) {
+ final String url = intent.getData().toString();
+ final String prefix = "about:reader?url=";
+ if (url != null && url.startsWith(prefix)) {
+ final String strippedUrl = url.replace(prefix, "");
+ if (strippedUrl != null) {
+ intent.setData(Uri.parse(strippedUrl));
+ }
+ }
+ }
+ }
+}
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..94f7a3dbdc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java
@@ -0,0 +1,184 @@
+/* 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 (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 {
+ FileDescriptor fd = (FileDescriptor)sGetFDMethod.invoke(mBackedFile);
+ mDescriptor = ParcelFileDescriptor.dup(fd);
+ mSize = size;
+ mId = id;
+ mBackedFile.allowPurging(false);
+ } catch (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 (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 (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..5663facdc6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja
@@ -0,0 +1,15 @@
+/* -*- 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 {
+ public static final class gmplugin extends GeckoServiceChildProcess {}
+ public static final class socket 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..88b9944e2a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java
@@ -0,0 +1,826 @@
+/* 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.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.TelemetryUtils;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.annotation.WrapForJNI;
+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;
+
+import android.os.Bundle;
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import androidx.annotation.NonNull;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+import androidx.collection.SimpleArrayMap;
+import android.util.Log;
+
+
+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;
+
+ 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);
+ }
+
+ /**
+ * 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 31 * mType.hashCode() + 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();
+ } 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;
+ }
+ }
+
+ @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.BACKGROUND);
+ }
+ }
+
+ 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) && !mNonStartedContentConnections.remove(conn)) {
+ throw new RuntimeException("Attempt to remove non-registered connection");
+ }
+
+ 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) {
+ 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(Integer.valueOf(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(Integer.valueOf(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 {
+ 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();
+ }
+
+ 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 (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 Bundle extras = GeckoThread.getActiveExtras();
+ final int flags = filterFlagsForChild(GeckoThread.getActiveFlags());
+
+ XPCOMEventTarget.runOnLauncherThread(() -> {
+ INSTANCE.start(result, type, args, extras, flags, prefsFd,
+ prefMapFd, ipcFd, crashFd, crashAnnotationFd,
+ /* isRetry */ false);
+ });
+
+ return result;
+ }
+
+ private static int filterFlagsForChild(final int flags) {
+ return flags & GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ }
+
+ private void start(final GeckoResult<Integer> result, final GeckoProcessType type,
+ final String[] args, final Bundle extras, final int flags,
+ final int prefsFd, final int prefMapFd, final int ipcFd,
+ final int crashFd, final int crashAnnotationFd,
+ final boolean isRetry) {
+ start(result, type, args, extras, flags, prefsFd, prefMapFd, ipcFd,
+ crashFd, crashAnnotationFd, isRetry, /* prevException */ null);
+ }
+
+ private void start(final GeckoResult<Integer> result, final GeckoProcessType type,
+ final String[] args, final Bundle extras, final int flags,
+ final int prefsFd, final int prefMapFd, final int ipcFd,
+ final int crashFd, final int crashAnnotationFd,
+ final boolean isRetry,
+ final RemoteException prevException) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final ChildConnection connection = mConnections.getConnectionForStart(type);
+ final GeckoResult<IChildProcess> childResult = connection.bind();
+
+ childResult.accept(childProcess -> {
+ start(result, connection, childProcess, type, args, extras,
+ flags, prefsFd, prefMapFd, ipcFd, crashFd,
+ crashAnnotationFd, isRetry, prevException);
+ }, error -> {
+ final StringBuilder builder = new StringBuilder("Cannot bind child process: ");
+ builder.append(error.toString());
+ if (prevException != null) {
+ builder.append("; Previous exception: ");
+ builder.append(prevException.toString());
+ }
+
+ builder.append("; Type: ");
+ builder.append(type.toString());
+
+ result.completeExceptionally(new RuntimeException(builder.toString()));
+ });
+ }
+
+ private void acceptUnbindFailure(@NonNull final GeckoResult<Void> unbindResult,
+ @NonNull final GeckoResult<Integer> finalResult,
+ final RemoteException exception,
+ @NonNull final GeckoProcessType type,
+ final boolean isRetry) {
+ unbindResult.accept(null, error -> {
+ final StringBuilder builder = new StringBuilder("Failed to unbind");
+ if (isRetry) {
+ builder.append(": ");
+ } else {
+ builder.append(" before child restart: ");
+ }
+
+ builder.append(error.toString());
+ if (exception != null) {
+ builder.append("; In response to RemoteException: ");
+ builder.append(exception.toString());
+ }
+
+ builder.append("; Type = ");
+ builder.append(type.toString());
+
+ finalResult.completeExceptionally(new RuntimeException(builder.toString()));
+ });
+ }
+
+ private void start(final GeckoResult<Integer> result,
+ final ChildConnection connection,
+ final IChildProcess child,
+ final GeckoProcessType type, final String[] args,
+ final Bundle extras, final int flags,
+ final int prefsFd, final int prefMapFd,
+ final int ipcFd, final int crashFd,
+ final int crashAnnotationFd, final boolean isRetry,
+ final RemoteException prevException) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final ParcelFileDescriptor prefsPfd =
+ (prefsFd >= 0) ? ParcelFileDescriptor.adoptFd(prefsFd) : null;
+ final ParcelFileDescriptor prefMapPfd =
+ (prefMapFd >= 0) ? ParcelFileDescriptor.adoptFd(prefMapFd) : null;
+ final ParcelFileDescriptor ipcPfd = ParcelFileDescriptor.adoptFd(ipcFd);
+ final ParcelFileDescriptor crashPfd =
+ (crashFd >= 0) ? ParcelFileDescriptor.adoptFd(crashFd) : null;
+ final ParcelFileDescriptor crashAnnotationPfd =
+ (crashAnnotationFd >= 0) ? ParcelFileDescriptor.adoptFd(crashAnnotationFd) : null;
+
+ boolean started = false;
+ RemoteException exception = null;
+ final String crashHandler = GeckoAppShell.getCrashHandlerService() != null ?
+ GeckoAppShell.getCrashHandlerService().getName() : null;
+ try {
+ started = child.start(this, args, extras, flags, crashHandler,
+ prefsPfd, prefMapPfd, ipcPfd, crashPfd, crashAnnotationPfd);
+ } catch (final RemoteException e) {
+ exception = e;
+ }
+
+ if (crashAnnotationPfd != null) {
+ crashAnnotationPfd.detachFd();
+ }
+ if (crashPfd != null) {
+ crashPfd.detachFd();
+ }
+ ipcPfd.detachFd();
+ if (prefMapPfd != null) {
+ prefMapPfd.detachFd();
+ }
+ if (prefsPfd != null) {
+ prefsPfd.detachFd();
+ }
+
+ if (started) {
+ result.complete(connection.getPid());
+ return;
+ }
+
+ // Whether retrying or not, we should always unbind connection so that it gets cleaned up.
+ final GeckoResult<Void> unbindResult = connection.unbind();
+
+ // We always complete result exceptionally if the unbind fails
+ acceptUnbindFailure(unbindResult, result, exception, type, isRetry);
+
+ if (isRetry) {
+ // If we've already retried, just assemble an error message and completeExceptionally.
+ Log.e(LOGTAG, "Cannot restart child " + type.toString());
+ final StringBuilder builder = new StringBuilder("Cannot restart child.");
+ if (prevException != null) {
+ builder.append(" Initial RemoteException: ");
+ builder.append(prevException.toString());
+ }
+ if (exception != null) {
+ builder.append(" Second RemoteException: ");
+ builder.append(exception.toString());
+ }
+ if (exception == null && prevException == null) {
+ builder.append(" No exceptions thrown; type = ");
+ builder.append(type.toString());
+ }
+
+ final RuntimeException completionException = new RuntimeException(builder.toString());
+ unbindResult.accept(v -> {
+ result.completeExceptionally(completionException);
+ });
+ return;
+ }
+
+ // Attempt to retry the connection once we've finished unbinding.
+ Log.w(LOGTAG, "Attempting to kill running child " + type.toString());
+ final RemoteException captureException = exception;
+ unbindResult.accept(v -> {
+ start(result, type, args, extras, flags, prefsFd, prefMapFd, ipcFd,
+ crashFd, crashAnnotationFd, /* isRetry */ true, captureException);
+ });
+ }
+
+} // 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..f5f3484579
--- /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");
+
+ 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..f8936e4a07
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java
@@ -0,0 +1,178 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.Service;
+import android.content.ComponentCallbacks2;
+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;
+
+public class GeckoServiceChildProcess extends Service {
+ private static final String LOGTAG = "ServiceChildProcess";
+ // Allowed elapsed time between full GCs while under constant memory pressure
+ private static final long LOW_MEMORY_ONGOING_RESET_TIME_MS = 10000;
+
+ private static IProcessManager sProcessManager;
+
+ private long mLastLowMemoryNotificationTime = 0;
+
+ @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();
+
+ GeckoAppShell.setApplicationContext(getApplicationContext());
+ }
+
+ @Override
+ public int onStartCommand(final Intent intent, final int flags, final int startId) {
+ return Service.START_NOT_STICKY;
+ }
+
+ private final Binder mBinder = new IChildProcess.Stub() {
+ @Override
+ public int getPid() {
+ return Process.myPid();
+ }
+
+ @Override
+ public boolean start(final IProcessManager procMan,
+ final String[] args,
+ final Bundle extras,
+ final int flags,
+ final String crashHandlerService,
+ final ParcelFileDescriptor prefsPfd,
+ final ParcelFileDescriptor prefMapPfd,
+ final ParcelFileDescriptor ipcPfd,
+ final ParcelFileDescriptor crashReporterPfd,
+ final ParcelFileDescriptor crashAnnotationPfd) {
+ synchronized (GeckoServiceChildProcess.class) {
+ if (sProcessManager != null) {
+ Log.e(LOGTAG, "Child process already started");
+ return false;
+ }
+ sProcessManager = procMan;
+ }
+
+ final int prefsFd = prefsPfd != null ?
+ prefsPfd.detachFd() : -1;
+ final int prefMapFd = prefMapPfd != null ?
+ prefMapPfd.detachFd() : -1;
+ final int ipcFd = ipcPfd.detachFd();
+ final int crashReporterFd = crashReporterPfd != null ?
+ crashReporterPfd.detachFd() : -1;
+ final int crashAnnotationFd = crashAnnotationPfd != null ?
+ crashAnnotationPfd.detachFd() : -1;
+
+ 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 (ClassNotFoundException e) {
+ Log.w(LOGTAG, "Couldn't find crash handler service " + crashHandlerService);
+ }
+ }
+
+ final GeckoThread.InitInfo info = new GeckoThread.InitInfo();
+ info.args = args;
+ info.extras = extras;
+ info.flags = flags;
+ info.prefsFd = prefsFd;
+ info.prefMapFd = prefMapFd;
+ info.ipcFd = ipcFd;
+ info.crashFd = crashReporterFd;
+ info.crashAnnotationFd = crashAnnotationFd;
+
+ if (GeckoThread.init(info)) {
+ GeckoThread.launch();
+ }
+ }
+ });
+ return true;
+ }
+
+ @Override
+ public void crash() {
+ GeckoThread.crash();
+ }
+ };
+
+ @Override
+ public IBinder onBind(final Intent intent) {
+ GeckoThread.launch(); // Preload Gecko.
+ return mBinder;
+ }
+
+ @Override
+ public boolean onUnbind(final Intent intent) {
+ Log.i(LOGTAG, "Service has been unbound. Stopping.");
+ stopSelf();
+ Process.killProcess(Process.myPid());
+ return false;
+ }
+
+ @Override
+ public void onTrimMemory(final int level) {
+ Log.i(LOGTAG, "onTrimMemory(" + level + ")");
+
+ // This is currently a no-op in Service, but let's future-proof.
+ super.onTrimMemory(level);
+
+ if (level < ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
+ // We're not currently interested in trim events for non-backgrounded processes.
+ return;
+ }
+
+ // See nsIMemory.idl for descriptions of the various arguments to the "memory-pressure" observer.
+ String observerArg = null;
+
+ final long currentNotificationTime = System.currentTimeMillis();
+ if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE ||
+ (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..fdb5de56b9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java
@@ -0,0 +1,579 @@
+/* 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;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.content.ServiceConnection;
+import android.os.Build;
+import android.os.IBinder;
+import androidx.annotation.NonNull;
+import android.util.Log;
+
+import java.util.BitSet;
+import java.util.EnumMap;
+import java.util.Map.Entry;
+
+/* package */ final class ServiceAllocator {
+ private static final String LOGTAG = "ServiceAllocator";
+ private static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = 50;
+
+ 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 static abstract 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),
+ getIdAsString(), binding);
+ }
+
+ @Override
+ public String getServiceName() {
+ return ServiceUtils.buildIsolatedSvcName(getType());
+ }
+ }
+
+ private final ServiceAllocator mAllocator;
+ private final GeckoProcessType mType;
+ private final Integer 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 int getId() {
+ if (mId == null) {
+ throw new RuntimeException("This service does not have a unique id");
+ }
+
+ return mId.intValue();
+ }
+
+ /**
+ * This method is infallible and returns an empty string for non-content services.
+ */
+ private String getIdAsString() {
+ return mId == null ? "" : mId.toString();
+ }
+
+ 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.
+ */
+ int allocate();
+
+ /**
+ * Release a previously used service ID.
+ * @param id The service id being released.
+ */
+ void release(final int 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;
+
+ public DefaultContentPolicy() {
+ mMaxNumSvcs = getContentServiceCount();
+ mAllocator = new BitSet(mMaxNumSvcs);
+ }
+
+ @Override
+ public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) {
+ return info.new DefaultBindDelegate();
+ }
+
+ @Override
+ public int allocate() {
+ final int next = mAllocator.nextClearBit(0);
+ if (next >= mMaxNumSvcs) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ mAllocator.set(next);
+ return next;
+ }
+
+ @Override
+ public void release(final int id) {
+ if (!mAllocator.get(id)) {
+ throw new IllegalStateException("Releasing an unallocated 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 int mNextIsolatedSvcId = 0;
+ private int mCurNumIsolatedSvcs = 0;
+
+ @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 int allocate() {
+ if (mCurNumIsolatedSvcs >= MAX_NUM_ISOLATED_CONTENT_SERVICES) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ ++mCurNumIsolatedSvcs;
+ return mNextIsolatedSvcId++;
+ }
+
+ /**
+ * Just drop the count of active services.
+ */
+ @Override
+ public void release(final int id) {
+ if (mCurNumIsolatedSvcs <= 0) {
+ throw new IllegalStateException("Releasing an unallocated id");
+ }
+
+ --mCurNumIsolatedSvcs;
+ }
+ }
+
+ /**
+ * 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 Integer 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 Integer.valueOf(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.getIdAsString());
+ }
+
+ /**
+ * 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..45325fa9ba
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.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.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.
+ *
+ * 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 (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 (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/ActivityUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java
new file mode 100644
index 0000000000..7a2c6cfbb6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java
@@ -0,0 +1,98 @@
+/* -*- 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.app.Activity;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.os.Build;
+import android.view.View;
+import android.view.Window;
+
+public class ActivityUtils {
+ private ActivityUtils() {
+ }
+
+ public static void setFullScreen(final Activity activity, final boolean fullscreen) {
+ // Hide/show the system notification bar
+ Window window = activity.getWindow();
+
+ int newVis;
+ if (fullscreen) {
+ newVis = View.SYSTEM_UI_FLAG_FULLSCREEN;
+ if (Build.VERSION.SDK_INT >= 19) {
+ newVis |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+ } else {
+ newVis |= View.SYSTEM_UI_FLAG_LOW_PROFILE;
+ }
+ } else {
+ // no need to prevent status bar to appear when exiting full screen
+ preventDisplayStatusbar(activity, false);
+ newVis = View.SYSTEM_UI_FLAG_VISIBLE;
+ }
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ // We also have to set SYSTEM_UI_FLAG_LIGHT_STATUS_BAR with to current system ui status
+ // to support both light and dark status bar.
+ final int oldVis = window.getDecorView().getSystemUiVisibility();
+ newVis |= (oldVis & View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
+ }
+
+ window.getDecorView().setSystemUiVisibility(newVis);
+ }
+
+ public static boolean isFullScreen(final Activity activity) {
+ final Window window = activity.getWindow();
+
+ final int vis = window.getDecorView().getSystemUiVisibility();
+ return (vis & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0;
+ }
+
+ /**
+ * Finish this activity and launch the default home screen activity.
+ */
+ public static void goToHomeScreen(final Context context) {
+ Intent intent = new Intent(Intent.ACTION_MAIN);
+
+ intent.addCategory(Intent.CATEGORY_HOME);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ context.startActivity(intent);
+ }
+
+ public 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;
+ }
+
+ public static void preventDisplayStatusbar(final Activity activity,
+ final boolean registering) {
+ final View decorView = activity.getWindow().getDecorView();
+ if (registering) {
+ decorView.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() {
+ @Override
+ public void onSystemUiVisibilityChange(final int visibility) {
+ if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
+ setFullScreen(activity, true);
+ }
+ }
+ });
+ } else {
+ decorView.setOnSystemUiVisibilityChangeListener(null);
+ }
+
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BitmapUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BitmapUtils.java
new file mode 100644
index 0000000000..f8af8561ff
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BitmapUtils.java
@@ -0,0 +1,321 @@
+/* -*- 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.io.IOException;
+import java.io.InputStream;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import androidx.annotation.ColorInt;
+import androidx.palette.graphics.Palette;
+import android.util.Base64;
+import android.util.Log;
+
+public final class BitmapUtils {
+ private static final String LOGTAG = "GeckoBitmapUtils";
+
+ private BitmapUtils() {}
+
+ public static Bitmap decodeByteArray(final byte[] bytes) {
+ return decodeByteArray(bytes, null);
+ }
+
+ public static Bitmap decodeByteArray(final byte[] bytes, final BitmapFactory.Options options) {
+ return decodeByteArray(bytes, 0, bytes.length, options);
+ }
+
+ public static Bitmap decodeByteArray(final byte[] bytes, final int offset, final int length) {
+ return decodeByteArray(bytes, offset, length, null);
+ }
+
+ public static Bitmap decodeByteArray(final byte[] bytes, final int offset, final int length,
+ final BitmapFactory.Options options) {
+ if (bytes.length <= 0) {
+ throw new IllegalArgumentException("bytes.length " + bytes.length
+ + " must be a positive number");
+ }
+
+ Bitmap bitmap = null;
+ try {
+ bitmap = BitmapFactory.decodeByteArray(bytes, offset, length, options);
+ } catch (OutOfMemoryError e) {
+ Log.e(LOGTAG, "decodeByteArray(bytes.length=" + bytes.length
+ + ", options= " + options + ") OOM!", e);
+ return null;
+ }
+
+ if (bitmap == null) {
+ Log.w(LOGTAG, "decodeByteArray() returning null because BitmapFactory returned null");
+ return null;
+ }
+
+ if (bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) {
+ Log.w(LOGTAG, "decodeByteArray() returning null because BitmapFactory returned "
+ + "a bitmap with dimensions " + bitmap.getWidth()
+ + "x" + bitmap.getHeight());
+ return null;
+ }
+
+ return bitmap;
+ }
+
+ public static Bitmap decodeStream(final InputStream inputStream) {
+ try {
+ return BitmapFactory.decodeStream(inputStream);
+ } catch (OutOfMemoryError e) {
+ Log.e(LOGTAG, "decodeStream() OOM!", e);
+ return null;
+ }
+ }
+
+ public static Bitmap decodeUrl(final Uri uri) {
+ return decodeUrl(uri.toString());
+ }
+
+ public static Bitmap decodeUrl(final String urlString) {
+ URL url;
+
+ try {
+ url = new URL(urlString);
+ } catch (MalformedURLException e) {
+ Log.w(LOGTAG, "decodeUrl: malformed URL " + urlString);
+ return null;
+ }
+
+ return decodeUrl(url);
+ }
+
+ public static Bitmap decodeUrl(final URL url) {
+ InputStream stream = null;
+
+ try {
+ stream = url.openStream();
+ } catch (IOException e) {
+ Log.w(LOGTAG, "decodeUrl: IOException downloading " + url);
+ return null;
+ }
+
+ if (stream == null) {
+ Log.w(LOGTAG, "decodeUrl: stream not found downloading " + url);
+ return null;
+ }
+
+ Bitmap bitmap = decodeStream(stream);
+
+ try {
+ stream.close();
+ } catch (IOException e) {
+ Log.w(LOGTAG, "decodeUrl: IOException closing stream " + url, e);
+ }
+
+ return bitmap;
+ }
+
+ public static Bitmap decodeResource(final Context context, final int id) {
+ return decodeResource(context, id, null);
+ }
+
+ public static Bitmap decodeResource(final Context context, final int id,
+ final BitmapFactory.Options options) {
+ Resources resources = context.getResources();
+ try {
+ return BitmapFactory.decodeResource(resources, id, options);
+ } catch (OutOfMemoryError e) {
+ Log.e(LOGTAG, "decodeResource() OOM! Resource id=" + id, e);
+ return null;
+ }
+ }
+
+ public static @ColorInt int getDominantColor(final Bitmap source,
+ final @ColorInt int defaultColor) {
+ if (HardwareUtils.isX86System()) {
+ // (Bug 1318667) We are running into crashes when using the palette library with
+ // specific icons on x86 devices. They take down the whole VM and are not recoverable.
+ // Unfortunately our release icon is triggering this crash. Until we can switch to a
+ // newer version of the support library where this does not happen, we are using our
+ // own slower implementation.
+ return getDominantColorCustomImplementation(source, true, defaultColor);
+ } else {
+ try {
+ final Palette palette = Palette.from(source).generate();
+ return palette.getVibrantColor(defaultColor);
+ } catch (ArrayIndexOutOfBoundsException e) {
+ // We saw the palette library fail with an ArrayIndexOutOfBoundsException intermittently
+ // in automation. In this case lets just swallow the exception and move on without a
+ // color. This is a valid condition and callers should handle this gracefully (Bug 1318560).
+ Log.e(LOGTAG, "Palette generation failed with ArrayIndexOutOfBoundsException", e);
+
+ return defaultColor;
+ }
+ }
+ }
+
+ public static @ColorInt int getDominantColorCustomImplementation(final Bitmap source) {
+ return getDominantColorCustomImplementation(source, true, Color.WHITE);
+ }
+
+ public static @ColorInt int getDominantColorCustomImplementation(
+ final Bitmap source, final boolean applyThreshold, final @ColorInt int defaultColor) {
+ if (source == null) {
+ return defaultColor;
+ }
+
+ // Keep track of how many times a hue in a given bin appears in the image.
+ // Hue values range [0 .. 360), so dividing by 10, we get 36 bins.
+ int[] colorBins = new int[36];
+
+ // The bin with the most colors. Initialize to -1 to prevent accidentally
+ // thinking the first bin holds the dominant color.
+ int maxBin = -1;
+
+ // Keep track of sum hue/saturation/value per hue bin, which we'll use to
+ // compute an average to for the dominant color.
+ float[] sumHue = new float[36];
+ float[] sumSat = new float[36];
+ float[] sumVal = new float[36];
+ float[] hsv = new float[3];
+
+ int height = source.getHeight();
+ int width = source.getWidth();
+ int[] pixels = new int[width * height];
+ source.getPixels(pixels, 0, width, 0, 0, width, height);
+ for (int row = 0; row < height; row++) {
+ for (int col = 0; col < width; col++) {
+ int c = pixels[col + row * width];
+ // Ignore pixels with a certain transparency.
+ if (Color.alpha(c) < 128)
+ continue;
+
+ Color.colorToHSV(c, hsv);
+
+ // If a threshold is applied, ignore arbitrarily chosen values for "white" and "black".
+ if (applyThreshold && (hsv[1] <= 0.35f || hsv[2] <= 0.35f))
+ continue;
+
+ // We compute the dominant color by putting colors in bins based on their hue.
+ int bin = (int) Math.floor(hsv[0] / 10.0f);
+
+ // Update the sum hue/saturation/value for this bin.
+ sumHue[bin] = sumHue[bin] + hsv[0];
+ sumSat[bin] = sumSat[bin] + hsv[1];
+ sumVal[bin] = sumVal[bin] + hsv[2];
+
+ // Increment the number of colors in this bin.
+ colorBins[bin]++;
+
+ // Keep track of the bin that holds the most colors.
+ if (maxBin < 0 || colorBins[bin] > colorBins[maxBin])
+ maxBin = bin;
+ }
+ }
+
+ // maxBin may never get updated if the image holds only transparent and/or black/white pixels.
+ if (maxBin < 0) {
+ return defaultColor;
+ }
+
+ // Return a color with the average hue/saturation/value of the bin with the most colors.
+ hsv[0] = sumHue[maxBin] / colorBins[maxBin];
+ hsv[1] = sumSat[maxBin] / colorBins[maxBin];
+ hsv[2] = sumVal[maxBin] / colorBins[maxBin];
+ return Color.HSVToColor(hsv);
+ }
+
+ /**
+ * Decodes a bitmap from a Base64 data URI.
+ *
+ * @param dataURI a Base64-encoded data URI string
+ * @return the decoded bitmap, or null if the data URI is invalid
+ */
+ public static Bitmap getBitmapFromDataURI(final String dataURI) {
+ if (dataURI == null) {
+ return null;
+ }
+
+ byte[] raw = getBytesFromDataURI(dataURI);
+ if (raw == null || raw.length == 0) {
+ return null;
+ }
+
+ return decodeByteArray(raw);
+ }
+
+ /**
+ * Return a byte[] containing the bytes in a given base64 string, or null if this is not a valid
+ * base64 string.
+ */
+ public static byte[] getBytesFromBase64(final String base64) {
+ try {
+ return Base64.decode(base64, Base64.DEFAULT);
+ } catch (Exception e) {
+ Log.e(LOGTAG, "exception decoding bitmap from data URI: " + base64, e);
+ }
+
+ return null;
+ }
+
+ public static byte[] getBytesFromDataURI(final String dataURI) {
+ final String base64 = dataURI.substring(dataURI.indexOf(',') + 1);
+ return getBytesFromBase64(base64);
+ }
+
+ public 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;
+
+ Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(bitmap);
+ drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+ drawable.draw(canvas);
+
+ return bitmap;
+ }
+
+ public static int getResource(final Context context, final Uri resourceUrl) {
+ final String scheme = resourceUrl.getScheme();
+ if (!"drawable".equals(scheme)) {
+ // Return a "not found" default icon that's easy to spot.
+ return android.R.drawable.sym_def_app_icon;
+ }
+
+ String resource = resourceUrl.getSchemeSpecificPart();
+ if (resource.startsWith("//")) {
+ resource = resource.substring(2);
+ }
+
+ final Resources res = context.getResources();
+ int id = res.getIdentifier(resource, "drawable", context.getPackageName());
+ if (id != 0) {
+ return id;
+ }
+
+ // For backwards compatibility, we also search in system resources.
+ id = res.getIdentifier(resource, "drawable", "android");
+ if (id != 0) {
+ return id;
+ }
+
+ Log.w(LOGTAG, "Cannot find drawable/" + resource);
+ // Return a "not found" default icon that's easy to spot.
+ return android.R.drawable.sym_def_app_icon;
+ }
+}
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..42fd7897ea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
@@ -0,0 +1,22 @@
+/* -*- 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/ContentUriUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContentUriUtils.java
new file mode 100644
index 0000000000..826f329eb0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContentUriUtils.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2007-2008 OpenIntents.org
+ *
+ * Licensed 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.
+ */
+
+package org.mozilla.gecko.util;
+
+import android.content.ContentUris;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.provider.MediaStore;
+import androidx.annotation.Nullable;
+import android.text.TextUtils;
+
+import java.io.File;
+
+/**
+ * Based on https://github.com/iPaulPro/aFileChooser/blob/48d65e6649d4201407702b0390326ec9d5c9d17c/aFileChooser/src/com/ipaulpro/afilechooser/utils/FileUtils.java
+ */
+public class ContentUriUtils {
+ /**
+ * Get a file path from a Uri. This will get the the path for Storage Access
+ * Framework Documents, as well as the _data field for the MediaStore and
+ * other file-based ContentProviders.<br>
+ * <br>
+ * Callers should check whether the path is local before assuming it
+ * represents a local file.
+ *
+ * @param context The context.
+ * @param uri The Uri to query.
+ * @author paulburke
+ */
+ public static @Nullable String getOriginalFilePathFromUri(final Context context, final Uri uri) {
+ // DocumentProvider
+ if (Build.VERSION.SDK_INT >= 19 && DocumentsContract.isDocumentUri(context, uri)) {
+ // ExternalStorageProvider
+ if (isExternalStorageDocument(uri)) {
+ final String docId = DocumentsContract.getDocumentId(uri);
+ // The AOSP ExternalStorageProvider creates document IDs of the form
+ // "storage device ID" + ':' + "document path".
+ final String[] split = docId.split(":");
+ final String type = split[0];
+ final String docPath = split[1];
+
+ final String rootPath;
+ if ("primary".equalsIgnoreCase(type)) {
+ rootPath = Environment.getExternalStorageDirectory().getAbsolutePath();
+ } else {
+ rootPath = FileUtils.getExternalStoragePath(context, type);
+ }
+ return !TextUtils.isEmpty(rootPath) ?
+ rootPath + "/" + docPath : null;
+ } else if (isDownloadsDocument(uri)) { // DownloadsProvider
+ final String id = DocumentsContract.getDocumentId(uri);
+ // workaround for issue (https://bugzilla.mozilla.org/show_bug.cgi?id=1502721) and
+ // as per https://github.com/Yalantis/uCrop/issues/318#issuecomment-333066640
+ if (!TextUtils.isEmpty(id)) {
+ if (id.startsWith("raw:")) {
+ return id.replaceFirst("raw:", "");
+ }
+ try {
+ final Uri contentUri = ContentUris.withAppendedId(
+ Uri.parse("content://downloads/public_downloads"), Long.valueOf(id));
+ return getDataColumn(context, contentUri, null, null);
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ } else if (isMediaDocument(uri)) { // MediaProvider
+ final String docId = DocumentsContract.getDocumentId(uri);
+ final String[] split = docId.split(":");
+ final String type = split[0];
+
+ Uri contentUri = null;
+ if ("image".equals(type)) {
+ contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
+ } else if ("video".equals(type)) {
+ contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
+ } else if ("audio".equals(type)) {
+ contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
+ }
+
+ final String selection = "_id=?";
+ final String[] selectionArgs = new String[] {
+ split[1]
+ };
+
+ return getDataColumn(context, contentUri, selection, selectionArgs);
+ }
+ } else if ("content".equalsIgnoreCase(uri.getScheme())) { // MediaStore (and general)
+ // Return the remote address
+ if (isGooglePhotosUri(uri))
+ return uri.getLastPathSegment();
+
+ return getDataColumn(context, uri, null, null);
+ } else if ("file".equalsIgnoreCase(uri.getScheme())) { // File
+ return uri.getPath();
+ }
+
+ return null;
+ }
+
+ /**
+ * Retrieves file contents via getContentResolver().openInputStream() and stores them in a
+ * temporary file.
+ *
+ * @return The path of the temporary file, or <code>null</code> if there was an error
+ * retrieving the file.
+ */
+ public static @Nullable String getTempFilePathFromContentUri(final Context context,
+ final Uri contentUri) {
+ //copy file and send new file path
+ final String fileName = FileUtils.getFileNameFromContentUri(context, contentUri);
+ final File folder = new File(context.getCacheDir(), FileUtils.CONTENT_TEMP_DIRECTORY);
+ boolean success = true;
+ if (!folder.exists()) {
+ success = folder.mkdirs();
+ }
+
+ if (!TextUtils.isEmpty(fileName) && success) {
+ File copyFile = new File(folder.getPath(), fileName);
+ FileUtils.copy(context, contentUri, copyFile);
+ return copyFile.getAbsolutePath();
+ }
+ return null;
+ }
+
+ /**
+ * Get the value of the data column for this Uri. This is useful for
+ * MediaStore Uris, and other file-based ContentProviders.
+ *
+ * @param context The context.
+ * @param uri The Uri to query.
+ * @param selection (Optional) Filter used in the query.
+ * @param selectionArgs (Optional) Selection arguments used in the query.
+ * @return The value of the _data column, which is typically a file path.
+ * @author paulburke
+ */
+ private static String getDataColumn(final Context context, final Uri uri,
+ final String selection, final String[] selectionArgs) {
+ final String column = "_data";
+ final String[] projection = {
+ column
+ };
+
+ try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs,
+ null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ final int column_index = cursor.getColumnIndex(column);
+ return column_index >= 0 ? cursor.getString(column_index) : null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is ExternalStorageProvider.
+ * @author paulburke
+ */
+ public static boolean isExternalStorageDocument(final Uri uri) {
+ return "com.android.externalstorage.documents".equals(uri.getAuthority());
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is DownloadsProvider.
+ * @author paulburke
+ */
+ public static boolean isDownloadsDocument(final Uri uri) {
+ return "com.android.providers.downloads.documents".equals(uri.getAuthority());
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is MediaProvider.
+ * @author paulburke
+ */
+ public static boolean isMediaDocument(final Uri uri) {
+ return "com.android.providers.media.documents".equals(uri.getAuthority());
+ }
+
+ /**
+ * @param uri The Uri to check.
+ * @return Whether the Uri authority is Google Photos.
+ */
+ public static boolean isGooglePhotosUri(final Uri uri) {
+ return "com.google.android.apps.photos.content".equals(uri.getAuthority());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java
new file mode 100644
index 0000000000..1bd7d429c1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java
@@ -0,0 +1,55 @@
+/*
+ * 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 java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Utilities to help with manipulating Java's dates and calendars.
+ */
+public class DateUtil {
+ private DateUtil() {}
+
+ /**
+ * @param date the date to convert to HTTP format
+ * @return the date as specified in rfc 1123, e.g. "Tue, 01 Feb 2011 14:00:00 GMT"
+ */
+ public static String getDateInHTTPFormat(@NonNull final Date date) {
+ final DateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.US);
+ df.setTimeZone(TimeZone.getTimeZone("GMT"));
+ return df.format(date);
+ }
+
+ /**
+ * Returns the timezone offset for the current date in minutes. See
+ * {@link #getTimezoneOffsetInMinutesForGivenDate(Calendar)} for more details.
+ */
+ public static int getTimezoneOffsetInMinutes(@NonNull final TimeZone timezone) {
+ return getTimezoneOffsetInMinutesForGivenDate(Calendar.getInstance(timezone));
+ }
+
+ /**
+ * Returns the time zone offset for the given date in minutes. The date makes a difference due to daylight
+ * savings time in some regions. We return minutes because we can accurately represent time zones that are
+ * offset by non-integer hour values, e.g. parts of New Zealand at UTC+12:45.
+ *
+ * @param calendar A calendar with the appropriate time zone &amp; date already set.
+ */
+ public static int getTimezoneOffsetInMinutesForGivenDate(@NonNull final Calendar calendar) {
+ // via Date.getTimezoneOffset deprecated docs (note: it had incorrect order of operations).
+ // Also, we cast to int because we should never overflow here - the max should be GMT+14 = 840.
+ return (int) TimeUnit.MILLISECONDS.toMinutes(calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET));
+ }
+}
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..912618d461
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java
@@ -0,0 +1,110 @@
+/* -*- 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 androidx.annotation.NonNull;
+import android.util.Log;
+
+import org.mozilla.gecko.GeckoThread;
+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;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+// 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 (YAMLException e) {
+ throw new ConfigException(e.getMessage());
+ } finally {
+ IOUtils.safeStreamClose(fileInputStream);
+ }
+ }
+
+ public void mergeIntoInitInfo(final @NonNull GeckoThread.InitInfo info) {
+ if (env != null) {
+ Log.d(LOGTAG, "Adding environment variables from debug config: " + env);
+
+ if (info.extras == null) {
+ info.extras = new Bundle();
+ }
+
+ int c = 0;
+ while (info.extras.getString("env" + c) != null) {
+ c += 1;
+ }
+
+ for (final Map.Entry<String, String> entry : env.entrySet()) {
+ info.extras.putString("env" + c, entry.getKey() + "=" + entry.getValue());
+ c += 1;
+ }
+ }
+
+ if (args != null) {
+ Log.d(LOGTAG, "Adding arguments from debug config: " + args);
+
+ final ArrayList<String> combinedArgs = new ArrayList<>();
+ combinedArgs.addAll(Arrays.asList(info.args));
+ combinedArgs.addAll(args);
+
+ info.args = combinedArgs.toArray(new String[combinedArgs.size()]);
+ }
+
+ if (prefs != null) {
+ Log.d(LOGTAG, "Adding prefs from debug config: " + prefs);
+
+ final Map<String, Object> combinedPrefs = new HashMap<>();
+ combinedPrefs.putAll(info.prefs);
+ combinedPrefs.putAll(prefs);
+ info.prefs = Collections.unmodifiableMap(prefs);
+ }
+ }
+}
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..30a3883323
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
@@ -0,0 +1,53 @@
+package org.mozilla.gecko.util;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.geckoview.GeckoResult;
+
+import javax.annotation.Nullable;
+
+/**
+ * Callback interface for Gecko requests.
+ *
+ * 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/FileUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java
new file mode 100644
index 0000000000..502b16aac4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java
@@ -0,0 +1,427 @@
+/* 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.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.storage.StorageVolume;
+import android.provider.MediaStore;
+import androidx.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.FilenameFilter;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+import java.util.Comparator;
+import java.util.Random;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+import static org.mozilla.gecko.util.ContentUriUtils.getOriginalFilePathFromUri;
+import static org.mozilla.gecko.util.ContentUriUtils.getTempFilePathFromContentUri;
+
+public class FileUtils {
+ private static final String LOGTAG = "GeckoFileUtils";
+ private static final String FILE_SCHEME = "file";
+ private static final String CONTENT_SCHEME = "content";
+ private static final String FILE_ABSOLUTE_URI = FILE_SCHEME + "://%s";
+ public static final String CONTENT_TEMP_DIRECTORY = "contentUri";
+
+ /*
+ * A basic Filter for checking a filename and age.
+ **/
+ static public class NameAndAgeFilter implements FilenameFilter {
+ final private String mName;
+ final private double mMaxAge;
+
+ public NameAndAgeFilter(final String name, final double age) {
+ mName = name;
+ mMaxAge = age;
+ }
+
+ @Override
+ public boolean accept(final File dir, final String filename) {
+ if (mName == null || mName.matches(filename)) {
+ File f = new File(dir, filename);
+
+ if (mMaxAge < 0 || System.currentTimeMillis() - f.lastModified() > mMaxAge) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ @RobocopTarget
+ public static void delTree(final File dir, final FilenameFilter filter, final boolean recurse) {
+ String[] files = null;
+
+ if (filter != null) {
+ files = dir.list(filter);
+ } else {
+ files = dir.list();
+ }
+
+ if (files == null) {
+ return;
+ }
+
+ for (String file : files) {
+ File f = new File(dir, file);
+ delete(f, recurse);
+ }
+ }
+
+ public static boolean delete(final File file) throws IOException {
+ return delete(file, true);
+ }
+
+ public static boolean delete(final File file, final boolean recurse) {
+ if (file.isDirectory() && recurse) {
+ // If the quick delete failed and this is a dir, recursively delete the contents of the dir
+ String files[] = file.list();
+ for (String temp : files) {
+ File fileDelete = new File(file, temp);
+ try {
+ delete(fileDelete);
+ } catch (IOException ex) {
+ Log.i(LOGTAG, "Error deleting " + fileDelete.getPath(), ex);
+ }
+ }
+ }
+
+ // Even if this is a dir, it should now be empty and delete should work
+ return file.delete();
+ }
+
+ /**
+ * A generic solution to read a JSONObject from a file. See
+ * {@link #readStringFromFile(File)} for more details.
+ *
+ * @throws IOException if the file is empty, or another IOException occurs
+ * @throws JSONException if the file could not be converted to a JSONObject.
+ */
+ public static JSONObject readJSONObjectFromFile(final File file) throws IOException, JSONException {
+ if (file.length() == 0) {
+ // Redirect this exception so it's clearer than when the JSON parser catches it.
+ throw new IOException("Given file is empty - the JSON parser cannot create an object from an empty file");
+ }
+ return new JSONObject(readStringFromFile(file));
+ }
+
+ /**
+ * A generic solution to read from a file. For more details,
+ * see {@link #readStringFromInputStreamAndCloseStream(InputStream, int)}.
+ *
+ * This method loads the entire file into memory so will have the expected performance impact.
+ * If you're trying to read a large file, you should be handling your own reading to avoid
+ * out-of-memory errors.
+ */
+ public static String readStringFromFile(final File file) throws IOException {
+ // FileInputStream will throw FileNotFoundException if the file does not exist, but
+ // File.length will return 0 if the file does not exist so we catch it sooner.
+ if (!file.exists()) {
+ throw new FileNotFoundException("Given file, " + file + ", does not exist");
+ } else if (file.length() == 0) {
+ return "";
+ }
+ final int len = (int) file.length(); // includes potential EOF character.
+ return readStringFromInputStreamAndCloseStream(new FileInputStream(file), len);
+ }
+
+ /**
+ * A generic solution to read from an input stream in UTF-8. This function will read from the stream until it
+ * is finished and close the stream - this is necessary to close the wrapping resources.
+ *
+ * For a higher-level method, see {@link #readStringFromFile(File)}.
+ *
+ * Since this is generic, it may not be the most performant for your use case.
+ *
+ * @param bufferSize Size of the underlying buffer for read optimizations - must be &gt; 0.
+ */
+ public static String readStringFromInputStreamAndCloseStream(final InputStream inputStream, final int bufferSize)
+ throws IOException {
+ InputStreamReader reader = null;
+ try {
+ if (bufferSize <= 0) {
+ throw new IllegalArgumentException("Expected buffer size larger than 0. Got: " + bufferSize);
+ }
+
+ final StringBuilder stringBuilder = new StringBuilder(bufferSize);
+ reader = new InputStreamReader(inputStream, StringUtils.UTF_8);
+
+ int charsRead;
+ final char[] buffer = new char[bufferSize];
+ while ((charsRead = reader.read(buffer, 0, bufferSize)) != -1) {
+ stringBuilder.append(buffer, 0, charsRead);
+ }
+
+ return stringBuilder.toString();
+ } finally {
+ IOUtils.safeStreamClose(reader);
+ IOUtils.safeStreamClose(inputStream);
+ }
+ }
+
+ /**
+ * A generic solution to write a JSONObject to a file.
+ * See {@link #writeStringToFile(File, String)} for more details.
+ */
+ public static void writeJSONObjectToFile(final File file, final JSONObject obj) throws IOException {
+ writeStringToFile(file, obj.toString());
+ }
+
+ /**
+ * A generic solution to write to a File - the given file will be overwritten. If it does not exist yet, it will
+ * be created. See {@link #writeStringToOutputStreamAndCloseStream(OutputStream, String)} for more details.
+ */
+ public static void writeStringToFile(final File file, final String str) throws IOException {
+ writeStringToOutputStreamAndCloseStream(new FileOutputStream(file, false), str);
+ }
+
+ /**
+ * A generic solution to write to an output stream in UTF-8. The stream will be closed at the
+ * completion of this method - it's necessary in order to close the wrapping resources.
+ *
+ * For a higher-level method, see {@link #writeStringToFile(File, String)}.
+ *
+ * Since this is generic, it may not be the most performant for your use case.
+ */
+ public static void writeStringToOutputStreamAndCloseStream(final OutputStream outputStream, final String str)
+ throws IOException {
+ try {
+ final OutputStreamWriter writer = new OutputStreamWriter(outputStream, Charset.forName("UTF-8"));
+ try {
+ writer.write(str);
+ } finally {
+ writer.close();
+ }
+ } finally {
+ // OutputStreamWriter.close can throw before closing the
+ // underlying stream. For safety, we close here too.
+ outputStream.close();
+ }
+ }
+
+ public static class FilenameWhitelistFilter implements FilenameFilter {
+ private final Set<String> mFilenameWhitelist;
+
+ public FilenameWhitelistFilter(final Set<String> filenameWhitelist) {
+ mFilenameWhitelist = filenameWhitelist;
+ }
+
+ @Override
+ public boolean accept(final File dir, final String filename) {
+ return mFilenameWhitelist.contains(filename);
+ }
+ }
+
+ public static class FilenameRegexFilter implements FilenameFilter {
+ private final Pattern mPattern;
+
+ // Each time `Pattern.matcher` is called, a new matcher is created. We can avoid the excessive object creation
+ // by caching the returned matcher and calling `Matcher.reset` on it. Since Matcher's are not thread safe,
+ // this assumes `FilenameFilter.accept` is not run in parallel (which, according to the source, it is not).
+ private Matcher mCachedMatcher;
+
+ public FilenameRegexFilter(final Pattern pattern) {
+ mPattern = pattern;
+ }
+
+ public FilenameRegexFilter(final String pattern) {
+ mPattern = Pattern.compile(pattern);
+ }
+
+ @Override
+ public boolean accept(final File dir, final String filename) {
+ if (mCachedMatcher == null) {
+ mCachedMatcher = mPattern.matcher(filename);
+ } else {
+ mCachedMatcher.reset(filename);
+ }
+ return mCachedMatcher.matches();
+ }
+ }
+
+ public static class FileLastModifiedComparator implements Comparator<File> {
+ @Override
+ public int compare(final File lhs, final File rhs) {
+ // Long.compare is API 19+.
+ final long lhsModified = lhs.lastModified();
+ final long rhsModified = rhs.lastModified();
+ if (lhsModified < rhsModified) {
+ return -1;
+ } else if (lhsModified == rhsModified) {
+ return 0;
+ } else {
+ return 1;
+ }
+ }
+ }
+
+ public static File createTempDir(final File directory, final String prefix) {
+ // Force a prefix null check first
+ if (prefix.length() < 3) {
+ throw new IllegalArgumentException("prefix must be at least 3 characters");
+ }
+ File tempDirectory = directory;
+ if (tempDirectory == null) {
+ String tmpDir = System.getProperty("java.io.tmpdir", ".");
+ tempDirectory = new File(tmpDir);
+ }
+ File result;
+ Random random = new Random();
+ do {
+ result = new File(tempDirectory, prefix + random.nextInt());
+ } while (!result.mkdirs());
+ return result;
+ }
+
+ public static String resolveContentUri(final Context context, final Uri uri) {
+ String path = getOriginalFilePathFromUri(context, uri);
+ if (TextUtils.isEmpty(path)) {
+ // We cannot always successfully guess the original path of the file behind the
+ // content:// URI, so we need a fallback. This will break local subresources and
+ // relative links, but unfortunately there's nothing else we can do
+ // (see https://issuetracker.google.com/issues/77406791).
+ path = getTempFilePathFromContentUri(context, uri);
+ }
+ return !TextUtils.isEmpty(path) ? String.format(FILE_ABSOLUTE_URI, path) : path;
+ }
+
+ public static String getFileNameFromContentUri(final Context context, final Uri uri) {
+ final ContentResolver cr = context.getContentResolver();
+ final String[] projection = {MediaStore.MediaColumns.DISPLAY_NAME};
+ String fileName = null;
+
+ try (Cursor metaCursor = cr.query(uri, projection, null, null, null);) {
+ if (metaCursor.moveToFirst()) {
+ fileName = metaCursor.getString(0);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+
+ return canonicalizeFilename(fileName);
+ }
+
+ public static void copy(final Context context, final Uri srcUri, final File dstFile) {
+ try (InputStream inputStream = context.getContentResolver().openInputStream(srcUri);
+ OutputStream outputStream = new FileOutputStream(dstFile)) {
+ IOUtils.copy(inputStream, outputStream);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ public static boolean isContentUri(final Uri uri) {
+ return uri != null && uri.getScheme() != null && CONTENT_SCHEME.equals(uri.getScheme());
+ }
+
+ public static boolean isContentUri(final String sUri) {
+ return sUri != null && sUri.startsWith(CONTENT_SCHEME);
+ }
+
+ /**
+ * Attempts to find the root path of an external (removable) SD card.
+ *
+ * @param uuid If you know the file system UUID (as returned e.g. by
+ * {@link StorageVolume#getUuid()}) of the storage device you're looking for, this
+ * may be used to filter down the selection of available non-emulated storage
+ * devices. If no storage device matching the given UUID was found, the first
+ * non-emulated storage device will be returned.
+ * @return The root path of the storage device.
+ */
+ @TargetApi(19)
+ public static @Nullable String getExternalStoragePath(final Context context,
+ final @Nullable String uuid) {
+ // Since around the time of Lollipop or Marshmallow, the common convention is for external
+ // SD cards to be mounted at /storage/<file system UUID>/, however this pattern is still not
+ // guaranteed to be 100 % reliable. Therefore we need another way of getting all potential
+ // mount points for external storage devices.
+ // StorageManager.getStorageVolumes() might possibly do the trick and be just what we need
+ // to enumerate all mount points, but it only works on API24+.
+ // So instead, we use the output of getExternalFilesDirs for this purpose, which works on
+ // API19 and up.
+ File [] externalStorages = context.getExternalFilesDirs(null);
+ String uuidDir = !TextUtils.isEmpty(uuid) ? '/' + uuid + '/' : null;
+
+ String firstNonEmulatedStorage = null;
+ String targetStorage = null;
+ for (File externalStorage : externalStorages) {
+ if (isExternalStorageEmulated(externalStorage)) {
+ // The paths returned by getExternalFilesDirs also include locations that actually
+ // sit on the internal "external" storage, so we need to filter them out again.
+ continue;
+ }
+ String storagePath = externalStorage.getAbsolutePath();
+ /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+ * NOTE: This is our big assumption in this function: That the folders returned by *
+ * context.getExternalFilesDir() will always be located somewhere inside *
+ * /<storage root path>/Android/<app specific directories>, so that we can retrieve *
+ * the storage root by simply snipping off everything starting from "/Android". *
+ * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
+ storagePath = storagePath.substring(0, storagePath.indexOf("/Android"));
+ if (firstNonEmulatedStorage == null) {
+ firstNonEmulatedStorage = storagePath;
+ }
+ if (!TextUtils.isEmpty(uuidDir) && storagePath.contains(uuidDir)) {
+ targetStorage = storagePath;
+ break;
+ }
+ }
+ if (targetStorage == null) {
+ // Either no UUID to narrow down the selection was given, or else this device doesn't
+ // mount its SD cards using the file system UUID, so we just fall back to the first
+ // non-emulated storage path we found.
+ targetStorage = firstNonEmulatedStorage;
+ }
+ return targetStorage;
+ }
+
+ /**
+ * Helper method because the framework version of this function is only available from API21+.
+ *
+ * @see Environment#isExternalStorageEmulated(File)
+ */
+ public static boolean isExternalStorageEmulated(final File path) {
+ if (Build.VERSION.SDK_INT >= 21) {
+ return Environment.isExternalStorageEmulated(path);
+ } else {
+ String absPath = path.getAbsolutePath();
+ // This is rather hacky, but then SD card support on older Android versions
+ // was equally messy.
+ return absPath.contains("/sdcard0") || absPath.contains("/storage/emulated");
+ }
+ }
+
+ private static @Nullable String canonicalizeFilename(@Nullable final String originalFilename) {
+ if (TextUtils.isEmpty(originalFilename)) {
+ return null;
+ } else {
+ return new File(originalFilename).getName();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java
new file mode 100644
index 0000000000..d9d237e71b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java
@@ -0,0 +1,14 @@
+/* -*- 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;
+
+public final class FloatUtils {
+ private FloatUtils() {}
+
+ public static boolean fuzzyEquals(final float a, final float b) {
+ return (Math.abs(a - b) < 1e-6);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java
new file mode 100644
index 0000000000..581a9807ea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java
@@ -0,0 +1,137 @@
+/* -*- 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.annotation.TargetApi;
+import android.os.Build;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+
+public final class GamepadUtils {
+ private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611;
+
+ private static View.OnKeyListener sClickDispatcher;
+ private static float sDeadZoneThresholdOverride = 1e-2f;
+
+ private GamepadUtils() {
+ }
+
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1)
+ private static boolean isGamepadKey(final KeyEvent event) {
+ return (event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD;
+ }
+
+ public static boolean isActionKey(final KeyEvent event) {
+ return (isGamepadKey(event) && (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_A));
+ }
+
+ public static boolean isActionKeyDown(final KeyEvent event) {
+ return isActionKey(event) && event.getAction() == KeyEvent.ACTION_DOWN;
+ }
+
+ public static boolean isBackKey(final KeyEvent event) {
+ return (isGamepadKey(event) && (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_B));
+ }
+
+ public static void overrideDeadZoneThreshold(final float threshold) {
+ sDeadZoneThresholdOverride = threshold;
+ }
+
+ public static boolean isValueInDeadZone(final MotionEvent event, final int axis) {
+ float threshold;
+ if (sDeadZoneThresholdOverride >= 0) {
+ threshold = sDeadZoneThresholdOverride;
+ } else {
+ InputDevice.MotionRange range = event.getDevice().getMotionRange(axis);
+ threshold = range.getFlat() + range.getFuzz();
+ }
+ float value = event.getAxisValue(axis);
+ return (Math.abs(value) < threshold);
+ }
+
+ public static boolean isPanningControl(final MotionEvent event) {
+ if ((event.getSource() & InputDevice.SOURCE_CLASS_MASK) != InputDevice.SOURCE_CLASS_JOYSTICK) {
+ return false;
+ }
+ if (isValueInDeadZone(event, MotionEvent.AXIS_X)
+ && isValueInDeadZone(event, MotionEvent.AXIS_Y)
+ && isValueInDeadZone(event, MotionEvent.AXIS_Z)
+ && isValueInDeadZone(event, MotionEvent.AXIS_RZ)) {
+ return false;
+ }
+ return true;
+ }
+
+ public static View.OnKeyListener getClickDispatcher() {
+ if (sClickDispatcher == null) {
+ sClickDispatcher = new View.OnKeyListener() {
+ @Override
+ public boolean onKey(final View v, final int keyCode, final KeyEvent event) {
+ if (isActionKeyDown(event)) {
+ return v.performClick();
+ }
+ return false;
+ }
+ };
+ }
+ return sClickDispatcher;
+ }
+
+ public 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
+ 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);
+ }
+
+ public 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;
+ int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int i = 0; deviceIds != null && i < deviceIds.length; i++) {
+ KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]);
+ if (keyCharacterMap != null && DEFAULT_O_BUTTON_LABEL ==
+ keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) {
+ swapped = true;
+ break;
+ }
+ }
+ return swapped;
+ }
+}
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..8e0891f342
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
@@ -0,0 +1,78 @@
+/* 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);
+ ThreadUtils.setBackgroundThread(thread);
+
+ 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);
+ }
+
+ /*package*/ static void postDelayed(final Runnable runnable, final long timeout) {
+ getHandler().postDelayed(runnable, timeout);
+ }
+}
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..9bdc228597
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java
@@ -0,0 +1,1093 @@
+/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+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;
+
+/**
+ * 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 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..2a63da7672
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java
@@ -0,0 +1,221 @@
+/* -*- 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.WrapForJNI;
+
+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.Locale;
+
+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.", "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."
+ };
+ 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.", "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
+ };
+
+ @WrapForJNI
+ public static boolean findDecoderCodecInfoForMimeType(final String aMimeType) {
+ int numCodecs = 0;
+ try {
+ numCodecs = MediaCodecList.getCodecCount();
+ } catch (final RuntimeException e) {
+ Log.e(LOGTAG, "Failed to retrieve media codec count", e);
+ return false;
+ }
+
+ for (int i = 0; i < numCodecs; ++i) {
+ MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder()) {
+ continue;
+ }
+ for (String mimeType : info.getSupportedTypes()) {
+ if (mimeType.equals(aMimeType)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @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 {
+ MediaCodecInfo info = aCodec.getCodecInfo();
+ MediaCodecInfo.CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType);
+ return capabilities != null &&
+ capabilities.isFeatureSupported(
+ MediaCodecInfo.CodecCapabilities.FEATURE_AdaptivePlayback);
+ } catch (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 (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 (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 (int colorFormat : capabilities.colorFormats) {
+ Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat));
+ }
+ for (int supportedColorFormat : supportedColorList) {
+ for (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;
+ }
+
+ 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(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..d8f4a1e88e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java
@@ -0,0 +1,160 @@
+/* -*- 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;
+import android.os.Build;
+import android.system.Os;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+
+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 static volatile File sLibDir;
+ private static volatile int sMachineType = -1;
+
+ private HardwareUtils() {
+ }
+
+ public static 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;
+ }
+
+ sLibDir = new File(context.getApplicationInfo().nativeLibraryDir);
+ sInited = true;
+ }
+
+ public static boolean isTablet() {
+ return sIsLargeTablet || sIsSmallTablet;
+ }
+
+ private static String getPreferredAbi() {
+ String abi = null;
+ if (Build.VERSION.SDK_INT >= 21) {
+ abi = Build.SUPPORTED_ABIS[0];
+ }
+ if (abi == null) {
+ abi = Build.CPU_ABI;
+ }
+ return abi;
+ }
+
+ public static boolean isARMSystem() {
+ return "armeabi-v7a".equals(getPreferredAbi());
+ }
+
+ public static boolean isARM64System() {
+ // 64-bit support was introduced in 21.
+ return "arm64-v8a".equals(getPreferredAbi());
+ }
+
+ public static boolean isX86System() {
+ if ("x86".equals(getPreferredAbi())) {
+ return true;
+ }
+ if (Build.VERSION.SDK_INT >= 21) {
+ // On some devices we have to look into the kernel release string.
+ try {
+ return Os.uname().release.contains("-x86_");
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Cannot get uname", e);
+ }
+ }
+ return false;
+ }
+
+ public static String getRealAbi() {
+ if (isX86System() && isARMSystem()) {
+ // Some x86 devices try to make us believe we're ARM,
+ // in which case CPU_ABI is not reliable.
+ return "x86";
+ }
+ return getPreferredAbi();
+ }
+
+ private static final int ELF_MACHINE_UNKNOWN = 0;
+ private static final int ELF_MACHINE_X86 = 0x03;
+ private static final int ELF_MACHINE_X86_64 = 0x3e;
+ private static final int ELF_MACHINE_ARM = 0x28;
+ private static final int ELF_MACHINE_AARCH64 = 0xb7;
+
+ private static int readElfMachineType(final File file) {
+ try (final FileInputStream is = new FileInputStream(file)) {
+ final byte[] buf = new byte[19];
+ int count = 0;
+ while (count != buf.length) {
+ count += is.read(buf, count, buf.length - count);
+ }
+
+ int machineType = buf[18];
+ if (machineType < 0) {
+ machineType += 256;
+ }
+
+ return machineType;
+ } catch (FileNotFoundException e) {
+ Log.w(LOGTAG, String.format("Failed to open %s", file.getAbsolutePath()));
+ return ELF_MACHINE_UNKNOWN;
+ } catch (IOException e) {
+ Log.w(LOGTAG, "Failed to read library", e);
+ return ELF_MACHINE_UNKNOWN;
+ }
+ }
+
+ private static String machineTypeToString(final int machineType) {
+ switch (machineType) {
+ case ELF_MACHINE_X86:
+ return "x86";
+ case ELF_MACHINE_X86_64:
+ return "x86_64";
+ case ELF_MACHINE_ARM:
+ return "arm";
+ case ELF_MACHINE_AARCH64:
+ return "aarch64";
+ case ELF_MACHINE_UNKNOWN:
+ default:
+ return String.format("unknown (0x%x)", machineType);
+ }
+ }
+
+ private static void initMachineType() {
+ if (sMachineType >= 0) {
+ return;
+ }
+
+ sMachineType = readElfMachineType(new File(sLibDir, System.mapLibraryName("mozglue")));
+ }
+
+ /**
+ * @return The ABI of the libraries installed for this app.
+ */
+ public static String getLibrariesABI() {
+ initMachineType();
+
+ return machineTypeToString(sMachineType);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java
new file mode 100644
index 0000000000..87164cec00
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java
@@ -0,0 +1,177 @@
+/* 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.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Hashtable;
+
+public final class INIParser extends INISection {
+ // default file to read and write to
+ private final File mFile;
+
+ // List of sections in the current iniFile. null if the file has not been parsed yet
+ private Hashtable<String, INISection> mSections;
+
+ // create a parser. The file will not be read until you attempt to
+ // access sections or properties inside it. At that point its read synchronously
+ public INIParser(final File iniFile) {
+ super("");
+ mFile = iniFile;
+ }
+
+ // write ini data to the default file. Will overwrite anything current inside
+ public void write() {
+ writeTo(mFile);
+ }
+
+ // write to the specified file. Will overwrite anything current inside
+ public void writeTo(final File f) {
+ if (f == null)
+ return;
+
+ FileWriter outputStream = null;
+ try {
+ outputStream = new FileWriter(f);
+ } catch (IOException e1) {
+ e1.printStackTrace();
+ }
+
+ final BufferedWriter writer = new BufferedWriter(outputStream);
+ try {
+ write(writer);
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ IOUtils.safeStreamClose(writer);
+ }
+ }
+
+ @Override
+ public void write(final BufferedWriter writer) throws IOException {
+ super.write(writer);
+
+ if (mSections != null) {
+ for (Enumeration<INISection> e = mSections.elements(); e.hasMoreElements();) {
+ INISection section = e.nextElement();
+ section.write(writer);
+ writer.newLine();
+ }
+ }
+ }
+
+ // return all of the sections inside this file
+ public Hashtable<String, INISection> getSections() {
+ if (mSections == null) {
+ try {
+ parse();
+ } catch (IOException e) {
+ debug("Error parsing: " + e);
+ }
+ }
+ return mSections;
+ }
+
+ // parse the default file
+ @Override
+ protected void parse() throws IOException {
+ super.parse();
+ parse(mFile);
+ }
+
+ // parse a passed in file
+ private void parse(final File f) throws IOException {
+ // Set up internal data members
+ mSections = new Hashtable<String, INISection>();
+
+ if (f == null || !f.exists())
+ return;
+
+ FileReader inputStream = null;
+ try {
+ inputStream = new FileReader(f);
+ } catch (FileNotFoundException e1) {
+ // If the file doesn't exist. Just return;
+ return;
+ }
+
+ BufferedReader buf = new BufferedReader(inputStream);
+ String line = null; // current line of text we are parsing
+ INISection currentSection = null; // section we are currently parsing
+
+ while ((line = buf.readLine()) != null) {
+
+ if (line != null)
+ line = line.trim();
+
+ // blank line or a comment. ignore it
+ if (line == null || line.length() == 0 || line.charAt(0) == ';') {
+ debug("Ignore line: " + line);
+ } else if (line.charAt(0) == '[') {
+ debug("Parse as section: " + line);
+ currentSection = new INISection(line.substring(1, line.length() - 1));
+ mSections.put(currentSection.getName(), currentSection);
+ } else {
+ debug("Parse as property: " + line);
+
+ String[] pieces = line.split("=");
+ if (pieces.length != 2)
+ continue;
+
+ String key = pieces[0].trim();
+ String value = pieces[1].trim();
+ if (currentSection != null) {
+ currentSection.setProperty(key, value);
+ } else {
+ mProperties.put(key, value);
+ }
+ }
+ }
+ buf.close();
+ }
+
+ // add a section to the file
+ public void addSection(final INISection sect) {
+ // ensure that we have parsed the file
+ getSections();
+ mSections.put(sect.getName(), sect);
+ }
+
+ // get a section from the file. will return null if the section doesn't exist
+ public INISection getSection(final String key) {
+ // ensure that we have parsed the file
+ getSections();
+ return mSections.get(key);
+ }
+
+ // remove an entire section from the file
+ public void removeSection(final String name) {
+ // ensure that we have parsed the file
+ getSections();
+ mSections.remove(name);
+ }
+
+ // rename a section; nuking any previous section with the new
+ // name in the process
+ public void renameSection(final String oldName, final String newName) {
+ // ensure that we have parsed the file
+ getSections();
+
+ mSections.remove(newName);
+ INISection section = mSections.get(oldName);
+ if (section == null)
+ return;
+
+ section.setName(newName);
+ mSections.remove(oldName);
+ mSections.put(newName, section);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java
new file mode 100644
index 0000000000..5ab7700559
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java
@@ -0,0 +1,127 @@
+/* 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.text.TextUtils;
+import android.util.Log;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Hashtable;
+
+public class INISection {
+ private static final String LOGTAG = "INIParser";
+
+ // default file to read and write to
+ private String mName;
+ public String getName() {
+ return mName;
+ }
+ public void setName(final String name) {
+ mName = name;
+ }
+
+ // show or hide debug logging
+ private boolean mDebug;
+
+ // Global properties that aren't inside a section in the file
+ protected Hashtable<String, Object> mProperties;
+
+ // create a parser. The file will not be read until you attempt to
+ // access sections or properties inside it. At that point its read synchronously
+ public INISection(final String name) {
+ mName = name;
+ }
+
+ // log a debug string to the console
+ protected void debug(final String msg) {
+ if (mDebug) {
+ Log.i(LOGTAG, msg);
+ }
+ }
+
+ // get a global property out of the hash table. will return null if the property doesn't exist
+ public Object getProperty(final String key) {
+ getProperties(); // ensure that we have parsed the file
+ return mProperties.get(key);
+ }
+
+ // get a global property out of the hash table. will return null if the property doesn't exist
+ public int getIntProperty(final String key) {
+ Object val = getProperty(key);
+ if (val == null)
+ return -1;
+
+ return Integer.parseInt(val.toString());
+ }
+
+ // get a global property out of the hash table. will return null if the property doesn't exist
+ public String getStringProperty(final String key) {
+ Object val = getProperty(key);
+ if (val == null)
+ return null;
+
+ return val.toString();
+ }
+
+ // get a hashtable of all the global properties in this file
+ public Hashtable<String, Object> getProperties() {
+ if (mProperties == null) {
+ try {
+ parse();
+ } catch (IOException e) {
+ debug("Error parsing: " + e);
+ }
+ }
+ return mProperties;
+ }
+
+ // do nothing for generic sections
+ protected void parse() throws IOException {
+ mProperties = new Hashtable<String, Object>();
+ }
+
+ // set a property. Will erase the property if value = null
+ public void setProperty(final String key, final Object value) {
+ getProperties(); // ensure that we have parsed the file
+ if (value == null)
+ removeProperty(key);
+ else
+ mProperties.put(key.trim(), value);
+ }
+
+ // remove a property
+ public void removeProperty(final String name) {
+ // ensure that we have parsed the file
+ getProperties();
+ mProperties.remove(name);
+ }
+
+ public void write(final BufferedWriter writer) throws IOException {
+ if (!TextUtils.isEmpty(mName)) {
+ writer.write("[" + mName + "]");
+ writer.newLine();
+ }
+
+ if (mProperties != null) {
+ for (Enumeration<String> e = mProperties.keys(); e.hasMoreElements();) {
+ String key = e.nextElement();
+ writeProperty(writer, key, mProperties.get(key));
+ }
+ }
+ writer.newLine();
+ }
+
+ // Helper function to write out a property
+ private void writeProperty(final BufferedWriter writer, final String key, final Object value) {
+ try {
+ writer.write(key + "=" + value);
+ writer.newLine();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java
new file mode 100644
index 0000000000..761e0b6abf
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java
@@ -0,0 +1,125 @@
+/* -*- 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.util.Log;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Static helper class containing useful methods for manipulating IO objects.
+ */
+public class IOUtils {
+ private static final String LOGTAG = "GeckoIOUtils";
+
+ /**
+ * Represents the result of consuming an input stream, holding the returned data as well
+ * as the length of the data returned.
+ * The byte[] is not guaranteed to be trimmed to the size of the data acquired from the stream:
+ * hence the need for the length field. This strategy avoids the need to copy the data into a
+ * trimmed buffer after consumption.
+ */
+ public static class ConsumedInputStream {
+ public final int consumedLength;
+ // Only reassigned in getTruncatedData.
+ private byte[] mConsumedData;
+
+ public ConsumedInputStream(final int consumedLength, final byte[] consumedData) {
+ this.consumedLength = consumedLength;
+ this.mConsumedData = consumedData;
+ }
+
+ /**
+ * Get the data trimmed to the length of the actual payload read, caching the result.
+ */
+ public byte[] getTruncatedData() {
+ if (mConsumedData.length == consumedLength) {
+ return mConsumedData;
+ }
+
+ mConsumedData = truncateBytes(mConsumedData, consumedLength);
+ return mConsumedData;
+ }
+
+ public byte[] getData() {
+ return mConsumedData;
+ }
+ }
+
+ /**
+ * Fully read an InputStream into a byte array.
+ * @param iStream the InputStream to consume.
+ * @param bufferSize The initial size of the buffer to allocate. It will be grown as
+ * needed, but if the caller knows something about the InputStream then
+ * passing a good value here can improve performance.
+ */
+ public static ConsumedInputStream readFully(final InputStream iStream, final int bufferSize) {
+ // Allocate a buffer to hold the raw data downloaded.
+ byte[] buffer = new byte[bufferSize];
+
+ // The offset of the start of the buffer's free space.
+ int bPointer = 0;
+
+ // The quantity of bytes the last call to read yielded.
+ int lastRead = 0;
+ try {
+ // Fully read the data into the buffer.
+ while (lastRead != -1) {
+ // Read as many bytes as are currently available into the buffer.
+ lastRead = iStream.read(buffer, bPointer, buffer.length - bPointer);
+ bPointer += lastRead;
+
+ // If buffer has overflowed, double its size and carry on.
+ if (bPointer > buffer.length) {
+ int newBufferSize = bufferSize * 2;
+ byte[] newBuffer = new byte[newBufferSize];
+
+ // Copy the contents of the old buffer into the new buffer.
+ System.arraycopy(buffer, 0, newBuffer, 0, buffer.length);
+ buffer = newBuffer;
+ }
+ }
+
+ return new ConsumedInputStream(bPointer + 1, buffer);
+ } catch (IOException e) {
+ Log.e(LOGTAG, "Error consuming input stream.", e);
+ } finally {
+ IOUtils.safeStreamClose(iStream);
+ }
+
+ return null;
+ }
+
+ /**
+ * Truncate a given byte[] to a given length. Returns a new byte[] with the first length many
+ * bytes of the input.
+ */
+ public static byte[] truncateBytes(final byte[] bytes, final int length) {
+ byte[] newBytes = new byte[length];
+ System.arraycopy(bytes, 0, newBytes, 0, length);
+
+ return newBytes;
+ }
+
+ public static void safeStreamClose(final Closeable stream) {
+ try {
+ if (stream != null)
+ stream.close();
+ } catch (IOException e) { }
+ }
+
+ public static void copy(final InputStream in, final OutputStream out) throws IOException {
+ byte[] buffer = new byte[4096];
+ int len;
+
+ while ((len = in.read(buffer)) != -1) {
+ out.write(buffer, 0, len);
+ }
+ }
+}
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..d6cef86710
--- /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..c34591e881
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java
@@ -0,0 +1,84 @@
+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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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..bfd747b0fa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java
@@ -0,0 +1,374 @@
+/* -*- 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 androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Log;
+
+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) {
+ 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..723b747f55
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java
@@ -0,0 +1,18 @@
+/* -*- 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) {
+ 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..71aa1d0e0b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
@@ -0,0 +1,219 @@
+/*
+ * 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 android.os.Bundle;
+import androidx.annotation.CheckResult;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+
+import org.mozilla.gecko.mozglue.SafeIntent;
+
+import java.net.URISyntaxException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utilities for Intents.
+ */
+public class IntentUtils {
+ public static final String ENV_VAR_IN_AUTOMATION = "MOZ_IN_AUTOMATION";
+
+ private static final String ENV_VAR_REGEX = "(.+)=(.*)";
+
+ private IntentUtils() {}
+
+ /**
+ * Returns a list of environment variables and their values. These are parsed from an Intent extra
+ * with the key -&gt; value format: env# -&gt; ENV_VAR=VALUE, where # is an integer starting at 0.
+ *
+ * @return A Map of environment variable name to value, e.g. ENV_VAR -&gt; VALUE
+ */
+ public static HashMap<String, String> getEnvVarMap(@NonNull final SafeIntent intent) {
+ // Optimization: get matcher for re-use. Pattern.matcher creates a new object every time so it'd be great
+ // to avoid the unnecessary allocation, particularly because we expect to be called on the startup path.
+ final Pattern envVarPattern = Pattern.compile(ENV_VAR_REGEX);
+ final Matcher matcher = envVarPattern.matcher(""); // argument does not matter here.
+
+ // This is expected to be an external intent so we should use SafeIntent to prevent crashing.
+ final HashMap<String, String> out = new HashMap<>();
+ int i = 0;
+ while (true) {
+ final String envKey = "env" + i;
+ i += 1;
+ if (!intent.hasExtra(envKey)) {
+ break;
+ }
+
+ maybeAddEnvVarToEnvVarMap(out, intent, envKey, matcher);
+ }
+ return out;
+ }
+
+ /**
+ * @param envVarMap the map to add the env var to
+ * @param intent the intent from which to extract the env var
+ * @param envKey the key at which the env var resides
+ * @param envVarMatcher a matcher initialized with the env var pattern to extract
+ */
+ private static void maybeAddEnvVarToEnvVarMap(@NonNull final HashMap<String, String> envVarMap,
+ @NonNull final SafeIntent intent, @NonNull final String envKey, @NonNull final Matcher envVarMatcher) {
+ final String envValue = intent.getStringExtra(envKey);
+ if (envValue == null) {
+ return; // nothing to do here!
+ }
+
+ envVarMatcher.reset(envValue);
+ if (envVarMatcher.matches()) {
+ final String envVarName = envVarMatcher.group(1);
+ final String envVarValue = envVarMatcher.group(2);
+ envVarMap.put(envVarName, envVarValue);
+ }
+ }
+
+ public static Bundle getBundleExtraSafe(final Intent intent, final String name) {
+ return new SafeIntent(intent).getBundleExtra(name);
+ }
+
+ public static String getStringExtraSafe(final Intent intent, final String name) {
+ return new SafeIntent(intent).getStringExtra(name);
+ }
+
+ public static boolean getBooleanExtraSafe(final Intent intent, final String name, final boolean defaultValue) {
+ return new SafeIntent(intent).getBooleanExtra(name, defaultValue);
+ }
+
+ /**
+ * Gets whether or not we're in automation from the passed in environment variables.
+ *
+ * We need to read environment variables from the intent string
+ * extra because environment variables from our test harness aren't set
+ * until Gecko is loaded, and we need to know this before then.
+ *
+ * The return value of this method should be used early since other
+ * initialization may depend on its results.
+ */
+ @CheckResult
+ public static boolean getIsInAutomationFromEnvironment(final SafeIntent intent) {
+ final HashMap<String, String> envVars = IntentUtils.getEnvVarMap(intent);
+ return !TextUtils.isEmpty(envVars.get(IntentUtils.ENV_VAR_IN_AUTOMATION));
+ }
+
+ /**
+ * 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..0308b875c8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java
@@ -0,0 +1,182 @@
+/* -*- 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.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import androidx.annotation.NonNull;
+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;
+ }
+ }
+
+ /**
+ * Indicates whether network connectivity exists and it is possible to establish connections and pass data.
+ */
+ public static boolean isConnected(final @NonNull Context context) {
+ return isConnected((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE));
+ }
+
+ 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 boolean isWifi(@NonNull final Context context) {
+ final ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ return getConnectionType(connectivityManager) == ConnectionType.WIFI;
+ }
+
+ 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..636586b231
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java
@@ -0,0 +1,156 @@
+/* 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 androidx.annotation.Nullable;
+import android.text.TextUtils;
+
+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 {
+ java.net.ProxySelector ps = java.net.ProxySelector.getDefault();
+ Proxy proxy = Proxy.NO_PROXY;
+ if (ps != null) {
+ 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) {
+ String string = System.getProperty(key);
+ if (string != null) {
+ try {
+ return Integer.parseInt(string);
+ } catch (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
+ StringBuilder patternBuilder = new StringBuilder();
+ for (int i = 0; i < nonProxyHosts.length(); i++) {
+ 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.
+ String pattern = patternBuilder.toString();
+ return host.matches(pattern);
+ }
+}
+
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java
new file mode 100644
index 0000000000..d02c07f4b0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java
@@ -0,0 +1,52 @@
+/* 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.Resources;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.StringWriter;
+
+/**
+ * {@code RawResource} provides API to load raw resources in different
+ * forms. For now, we only load them as strings. We're using raw resources
+ * as localizable 'assets' as opposed to a string that can be directly
+ * translatable e.g. JSON file vs string.
+ *
+ * This is just a utility class to avoid code duplication for the different
+ * cases where need to read such assets.
+ */
+public final class RawResource {
+ public static String getAsString(final Context context, final int id) throws IOException {
+ InputStreamReader reader = null;
+
+ try {
+ final Resources res = context.getResources();
+ final InputStream is = res.openRawResource(id);
+ if (is == null) {
+ return null;
+ }
+
+ reader = new InputStreamReader(is);
+
+ final char[] buffer = new char[1024];
+ final StringWriter s = new StringWriter();
+
+ int n;
+ while ((n = reader.read(buffer, 0, buffer.length)) != -1) {
+ s.write(buffer, 0, n);
+ }
+
+ return s.toString();
+ } finally {
+ if (reader != null) {
+ reader.close();
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StrictModeContext.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StrictModeContext.java
new file mode 100644
index 0000000000..7256e75479
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StrictModeContext.java
@@ -0,0 +1,92 @@
+// Copyright 2017 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// Copied from Chromium's /src/base/android/java/src/org/chromium/base/StrictModeContext.java.
+
+package org.mozilla.gecko.util;
+
+import android.os.StrictMode;
+
+import java.io.Closeable;
+
+/**
+ * Enables try-with-resources compatible StrictMode violation whitelisting.
+ *
+ * Example:
+ * <pre>
+ * try (StrictModeContext unused = StrictModeContext.allowDiskWrites()) {
+ * return Example.doThingThatRequiresDiskWrites();
+ * }
+ * </pre>
+ *
+ * Because the StrictModeContext variable is technically unused, the containing method might have to
+ * be annotated with <code>@SuppressWarnings("try")</code>.
+ *
+ */
+public final class StrictModeContext implements Closeable {
+ private final StrictMode.ThreadPolicy mThreadPolicy;
+ private final StrictMode.VmPolicy mVmPolicy;
+
+ private StrictModeContext(final StrictMode.ThreadPolicy threadPolicy,
+ final StrictMode.VmPolicy vmPolicy) {
+ mThreadPolicy = threadPolicy;
+ mVmPolicy = vmPolicy;
+ }
+
+ private StrictModeContext(final StrictMode.ThreadPolicy threadPolicy) {
+ this(threadPolicy, null);
+ }
+
+ private StrictModeContext(final StrictMode.VmPolicy vmPolicy) {
+ this(null, vmPolicy);
+ }
+
+ /**
+ * Convenience method for disabling all VM-level StrictMode checks with try-with-resources.
+ * Includes everything listed here:
+ * https://developer.android.com/reference/android/os/StrictMode.VmPolicy.Builder.html
+ */
+ public static StrictModeContext allowAllVmPolicies() {
+ StrictMode.VmPolicy oldPolicy = StrictMode.getVmPolicy();
+ StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX);
+ return new StrictModeContext(oldPolicy);
+ }
+
+ /**
+ * Convenience method for disabling StrictMode for disk-writes and -reads with
+ * try-with-resources.
+ */
+ public static StrictModeContext allowDiskWrites() {
+ StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskWrites();
+ return new StrictModeContext(oldPolicy);
+ }
+
+ /**
+ * Convenience method for disabling StrictMode for disk-reads with try-with-resources.
+ */
+ public static StrictModeContext allowDiskReads() {
+ StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
+ return new StrictModeContext(oldPolicy);
+ }
+
+ /**
+ * Convenience method for disabling StrictMode for slow calls with try-with-resources.
+ */
+ public static StrictModeContext allowSlowCalls() {
+ StrictMode.ThreadPolicy oldPolicy = StrictMode.getThreadPolicy();
+ StrictMode.setThreadPolicy(
+ new StrictMode.ThreadPolicy.Builder(oldPolicy).permitCustomSlowCalls().build());
+ return new StrictModeContext(oldPolicy);
+ }
+
+ @Override
+ public void close() {
+ if (mThreadPolicy != null) {
+ StrictMode.setThreadPolicy(mThreadPolicy);
+ }
+ if (mVmPolicy != null) {
+ StrictMode.setVmPolicy(mVmPolicy);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java
new file mode 100644
index 0000000000..87001bebc3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java
@@ -0,0 +1,331 @@
+/* -*- 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.graphics.Paint;
+import android.graphics.Rect;
+import android.net.Uri;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+
+import java.nio.charset.Charset;
+import java.util.Set;
+
+public class StringUtils {
+ private static final String LOGTAG = "GeckoStringUtils";
+
+ private static final String FILTER_URL_PREFIX = "filter://";
+ private static final String USER_ENTERED_URL_PREFIX = "user-entered:";
+
+
+ /**
+ * The UTF-8 charset.
+ */
+ public static final Charset UTF_8 = Charset.forName("UTF-8");
+
+ /*
+ * This method tries to guess if the given string could be a search query or URL,
+ * and returns a previous result if there is ambiguity
+ *
+ * Search examples:
+ * foo
+ * foo bar.com
+ * foo http://bar.com
+ *
+ * URL examples
+ * foo.com
+ * foo.c
+ * :foo
+ * http://foo.com bar
+ *
+ * wasSearchQuery specifies whether text was a search query before the latest change
+ * in text. In ambiguous cases where the new text can be either a search or a URL,
+ * wasSearchQuery is returned
+ */
+ public static boolean isSearchQuery(final String text, final boolean wasSearchQuery) {
+ // We remove leading and trailing white spaces when decoding URLs
+ String trimmedText = text.trim();
+ if (trimmedText.length() == 0) {
+ return wasSearchQuery;
+ }
+ int colon = trimmedText.indexOf(':');
+ int dot = trimmedText.indexOf('.');
+ int space = trimmedText.indexOf(' ');
+
+ // If a space is found in a trimmed string, we assume this is a search query(Bug 1278245)
+ if (space > -1) {
+ return true;
+ }
+ // Otherwise, if a dot or a colon is found, we assume this is a URL
+ if (dot > -1 || colon > -1) {
+ return false;
+ }
+ // Otherwise, text is ambiguous, and we keep its status unchanged
+ return wasSearchQuery;
+ }
+
+ /**
+ * Check for the existence of %s and %S in a given URL
+ *
+ * @return True if %s or %S exists, False otherwise.
+ */
+ public static boolean queryExists(final String inputURL) {
+ if (inputURL == null) {
+ return false;
+ }
+ return inputURL.contains("%s") || inputURL.contains("%S");
+ }
+
+ /**
+ * Strip the ref from a URL, if present
+ *
+ * @return The base URL, without the ref. The original String is returned if it has no ref,
+ * of if the input is malformed.
+ */
+ public static String stripRef(final String inputURL) {
+ if (inputURL == null) {
+ return null;
+ }
+
+ final int refIndex = inputURL.indexOf('#');
+
+ if (refIndex >= 0) {
+ return inputURL.substring(0, refIndex);
+ }
+
+ return inputURL;
+ }
+
+ public static class UrlFlags {
+ public static final int NONE = 0;
+ public static final int STRIP_HTTPS = 1;
+ }
+
+ public static String stripScheme(final String url) {
+ return stripScheme(url, UrlFlags.NONE);
+ }
+
+ public static String stripScheme(final String url, final int flags) {
+ if (url == null) {
+ return url;
+ }
+
+ String newURL = url;
+
+ if (newURL.startsWith("http://")) {
+ newURL = newURL.replace("http://", "");
+ } else if (newURL.startsWith("https://") && flags == UrlFlags.STRIP_HTTPS) {
+ newURL = newURL.replace("https://", "");
+ }
+
+ if (newURL.endsWith("/")) {
+ newURL = newURL.substring(0, newURL.length() - 1);
+ }
+
+ return newURL;
+ }
+
+ public static boolean isHttpOrHttps(final String url) {
+ if (TextUtils.isEmpty(url)) {
+ return false;
+ }
+
+ return url.startsWith("http://") || url.startsWith("https://");
+ }
+
+ public static String stripCommonSubdomains(final String host) {
+ if (host == null) {
+ return host;
+ }
+
+ // In contrast to desktop, we also strip mobile subdomains,
+ // since its unlikely users are intentionally typing them
+ int start = 0;
+
+ if (host.startsWith("www.")) {
+ start = 4;
+ } else if (host.startsWith("mobile.")) {
+ start = 7;
+ } else if (host.startsWith("m.")) {
+ start = 2;
+ }
+
+ return host.substring(start);
+ }
+
+ /**
+ * Searches the url query string for the first value with the given key.
+ */
+ public static String getQueryParameter(final String url, final String desiredKey) {
+ if (TextUtils.isEmpty(url) || TextUtils.isEmpty(desiredKey)) {
+ return null;
+ }
+
+ final String[] urlParts = url.split("\\?");
+ if (urlParts.length < 2) {
+ return null;
+ }
+
+ final String query = urlParts[1];
+ for (final String param : query.split("&")) {
+ final String pair[] = param.split("=");
+ final String key = Uri.decode(pair[0]);
+
+ // Key is empty or does not match the key we're looking for, discard
+ if (TextUtils.isEmpty(key) || !key.equals(desiredKey)) {
+ continue;
+ }
+ // No value associated with key, discard
+ if (pair.length < 2) {
+ continue;
+ }
+ final String value = Uri.decode(pair[1]);
+ if (TextUtils.isEmpty(value)) {
+ return null;
+ }
+ return value;
+ }
+
+ return null;
+ }
+
+ public static boolean isFilterUrl(final String url) {
+ if (TextUtils.isEmpty(url)) {
+ return false;
+ }
+
+ return url.startsWith(FILTER_URL_PREFIX);
+ }
+
+ public static String getFilterFromUrl(final String url) {
+ if (TextUtils.isEmpty(url)) {
+ return null;
+ }
+
+ return url.substring(FILTER_URL_PREFIX.length());
+ }
+
+ public static boolean isShareableUrl(final String url) {
+ final String scheme = Uri.parse(url).getScheme();
+ return !("about".equals(scheme) || "chrome".equals(scheme) ||
+ "file".equals(scheme) || "resource".equals(scheme));
+ }
+
+ public static boolean isUserEnteredUrl(final String url) {
+ return (url != null && url.startsWith(USER_ENTERED_URL_PREFIX));
+ }
+
+ /**
+ * Given a url with a user-entered scheme, extract the
+ * scheme-specific component. For e.g, given "user-entered://www.google.com",
+ * this method returns "//www.google.com". If the passed url
+ * does not have a user-entered scheme, the same url will be returned.
+ *
+ * @param url to be decoded
+ * @return url component entered by user
+ */
+ public static String decodeUserEnteredUrl(final String url) {
+ Uri uri = Uri.parse(url);
+ if ("user-entered".equals(uri.getScheme())) {
+ return uri.getSchemeSpecificPart();
+ }
+ return url;
+ }
+
+ public static String encodeUserEnteredUrl(final String url) {
+ return Uri.fromParts("user-entered", url, null).toString();
+ }
+
+ /**
+ * Compatibility layer for API &lt; 11.
+ *
+ * Returns a set of the unique names of all query parameters. Iterating
+ * over the set will return the names in order of their first occurrence.
+ *
+ * @param uri
+ * @throws UnsupportedOperationException if this isn't a hierarchical URI
+ *
+ * @return a set of decoded names
+ */
+ public static Set<String> getQueryParameterNames(final Uri uri) {
+ return uri.getQueryParameterNames();
+ }
+
+ /**
+ * @return The index of the path segment of an URL, or -1 if no path segment was detected.
+ */
+ public static int pathStartIndex(final String text) {
+ if (text.contains("://")) {
+ return text.indexOf('/', text.indexOf("://") + 3);
+ } else {
+ return text.indexOf('/');
+ }
+ }
+
+ public static String safeSubstring(@NonNull final String str, final int start, final int end) {
+ return str.substring(
+ Math.max(0, start),
+ Math.min(end, str.length()));
+ }
+
+ /**
+ * Check if this might be a RTL (right-to-left) text by looking at the first character.
+ */
+ public static boolean isRTL(final String text) {
+ if (TextUtils.isEmpty(text)) {
+ return false;
+ }
+
+ final char character = text.charAt(0);
+ final byte directionality = Character.getDirectionality(character);
+
+ return directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT
+ || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC
+ || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING
+ || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE;
+ }
+
+ /**
+ * Force LTR (left-to-right) by prepending the text with the "left-to-right mark" (U+200E) if needed.
+ */
+ public static String forceLTR(final String text) {
+ if (!isRTL(text)) {
+ return text;
+ }
+
+ return "\u200E" + text;
+ }
+
+ /**
+ * Case-insensitive version of {@link String#startsWith(String)}.
+ */
+ public static boolean caseInsensitiveStartsWith(final String text, final String prefix) {
+ return caseInsensitiveStartsWith(text, prefix, 0);
+ }
+
+ /**
+ * Case-insensitive version of {@link String#startsWith(String, int)}.
+ */
+ public static boolean caseInsensitiveStartsWith(final String text, final String prefix,
+ final int start) {
+ return text.regionMatches(true, start, prefix, 0, prefix.length());
+ }
+
+ /**
+ * Measures the width of the given substring when rendered using the specified Paint.
+ *
+ * @param text String to measure and return its width
+ * @param start Index of the first char in the string to measure
+ * @param end 1 past the last char in the string measure
+ * @param textPaint the paint used to render the text
+ * @return the width of the specified substring in screen pixels
+ */
+ public static int getTextWidth(final String text, final int start, final int end, final Paint textPaint) {
+ final Rect bounds = new Rect();
+ textPaint.getTextBounds(text, start, end, bounds);
+ return bounds.width();
+ }
+}
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..829de8872f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java
@@ -0,0 +1,163 @@
+/* -*- 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;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+
+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());
+
+ private static volatile Thread sBackgroundThread;
+
+ // 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 void setBackgroundThread(final Thread thread) {
+ sBackgroundThread = thread;
+ }
+
+ 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 Thread getBackgroundThread() {
+ return sBackgroundThread;
+ }
+
+ 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);
+ }
+
+ public static void assertNotOnUiThread() {
+ assertNotOnThread(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);
+ }
+
+ public static void assertNotOnThread(final Thread expectedThread,
+ final AssertBehavior behavior) {
+ assertOnThreadComparison(expectedThread, behavior, false);
+ }
+
+ 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 isOnGeckoThread() {
+ if (sGeckoThread != null) {
+ return isOnThread(sGeckoThread);
+ }
+ return false;
+ }
+
+ public static boolean isOnUiThread() {
+ return isOnThread(getUiThread());
+ }
+
+ @RobocopTarget
+ public static boolean isOnBackgroundThread() {
+ if (sBackgroundThread == null) {
+ return false;
+ }
+
+ return isOnThread(sBackgroundThread);
+ }
+
+ @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/UUIDUtil.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.java
new file mode 100644
index 0000000000..cef303a870
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.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.util;
+
+import java.util.regex.Pattern;
+
+/**
+ * Utilities for UUIDs.
+ */
+public class UUIDUtil {
+ private UUIDUtil() {}
+
+ public static final String UUID_REGEX = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
+ public static final Pattern UUID_PATTERN = Pattern.compile(UUID_REGEX);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java
new file mode 100644
index 0000000000..3e8508bceb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java
@@ -0,0 +1,27 @@
+/* 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 java.lang.ref.WeakReference;
+
+/**
+ * A Handler to help prevent memory leaks when using Handlers as inner classes.
+ *
+ * To use, extend the Handler, if it's an inner class, make it static,
+ * and reference `this` via the associated WeakReference.
+ *
+ * For additional context, see the "HandlerLeak" android lint item and this post by Romain Guy:
+ * https://groups.google.com/forum/#!msg/android-developers/1aPZXZG6kWk/lIYDavGYn5UJ
+ */
+public class WeakReferenceHandler<T> extends Handler {
+ public final WeakReference<T> mTarget;
+
+ public WeakReferenceHandler(final T that) {
+ super();
+ mTarget = new WeakReference<>(that);
+ }
+}
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..4962316d30
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java
@@ -0,0 +1,165 @@
+/* -*- 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.WrapForJNI;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+import androidx.annotation.NonNull;
+
+/**
+ * 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 && !launcherThread().isOnCurrentThread()) {
+ throw new AssertionError("Expected to be running on XPCOM launcher thread");
+ }
+ }
+
+ public static void assertNotOnLauncherThread() {
+ if (BuildConfig.DEBUG && 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..59be6e08e1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
@@ -0,0 +1,17 @@
+/* -*- 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..cd04511146
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
@@ -0,0 +1,708 @@
+/* -*- 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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.util.Log;
+
+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>
+ * <p>
+ * The storage-level delegates connect Gecko mechanics to the app's storage,
+ * e.g., retrieving and storing of login entries.
+ * </p>
+ * <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>
+ * <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.
+ * </p>
+ * <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>LoginStorageDelegate.onLoginFetch(&quot;example.com&quot;)</code>
+ * request to fetch logins for the given domain.
+ * </p>
+ * <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>
+ * <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>
+ * <p>
+ * Based on the returned login entries, GeckoView will attempt to
+ * autofill/autocomplete the login input fields.
+ * </p>
+ *
+ * <h3>Update API</h3>
+ * <p>
+ * When the user submits some login input fields, GeckoView dispatches another
+ * <code>LoginStorageDelegate.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>
+ * <p>
+ * If the submitted login is already contained as-is in the collection returned
+ * by <code>onLoginFetch</code>, then GeckoView dispatches
+ * <code>LoginStorageDelegate.onLoginUsed</code> with the submitted login
+ * entry.
+ * </p>
+ * <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.
+ * </p>
+ *
+ * <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>
+ *
+ * <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>
+ * <p>
+ * The login entry returned in a confirmed save prompt is used to request for
+ * saving in the runtime delegate via
+ * <code>LoginStorageDelegate.onLoginSave(login)</code>.
+ * If the app has already stored the entry during the prompt request handling,
+ * it may ignore this storage saving request.
+ * </p>
+ *
+ * <br>@see GeckoRuntime#setLoginStorageDelegate
+ * <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 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() {
+ 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 })
+ /* package */ @interface LSUsedField {}
+
+ // Sync with UsedField in GeckoViewAutocomplete.jsm.
+ /**
+ * Possible login entry field types for {@link LoginStorageDelegate#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#setLoginStorageDelegate}.
+ */
+ public interface LoginStorageDelegate {
+ /**
+ * 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 String domain) {
+ 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 LoginEntry login) {}
+
+ /**
+ * 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 LoginEntry login,
+ @LSUsedField 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> {
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SaveOption(final @NonNull T value, final 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> {
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SelectOption(
+ final @NonNull T value,
+ final 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> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true,
+ value = { Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE })
+ /* package */ @interface LoginSaveHint {}
+
+ /**
+ * 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() {}
+ }
+
+ /**
+ * 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 @LoginSaveHint 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 login selection requests.
+ */
+ public static class LoginSelectOption extends SelectOption<LoginEntry> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true,
+ value = { Hint.NONE, Hint.GENERATED, Hint.INSECURE_FORM,
+ Hint.DUPLICATE_USERNAME, Hint.MATCHING_ORIGIN })
+ /* package */ @interface LoginSelectHint {}
+
+ /**
+ * Hint types for login 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 login.
+ * The login 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;
+ }
+
+ /**
+ * 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 @LoginSelectHint 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;
+ }
+ }
+
+ /* package */ final static class LoginStorageProxy implements BundleEventListener {
+ private static final String LOGTAG = "LoginStorageProxy";
+
+ private static final String FETCH_LOGIN_EVENT =
+ "GeckoView:Autocomplete:Fetch:Login";
+ private static final String SAVE_LOGIN_EVENT =
+ "GeckoView:Autocomplete:Save:Login";
+ private static final String USED_LOGIN_EVENT =
+ "GeckoView:Autocomplete:Used:Login";
+
+ private @Nullable LoginStorageDelegate mDelegate;
+
+ public LoginStorageProxy() {}
+
+ private void registerListener() {
+ EventDispatcher.getInstance().registerUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ SAVE_LOGIN_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ private void unregisterListener() {
+ EventDispatcher.getInstance().unregisterUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ SAVE_LOGIN_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ public synchronized void setDelegate(
+ final @Nullable LoginStorageDelegate delegate) {
+ if (mDelegate == null && delegate != null) {
+ registerListener();
+ } else if (mDelegate != null && delegate == null) {
+ unregisterListener();
+ }
+
+ mDelegate = delegate;
+ }
+
+ public synchronized @Nullable LoginStorageDelegate 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 LoginStorageDelegate attached");
+ }
+ return;
+ }
+
+ if (FETCH_LOGIN_EVENT.equals(event)) {
+ final String domain = message.getString("domain");
+ final GeckoResult<Autocomplete.LoginEntry[]> result =
+ mDelegate.onLoginFetch(domain);
+
+ 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 (SAVE_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+
+ mDelegate.onLoginSave(login);
+ } 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..f402fe4b3c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
@@ -0,0 +1,1251 @@
+/* -*- 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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collection;
+import java.util.LinkedList;
+import java.util.Locale;
+import java.util.Map;
+
+import android.annotation.TargetApi;
+import android.graphics.Rect;
+import android.os.Build;
+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 android.util.Log;
+import android.util.SparseArray;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillManager;
+import android.view.autofill.AutofillValue;
+
+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 static final class Notify {
+ private Notify() {}
+
+ /**
+ * An autofill session has started.
+ * Usually triggered by page load.
+ */
+ public static final int SESSION_STARTED = 0;
+
+ /**
+ * An autofill session has been committed.
+ * Triggered by form submission or navigation.
+ */
+ public static final int SESSION_COMMITTED = 1;
+
+ /**
+ * An autofill session has been canceled.
+ * Triggered by page unload.
+ */
+ public static final int SESSION_CANCELED = 2;
+
+ /**
+ * A node within the autofill session has been added.
+ */
+ public static final int NODE_ADDED = 3;
+
+ /**
+ * A node within the autofill session has been removed.
+ */
+ public static final int NODE_REMOVED = 4;
+
+ /**
+ * A node within the autofill session has been updated.
+ */
+ public static final int NODE_UPDATED = 5;
+
+ /**
+ * A node within the autofill session has gained focus.
+ */
+ public static final int NODE_FOCUSED = 6;
+
+ /**
+ * A node within the autofill session has lost focus.
+ */
+ public static final int NODE_BLURRED = 7;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(
+ final @AutofillNotify int notification) {
+ final String[] map = new String[] {
+ "SESSION_STARTED", "SESSION_COMMITTED", "SESSION_CANCELED",
+ "NODE_ADDED", "NODE_REMOVED", "NODE_UPDATED", "NODE_FOCUSED",
+ "NODE_BLURRED" };
+ if (notification < 0 || notification >= map.length) {
+ return null;
+ }
+ return map[notification];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ Notify.SESSION_STARTED,
+ Notify.SESSION_COMMITTED,
+ Notify.SESSION_CANCELED,
+ Notify.NODE_ADDED,
+ Notify.NODE_REMOVED,
+ Notify.NODE_UPDATED,
+ Notify.NODE_FOCUSED,
+ Notify.NODE_BLURRED})
+ /* package */ @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 })
+ /* package */ @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 })
+ /* package */ @interface AutofillInputType {}
+
+ /**
+ * 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 SparseArray<Node> mNodes;
+ // TODO: support session id?
+ private int mId = View.NO_ID;
+ private int mFocusedId = View.NO_ID;
+ private int mFocusedRootId = View.NO_ID;
+
+ /* package */ Session(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ clear();
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Rect getDefaultDimensions() {
+ return Support.getDummyAutofillRect(mGeckoSession, false, null);
+ }
+
+ /* package */ void clear() {
+ mId = View.NO_ID;
+ mFocusedId = View.NO_ID;
+ mFocusedRootId = View.NO_ID;
+ mRoot = new Node.Builder(this)
+ .dimensions(getDefaultDimensions())
+ .build();
+ mNodes = new SparseArray<>();
+ }
+
+ /* package */ boolean isEmpty() {
+ return mNodes.size() == 0;
+ }
+
+ /* package */ void addNode(@NonNull final Node node) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addNode: " + node);
+ }
+ node.setAutofillSession(this);
+ mNodes.put(node.getId(), node);
+
+ if (node.getParentId() == View.NO_ID) {
+ mRoot.addChild(node);
+ }
+ }
+
+ /* package */ void setFocus(final int id, final int rootId) {
+ mFocusedId = id;
+ mFocusedRootId = rootId;
+ }
+
+ /* package */ int getFocusedId() {
+ return mFocusedId;
+ }
+
+ /* package */ int getFocusedRootId() {
+ return mFocusedRootId;
+ }
+
+ /* package */ @Nullable Node getNode(final int id) {
+ return mNodes.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;
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ StringBuilder builder = new StringBuilder("Session {");
+ builder
+ .append("id=").append(mId)
+ .append(", focusedId=").append(mFocusedId)
+ .append(", focusedRootId=").append(mFocusedRootId)
+ .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();
+
+ getRoot().fillViewStructure(view, structure, flags);
+ }
+ }
+
+ /**
+ * Represents an autofill node.
+ * A node is an input element and may contain child nodes forming a tree.
+ */
+ public static final class Node {
+ private static final String LOGTAG = "AutofillNode";
+
+ private int mId;
+ private int mRootId;
+ private int mParentId;
+ private Session mAutofillSession;
+ private @NonNull Rect mDimens;
+ private @NonNull Collection<Node> mChildren;
+ private @NonNull Map<String, String> mAttributes;
+ private boolean mEnabled;
+ private boolean mFocusable;
+ private @AutofillHint int mHint;
+ private @AutofillInputType int mInputType;
+ private @NonNull String mTag;
+ private @NonNull String mDomain;
+ private @NonNull String mValue;
+ private @Nullable EventCallback mCallback;
+
+ /**
+ * Get the unique (within this page) ID for this node.
+ *
+ * @return The unique ID of this node.
+ */
+ @AnyThread
+ public int getId() {
+ return mId;
+ }
+
+ /* package */ @NonNull Node setId(final int id) {
+ mId = id;
+ return this;
+ }
+
+ /* package */ @Nullable Node getRoot() {
+ return getAutofillSession().getNode(mRootId);
+ }
+
+ /* package */ @NonNull Node setRootId(final int rootId) {
+ mRootId = rootId;
+ return this;
+ }
+
+ /* package */ @Nullable Node getParent() {
+ return getAutofillSession().getNode(mParentId);
+ }
+
+ /* package */ int getParentId() {
+ return mParentId;
+ }
+
+ /* package */ @NonNull Node setParentId(final int parentId) {
+ mParentId = parentId;
+ return this;
+ }
+
+ /* package */ @NonNull Session getAutofillSession() {
+ return mAutofillSession;
+ }
+
+ /* package */ @NonNull Node setAutofillSession(
+ @Nullable final Session session) {
+ mAutofillSession = session;
+ return this;
+ }
+
+
+ /**
+ * Get whether this node is visible.
+ * Nodes are visible, when they are part of a focused branch.
+ * A focused branch includes the focused node, its siblings, its parent
+ * and the session root node.
+ *
+ * @return True if this node is visible, false otherwise.
+ */
+ @AnyThread
+ public boolean getVisible() {
+ final int focusedId = getAutofillSession().getFocusedId();
+ final int focusedRootId = getAutofillSession().getFocusedRootId();
+
+ if (focusedId == View.NO_ID) {
+ return false;
+ }
+
+ final int focusedParentId =
+ getAutofillSession().getNode(focusedId).getParentId();
+
+ return mId == View.NO_ID || // The session root node.
+ mParentId == focusedParentId ||
+ mRootId == focusedRootId;
+ }
+
+ /**
+ * Get the dimensions of this node in CSS coordinates.
+ * Note: Invisible nodes will report their proper dimensions, see
+ * {@link #getVisible} for details.
+ *
+ * @return The dimensions of this node.
+ */
+ @AnyThread
+ public @NonNull Rect getDimensions() {
+ return mDimens;
+ }
+
+ /* package */ @NonNull Node setDimensions(final Rect rect) {
+ mDimens = rect;
+ return this;
+ }
+
+ /**
+ * Get the child nodes for this node.
+ *
+ * @return The collection of child nodes for this node.
+ */
+ @AnyThread
+ public @NonNull Collection<Node> getChildren() {
+ return mChildren;
+ }
+
+ /* package */ @NonNull Node addChild(@NonNull final Node child) {
+ mChildren.add(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);
+ }
+
+ /* package */ @NonNull Node setAttributes(
+ final Map<String, String> attributes) {
+ mAttributes = attributes;
+ return this;
+ }
+
+ /* package */ @NonNull Node setAttribute(
+ final String key, final String value) {
+ mAttributes.put(key, value);
+ return this;
+ }
+
+ /**
+ * Get whether or not this node is enabled.
+ *
+ * @return True if the node is enabled, false otherwise.
+ */
+ @AnyThread
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ /* package */ @NonNull Node setEnabled(final boolean enabled) {
+ mEnabled = enabled;
+ return this;
+ }
+
+ /**
+ * Get whether or not this node is focusable.
+ *
+ * @return True if the node is focusable, false otherwise.
+ */
+ @AnyThread
+ public boolean getFocusable() {
+ return mFocusable;
+ }
+
+ /* package */ @NonNull Node setFocusable(final boolean focusable) {
+ mFocusable = focusable;
+ return this;
+ }
+
+ /**
+ * Get whether or not this node is focused.
+ *
+ * @return True if this node is focused, false otherwise.
+ */
+ @AnyThread
+ public boolean getFocused() {
+ return getId() != View.NO_ID &&
+ getAutofillSession().getFocusedId() == getId();
+ }
+
+ /**
+ * 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;
+ }
+
+ /* package */ @NonNull Node setHint(final @AutofillHint int hint) {
+ mHint = hint;
+ return this;
+ }
+
+ /**
+ * 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;
+ }
+
+ /* package */ @NonNull Node setInputType(
+ final @AutofillInputType int inputType) {
+ mInputType = inputType;
+ return this;
+ }
+
+ /**
+ * Get the HTML tag of this node.
+ *
+ * @return The HTML tag of this node.
+ */
+ @AnyThread
+ public @NonNull String getTag() {
+ return mTag;
+ }
+
+ /* package */ @NonNull Node setTag(final String tag) {
+ mTag = tag;
+ return this;
+ }
+
+ /**
+ * Get web domain of this node.
+ *
+ * @return The domain of this node.
+ */
+ @AnyThread
+ public @NonNull String getDomain() {
+ return mDomain;
+ }
+
+ /* package */ @NonNull Node setDomain(final String domain) {
+ mDomain = domain;
+ return this;
+ }
+
+ /**
+ * Get the value assigned to this node.
+ *
+ * @return The value of this node.
+ */
+ @AnyThread
+ public @NonNull String getValue() {
+ return mValue;
+ }
+
+ /* package */ @NonNull Node setValue(final String value) {
+ mValue = value;
+ return this;
+ }
+
+ /* package */ @Nullable EventCallback getCallback() {
+ return mCallback;
+ }
+
+ /* package */ @NonNull Node setCallback(final EventCallback callback) {
+ mCallback = callback;
+ return this;
+ }
+
+ /* package */ Node(@NonNull final Session session) {
+ mAutofillSession = session;
+ mId = View.NO_ID;
+ mDimens = new Rect(0, 0, 0, 0);
+ mAttributes = new ArrayMap<>();
+ mEnabled = false;
+ mFocusable = false;
+ mHint = Hint.NONE;
+ mInputType = InputType.NONE;
+ mTag = "";
+ mDomain = "";
+ mValue = "";
+ mChildren = new LinkedList<>();
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ StringBuilder builder = new StringBuilder("Node {");
+ builder
+ .append("id=").append(mId)
+ .append(", parent=").append(mParentId)
+ .append(", root=").append(mRootId)
+ .append(", dims=").append(getDimensions().toShortString())
+ .append(", children=[");
+
+ for (final Node child: mChildren) {
+ builder.append(child.getId()).append(", ");
+ }
+
+ builder
+ .append("]")
+ .append(", attrs=").append(mAttributes)
+ .append(", enabled=").append(mEnabled)
+ .append(", focusable=").append(mFocusable)
+ .append(", focused=").append(getFocused())
+ .append(", visible=").append(getVisible())
+ .append(", hint=").append(Hint.toString(mHint))
+ .append(", type=").append(InputType.toString(mInputType))
+ .append(", tag=").append(mTag)
+ .append(", domain=").append(mDomain)
+ .append(", value=").append(mValue)
+ .append(", callback=").append(mCallback != null)
+ .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();
+
+ Log.d(LOGTAG, "fillViewStructure");
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillId(view.getAutofillId(), getId());
+ structure.setWebDomain(getDomain());
+ structure.setAutofillValue(AutofillValue.forText(getValue()));
+ }
+
+ structure.setId(getId(), null, null, null);
+ structure.setDimens(0, 0, 0, 0,
+ getDimensions().width(),
+ getDimensions().height());
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ final ViewStructure.HtmlInfo.Builder htmlBuilder =
+ structure.newHtmlInfoBuilder(getTag());
+ for (final String key : getAttributes().keySet()) {
+ htmlBuilder.addAttribute(key,
+ String.valueOf(getAttribute(key)));
+ }
+
+ structure.setHtmlInfo(htmlBuilder.build());
+ }
+
+ structure.setChildCount(getChildren().size());
+ int childCount = 0;
+
+ for (final Node child : getChildren()) {
+ final ViewStructure childStructure =
+ structure.newChild(childCount);
+ child.fillViewStructure(view, childStructure, flags);
+ childCount++;
+ }
+
+ switch (getTag()) {
+ case "input":
+ case "textarea":
+ structure.setClassName("android.widget.EditText");
+ structure.setEnabled(getEnabled());
+ structure.setFocusable(getFocusable());
+ structure.setFocused(getFocused());
+ structure.setVisibility(
+ getVisible()
+ ? 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(getTag())) {
+ return;
+ }
+ // LastPass will fill password to the field where setAutofillHints
+ // is unset and setInputType is set.
+ switch (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;
+ }
+ }
+
+ switch (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;
+ }
+ default:
+ break;
+ }
+ }
+
+ /* package */ static class Builder {
+ private Node mNode;
+
+ /* package */ Builder(@NonNull final Session session) {
+ mNode = new Node(session);
+ }
+
+ public Builder(
+ @NonNull final Session autofillSession,
+ @NonNull final GeckoBundle bundle) {
+ this(autofillSession);
+
+ final GeckoBundle bounds = bundle.getBundle("bounds");
+ mNode
+ .setAutofillSession(autofillSession)
+ .setId(bundle.getInt("id"))
+ .setParentId(bundle.getInt("parent", View.NO_ID))
+ .setRootId(bundle.getInt("root", View.NO_ID))
+ .setDomain(bundle.getString("origin", ""))
+ .setValue(bundle.getString("value", ""))
+ .setDimensions(
+ new Rect(bounds.getInt("left"),
+ bounds.getInt("top"),
+ bounds.getInt("right"),
+ bounds.getInt("bottom")));
+
+ if (mNode.getDimensions().isEmpty()) {
+ // Some nodes like <html> will have null-dimensions,
+ // we need to set them to the virtual documents dimensions.
+ mNode.setDimensions(autofillSession.getDefaultDimensions());
+ }
+
+ final GeckoBundle[] children = bundle.getBundleArray("children");
+ if (children != null) {
+ for (final GeckoBundle childBundle: children) {
+ final Node child =
+ new Builder(autofillSession, childBundle).build();
+ mNode.addChild(child);
+ autofillSession.addNode(child);
+ }
+ }
+
+ String tag = bundle.getString("tag", "").toLowerCase(Locale.ROOT);
+ mNode.setTag(tag);
+
+ final GeckoBundle attrs = bundle.getBundle("attributes");
+
+ for (final String key : attrs.keys()) {
+ mNode.setAttribute(key, String.valueOf(attrs.get(key)));
+ }
+
+ if ("input".equals(tag) &&
+ !bundle.getBoolean("editable", false)) {
+ // Don't process non-editable inputs (e.g., type="button").
+ tag = "";
+ }
+
+ switch (tag) {
+ case "input":
+ case "textarea": {
+ final boolean disabled = bundle.getBoolean("disabled");
+ mNode
+ .setEnabled(!disabled)
+ .setFocusable(!disabled);
+ break;
+ }
+ default:
+ break;
+ }
+
+ final String type =
+ bundle.getString("type", "text").toLowerCase(Locale.ROOT);
+
+ switch (type) {
+ case "email": {
+ mNode
+ .setHint(Hint.EMAIL_ADDRESS)
+ .setInputType(InputType.TEXT);
+ break;
+ }
+ case "number": {
+ mNode.setInputType(InputType.NUMBER);
+ break;
+ }
+ case "password": {
+ mNode
+ .setHint(Hint.PASSWORD)
+ .setInputType(InputType.TEXT);
+ break;
+ }
+ case "tel": {
+ mNode.setInputType(InputType.PHONE);
+ break;
+ }
+ case "url": {
+ mNode
+ .setHint(Hint.URI)
+ .setInputType(InputType.TEXT);
+ break;
+ }
+ case "text": {
+ final String autofillHint =
+ bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT);
+ if (autofillHint.equals("username")) {
+ mNode
+ .setHint(Hint.USERNAME)
+ .setInputType(InputType.TEXT);
+ }
+ break;
+ }
+ }
+ }
+
+ public @NonNull Builder dimensions(final Rect rect) {
+ mNode.setDimensions(rect);
+ return this;
+ }
+
+ public @NonNull Node build() {
+ return mNode;
+ }
+
+ public @NonNull Builder id(final int id) {
+ mNode.setId(id);
+ return this;
+ }
+
+ public @NonNull Builder child(@NonNull final Node child) {
+ mNode.addChild(child);
+ return this;
+ }
+
+ public @NonNull Builder attribute(
+ final String key, final String value) {
+ mNode.setAttribute(key, value);
+ return this;
+ }
+
+ public @NonNull Builder enabled(final boolean enabled) {
+ mNode.setEnabled(enabled);
+ return this;
+ }
+
+ public @NonNull Builder focusable(final boolean focusable) {
+ mNode.setFocusable(focusable);
+ return this;
+ }
+
+ public @NonNull Builder hint(final int hint) {
+ mNode.setHint(hint);
+ return this;
+ }
+
+ public @NonNull Builder inputType(final int inputType) {
+ mNode.setInputType(inputType);
+ return this;
+ }
+
+ public @NonNull Builder tag(final String tag) {
+ mNode.setTag(tag);
+ return this;
+ }
+
+ public @NonNull Builder domain(final String domain) {
+ mNode.setDomain(domain);
+ return this;
+ }
+
+ public @NonNull Builder value(final String value) {
+ mNode.setValue(value);
+ return this;
+ }
+ }
+ }
+
+ public interface Delegate {
+ /**
+ * Notify that an autofill event has occurred.
+ *
+ * The default implementation in {@link GeckoView} forwards the
+ * notification to the system {@link AutofillManager}.
+ * This method is only called on Android 6.0 and above and it is called
+ * in viewless mode as well.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param notification Notification type, one of {@link Notify}.
+ * @param node The target node for this event, or null for
+ * {@link Notify#SESSION_CANCELED}.
+ */
+ @UiThread
+ default void onAutofill(@NonNull GeckoSession session,
+ @AutofillNotify int notification,
+ @Nullable Node node) {}
+ }
+
+ /* 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:AddAutofill",
+ "GeckoView:ClearAutofill",
+ "GeckoView:CommitAutofill",
+ "GeckoView:OnAutofillFocus",
+ "GeckoView:UpdateAutofill");
+
+ }
+
+ @Override
+ public void handleMessage(
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:AddAutofill".equals(event)) {
+ addNode(message, callback);
+ } else if ("GeckoView:ClearAutofill".equals(event)) {
+ clear();
+ } else if ("GeckoView:OnAutofillFocus".equals(event)) {
+ onFocusChanged(message);
+ } else if ("GeckoView:CommitAutofill".equals(event)) {
+ commit(message);
+ } else if ("GeckoView:UpdateAutofill".equals(event)) {
+ update(message);
+ }
+ }
+
+ /**
+ * Perform auto-fill using the specified values.
+ *
+ * @param values Map of auto-fill IDs to values.
+ */
+ @UiThread
+ public void autofill(final SparseArray<CharSequence> values) {
+ ThreadUtils.assertOnUiThread();
+
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ GeckoBundle response = null;
+ EventCallback callback = null;
+
+ for (int i = 0; i < values.size(); i++) {
+ final int id = values.keyAt(i);
+ final CharSequence value = values.valueAt(i);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value);
+ }
+
+ int rootId = id;
+ for (int currentId = id; currentId != View.NO_ID; ) {
+ final Node elem = getAutofillSession().getNode(currentId);
+
+ if (elem == null) {
+ return;
+ }
+ rootId = currentId;
+ currentId = elem.getParentId();
+ }
+
+ final Node root = getAutofillSession().getNode(rootId);
+ final EventCallback newCallback =
+ root != null
+ ? root.getCallback()
+ : null;
+ if (callback == null || newCallback != callback) {
+ if (callback != null) {
+ callback.sendSuccess(response);
+ }
+ response = new GeckoBundle(values.size() - i);
+ callback = newCallback;
+ }
+ response.putString(String.valueOf(id), String.valueOf(value));
+ }
+
+ if (callback != null) {
+ callback.sendSuccess(response);
+ }
+ }
+
+ @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 boolean initializing = getAutofillSession().isEmpty();
+ final int id = message.getInt("id");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "addNode(" + id + ')');
+ }
+
+ if (initializing) {
+ // TODO: We need this to set the dimensions on the root node.
+ // We should find a better way of handling this.
+ getAutofillSession().clear();
+ }
+
+ final Node node = new Node.Builder(
+ getAutofillSession(), message).build();
+ node.setCallback(callback);
+ getAutofillSession().addNode(node);
+ maybeDispatch(
+ initializing
+ ? Notify.SESSION_STARTED
+ : Notify.NODE_ADDED,
+ node);
+ }
+
+ private void maybeDispatch(
+ final @AutofillNotify int notification, final Node node) {
+ if (mDelegate == null) {
+ return;
+ }
+
+ mDelegate.onAutofill(mGeckoSession, notification, node);
+ }
+
+ /* package */ void commit(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ final int id = message.getInt("id");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "commit(" + id + ")");
+ }
+
+ maybeDispatch(
+ Notify.SESSION_COMMITTED,
+ getAutofillSession().getNode(id));
+ }
+
+ /* package */ void update(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ final int id = message.getInt("id");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "update(" + id + ")");
+ }
+
+ final Node node = getAutofillSession().getNode(id);
+ final String value = message.getString("value", "");
+
+ if (node == null) {
+ Log.d(LOGTAG, "could not find node " + id);
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "updating node " + id + " value from " +
+ node.getValue() + " to " + value);
+ }
+
+ node.setValue(value);
+ maybeDispatch(Notify.NODE_UPDATED, node);
+ }
+
+ /* package */ void clear() {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "clear()");
+ }
+
+ getAutofillSession().clear();
+ maybeDispatch(Notify.SESSION_CANCELED, null);
+ }
+
+ /* package */ void onFocusChanged(
+ @Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ final int prevId = getAutofillSession().getFocusedId();
+ final int id;
+ final int root;
+
+ if (message != null) {
+ id = message.getInt("id");
+ root = message.getInt("root");
+ } else {
+ id = root = View.NO_ID;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "onFocusChanged(" + prevId + " -> " + id + ')');
+ }
+
+ if (prevId == id) {
+ return;
+ }
+
+ getAutofillSession().setFocus(id, root);
+
+ if (prevId != View.NO_ID) {
+ maybeDispatch(
+ Notify.NODE_BLURRED,
+ getAutofillSession().getNode(prevId));
+ }
+
+ if (id != View.NO_ID) {
+ maybeDispatch(
+ Notify.NODE_FOCUSED,
+ getAutofillSession().getNode(id));
+ }
+ }
+
+ /* package */ static Rect getDummyAutofillRect(
+ @NonNull final GeckoSession geckoSession,
+ final boolean screen,
+ @Nullable final View view) {
+ final Rect rect = new Rect();
+ geckoSession.getSurfaceBounds(rect);
+
+ if (screen) {
+ if (view == null) {
+ throw new IllegalArgumentException();
+ }
+ final int[] offset = new int[2];
+ view.getLocationOnScreen(offset);
+ rect.offset(offset[0], offset[1]);
+ }
+ return rect;
+ }
+
+ @UiThread
+ public void onActiveChanged(final boolean active) {
+ ThreadUtils.assertOnUiThread();
+
+ final int focusedId = getAutofillSession().getFocusedId();
+
+ if (focusedId == View.NO_ID) {
+ return;
+ }
+
+ maybeDispatch(
+ active
+ ? Notify.NODE_FOCUSED
+ : Notify.NODE_BLURRED,
+ getAutofillSession().getNode(focusedId));
+ }
+ }
+}
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..279bd88790
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
@@ -0,0 +1,14 @@
+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..c189dcb3f5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java
@@ -0,0 +1,437 @@
+/* -*- 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.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.util.Log;
+import android.view.ActionMode;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+
+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.
+ *
+ * To provide custom actions, extend this class and override the following methods,
+ *
+ * 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.
+ *
+ * 2) Override {@link #isActionAvailable} to return whether a custom action is currently available.
+ *
+ * 3) Override {@link #prepareAction} to set custom title and/or icon for a custom action.
+ *
+ * 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_PROCESS_TEXT
+ };
+ private static final String[] FIXED_TOOLBAR_ACTIONS = new String[] {
+ ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE
+ };
+
+ protected final @NonNull Activity mActivity;
+ protected final boolean mUseFloatingToolbar;
+ protected final @NonNull Matrix mTempMatrix = new Matrix();
+ 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;
+
+ @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 (mExternalActionsEnabled && !mSelection.text.isEmpty() &&
+ ACTION_PROCESS_TEXT.equals(id)) {
+ final PackageManager pm = mActivity.getPackageManager();
+ return pm.resolveActivity(getProcessTextIntent(),
+ PackageManager.MATCH_DEFAULT_ONLY) != null;
+ }
+ return mSelection.isActionAvailable(id);
+ }
+
+ /**
+ * 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_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 Intent getProcessTextIntent() {
+ final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT);
+ intent.addCategory(Intent.CATEGORY_DEFAULT);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT, mSelection.text);
+ // 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.text.isEmpty()) {
+ menu.addIntentOptions(menuId, menuId, menuId,
+ mActivity.getComponentName(),
+ /* specifiec */ null, getProcessTextIntent(),
+ /* flags */ 0, /* items */ null);
+ 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;
+ }
+
+ @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.clientRect == null) {
+ return;
+ }
+ mSession.getClientToScreenMatrix(mTempMatrix);
+ mTempMatrix.mapRect(mTempRect, mSelection.clientRect);
+ mTempRect.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 (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;
+ }
+ }
+}
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..0ef73599d9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
@@ -0,0 +1,17 @@
+/* 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..dedeabfb83
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
@@ -0,0 +1,138 @@
+/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.graphics.Color;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@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..aaa0215ee0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
@@ -0,0 +1,1655 @@
+/* -*- 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 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 android.os.Parcelable;
+import android.os.Parcel;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.text.TextUtils;
+
+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 final static 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 final static 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 final static 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 the cookie lifetime.
+ *
+ * @param lifetime The enforced cookie lifetime.
+ * Use one of the {@link CookieLifetime} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieLifetime(final @CBCookieLifetime int lifetime) {
+ getSettings().setCookieLifetime(lifetime);
+ 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;
+ }
+ }
+
+ /* 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> mCookieLifetime = new Pref<Integer>(
+ "network.cookie.lifetimePolicy", CookieLifetime.NORMAL);
+ /* 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<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.
+ *
+ * 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 @CBAntiTracking 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.
+ */
+ 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 cookie lifetime.
+ *
+ * @return The assigned lifetime, as one of {@link CookieLifetime} flags.
+ */
+ public @CBCookieLifetime int getCookieLifetime() {
+ return mCookieLifetime.get();
+ }
+
+ /**
+ * Set the cookie lifetime.
+ *
+ * @param lifetime The enforced cookie lifetime.
+ * Use one of the {@link CookieLifetime} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieLifetime(
+ final @CBCookieLifetime int lifetime) {
+ mCookieLifetime.commit(lifetime);
+ 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;
+ }
+
+ 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.
+ *
+ * See also <a href="https://developers.google.com/safe-browsing/v4">safe-browsing/v4</a>.
+ */
+ @AnyThread
+ public static class SafeBrowsingProvider extends RuntimeSettings {
+ final static private String ROOT = "browser.safebrowsing.provider.";
+
+ final private 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 })
+ /* package */ @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 })
+ /* package */ @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 })
+ /* package */ @interface CBCookieBehavior {}
+
+ // Sync values with nsICookieService.idl.
+ public static class CookieLifetime {
+ /**
+ * Accept default cookie lifetime.
+ */
+ public static final int NORMAL = 0;
+
+ /**
+ * Downgrade cookie lifetime to this runtime's lifetime.
+ */
+ public static final int RUNTIME = 2;
+
+ /**
+ * Limit cookie lifetime to N days.
+ * Defaults to 90 days.
+ */
+ public static final int DAYS = 3;
+
+ protected CookieLifetime() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ CookieLifetime.NORMAL, CookieLifetime.RUNTIME,
+ CookieLifetime.DAYS })
+ /* package */ @interface CBCookieLifetime {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT })
+ /* package */ @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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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;
+ }
+}
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..3c02c458b9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java
@@ -0,0 +1,419 @@
+/* -*- 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 org.json.JSONException;
+import org.json.JSONObject;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+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;
+
+/**
+ * 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";
+
+ @AnyThread
+ public static class ContentBlockingException {
+ private final @NonNull String mEncodedPrincipal;
+
+ /**
+ * A String representing the URI of this content blocking exception.
+ */
+ public final @NonNull String uri;
+
+ /* package */ ContentBlockingException(final @NonNull String encodedPrincipal,
+ final @NonNull String uri) {
+ mEncodedPrincipal = encodedPrincipal;
+ this.uri = uri;
+ }
+
+ /**
+ * Returns a JSONObject representation of the content blocking exception.
+ *
+ * @return A JSONObject representing the exception.
+ *
+ * @throws JSONException if conversion to JSONObject fails.
+ */
+ public @NonNull JSONObject toJson() throws JSONException {
+ final JSONObject res = new JSONObject();
+ res.put("principal", mEncodedPrincipal);
+ res.put("uri", uri);
+ return res;
+ }
+
+ /**
+ *
+ * Returns a ContentBlockingException reconstructed from JSON.
+ *
+ * @param savedException A JSONObject representation of a saved exception; should be the output of
+ * {@link #toJson}.
+ *
+ * @return A ContentBlockingException reconstructed from the supplied JSONObject.
+ *
+ * @throws JSONException if the JSONObject cannot be converted for any reason.
+ */
+ public static @NonNull ContentBlockingException fromJson(final @NonNull JSONObject savedException) throws JSONException {
+ return new ContentBlockingException(savedException.getString("principal"), savedException.getString("uri"));
+ }
+ }
+
+ /**
+ * Add a content blocking exception for the site currently loaded by the supplied
+ * {@link GeckoSession}.
+ *
+ * @param session A {@link GeckoSession} whose site will be added to the content
+ * blocking exceptions list.
+ */
+ @UiThread
+ public void addException(final @NonNull GeckoSession session) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("sessionId", session.getId());
+ EventDispatcher.getInstance().dispatch("ContentBlocking:AddException", msg);
+ }
+
+ /**
+ * Remove an exception for the site currently loaded by the supplied {@link GeckoSession}
+ * from the content blocking exception list, if there is such an exception. If there is no
+ * such exception, this is a no-op.
+ *
+ * @param session A {@link GeckoSession} whose site will be removed from the content
+ * blocking exceptions list.
+ */
+ @UiThread
+ public void removeException(final @NonNull GeckoSession session) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("sessionId", session.getId());
+ EventDispatcher.getInstance().dispatch("ContentBlocking:RemoveException", msg);
+ }
+
+ /**
+ * Remove the exception specified by the supplied {@link ContentBlockingException} from
+ * the content blocking exception list, if it is present. If there is no such exception,
+ * this is a no-op.
+ *
+ * @param exception A {@link ContentBlockingException} which will be removed from the
+ * content blocking exception list.
+ */
+ @AnyThread
+ public void removeException(final @NonNull ContentBlockingException exception) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("principal", exception.mEncodedPrincipal);
+ EventDispatcher.getInstance().dispatch("ContentBlocking:RemoveExceptionByPrincipal", msg);
+ }
+
+ /**
+ * Check whether or not there is an exception for the site currently loaded by the
+ * supplied {@link GeckoSession}.
+ *
+ * @param session A {@link GeckoSession} whose site will be checked against the content
+ * blocking exceptions list.
+ *
+ * @return A {@link GeckoResult} which resolves to a Boolean indicating whether or
+ * not the current site is on the exception list.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Boolean> checkException(final @NonNull GeckoSession session) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("sessionId", session.getId());
+ return EventDispatcher.getInstance()
+ .queryBoolean("ContentBlocking:CheckException", msg);
+ }
+
+ private List<ContentBlockingException> exceptionListFromBundle(final GeckoBundle value) {
+ final String[] principals = value.getStringArray("principals");
+ final String[] uris = value.getStringArray("uris");
+
+ if (principals == null || uris == null) {
+ throw new RuntimeException("Received invalid content blocking exception list");
+ }
+
+ final ArrayList<ContentBlockingException> res = new ArrayList<>(principals.length);
+
+ for (int i = 0; i < principals.length; i++) {
+ res.add(new ContentBlockingException(principals[i], uris[i]));
+ }
+
+ return Collections.unmodifiableList(res);
+ }
+
+ /**
+ * Save the current content blocking exception list as a List of {@link ContentBlockingException}.
+ *
+ * @return A List of {@link ContentBlockingException} which can be used to restore or
+ * inspect the current exception list.
+ */
+ @UiThread
+ public @NonNull GeckoResult<List<ContentBlockingException>> saveExceptionList() {
+ return EventDispatcher.getInstance()
+ .queryBundle("ContentBlocking:SaveList")
+ .map(this::exceptionListFromBundle);
+ }
+
+ /**
+ * Restore the supplied List of {@link ContentBlockingException}, overwriting the existing exception list.
+ *
+ * @param list A List of {@link ContentBlockingException} originally created by {@link #saveExceptionList}.
+ */
+ @AnyThread
+ public void restoreExceptionList(final @NonNull List<ContentBlockingException> list) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ final String[] principals = new String[list.size()];
+ final String[] uris = new String[list.size()];
+
+ for (int i = 0; i < list.size(); i++) {
+ principals[i] = list.get(i).mEncodedPrincipal;
+ uris[i] = list.get(i).uri;
+ }
+
+ bundle.putStringArray("principals", principals);
+ bundle.putStringArray("uris", uris);
+
+ EventDispatcher.getInstance().dispatch("ContentBlocking:RestoreList", bundle);
+ }
+
+ /**
+ * Clear the content blocking exception list entirely.
+ */
+ @UiThread
+ public void clearExceptionList() {
+ EventDispatcher.getInstance().dispatch("ContentBlocking:ClearList", null);
+ }
+
+ 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;
+
+ 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 })
+ /* package */ @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 = 0;
+ 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 (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 (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..d0f4a53f8b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
@@ -0,0 +1,361 @@
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.util.ProxySelector;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+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;
+
+/**
+ * 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 (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");
+ String boundary = generateBoundary();
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ conn.setRequestProperty("Content-Encoding", "gzip");
+
+ 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()));
+ HashMap<String, String> responseMap = readStringsFromReader(br);
+
+ if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ 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 (Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ try {
+ if (br != null) {
+ br.close();
+ }
+ } catch (IOException e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ }
+ }
+ } catch (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;
+ FileInputStream stream = new FileInputStream(minidump);
+ try {
+ md = MessageDigest.getInstance("SHA-256");
+
+ byte[] buffer = new byte[4096];
+ int readBytes;
+
+ while ((readBytes = stream.read(buffer)) != -1) {
+ md.update(buffer, 0, readBytes);
+ }
+ } catch (NoSuchAlgorithmException e) {
+ throw new IOException(e);
+ } finally {
+ stream.close();
+ }
+
+ byte[] digest = md.digest();
+ 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;
+ HashMap<String, String> map = new HashMap<>();
+ while ((line = reader.readLine()) != null) {
+ int equalsPos = -1;
+ if ((equalsPos = line.indexOf('=')) != -1) {
+ String key = line.substring(0, equalsPos);
+ String val = unescape(line.substring(equalsPos + 1));
+ map.put(key, val);
+ }
+ }
+ return map;
+ }
+
+ private static JSONObject readExtraFile(final String filePath)
+ throws IOException, JSONException {
+ byte[] buffer = new byte[4096];
+ FileInputStream inputStream = new FileInputStream(filePath);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ int bytesRead = 0;
+
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+
+ 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 (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 (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 (JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String generateBoundary() {
+ // Generate some random numbers to fill out the boundary
+ int r0 = (int)(Integer.MAX_VALUE * Math.random());
+ 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());
+ 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..fb03cd966a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
@@ -0,0 +1,34 @@
+package org.mozilla.geckoview;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+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;
+
+/**
+ * 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..5f19570e64
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
@@ -0,0 +1,399 @@
+/* -*- 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 androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.view.Surface;
+
+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(Surface, int, int)} or
+ * {@link #surfaceChanged(Surface, int, int, int, int)} is called and before {@link #surfaceDestroyed()} returns.
+ */
+public class GeckoDisplay {
+ private final GeckoSession mSession;
+
+ protected GeckoDisplay(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Sets a surface for the compositor render a surface.
+ *
+ * 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.
+ *
+ * @param surface The new Surface.
+ * @param width New width of the Surface. Can not be negative.
+ * @param height New height of the Surface. Can not be negative.
+ */
+ @UiThread
+ public void surfaceChanged(@NonNull final Surface surface, final int width, final int height) {
+ surfaceChanged(surface, 0, 0, width, height);
+ }
+
+ /**
+ * Sets a surface for the compositor render a surface.
+ *
+ * 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. The origin of the content window
+ * (0, 0) is the top left corner of the screen.
+ *
+ * @param surface The new Surface.
+ * @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.
+ * @param width New width of the Surface. Can not be negative.
+ * @param height New height of the Surface. Can not be negative.
+ * @throws IllegalArgumentException if left or top are negative.
+ */
+ @UiThread
+ public void surfaceChanged(@NonNull final Surface surface, final int left, final int top,
+ final int width, final int height) {
+ ThreadUtils.assertOnUiThread();
+
+ if ((left < 0) || (top < 0)) {
+ throw new IllegalArgumentException("Parameters can not be negative.");
+ }
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceChanged(surface, left, top, width, height);
+ }
+ }
+
+ /**
+ * Removes the current surface registered with the compositor.
+ *
+ * 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.
+ *
+ * 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).
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * Returned {@link Bitmap} will have the same dimensions as the {@link Surface} the
+ * {@link GeckoDisplay} is currently using.
+ *
+ * If the {@link GeckoSession#isCompositorReady} is false the {@link GeckoResult} will complete
+ * with an {@link IllegalStateException}.
+ *
+ * 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.
+ */
+ final static public 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.
+ *
+ * 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 (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..1d1b47e462
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
@@ -0,0 +1,2336 @@
+/* -*- 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 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.GamepadUtils;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+
+import android.graphics.RectF;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+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.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+
+/**
+ * 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
+
+ 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.
+ 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 (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;
+ }
+ 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;
+ }
+ KeyEvent [] keyEvents = synthesizeKeyEvents(action.mSequence);
+ if (keyEvents == null) {
+ return;
+ }
+ for (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 (Exception e) {
+ return def;
+ }
+ }
+
+ // Flags for icMaybeSendComposition
+ // 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;
+ // Notify Gecko of the new composition ranges;
+ // otherwise, the caller is responsible for notifying Gecko.
+ private static final int SEND_COMPOSITION_NOTIFY_GECKO = 2;
+ // Keep the current composition when updating;
+ // composition is not updated if there is no current composition.
+ private static final int SEND_COMPOSITION_KEEP_CURRENT = 4;
+
+ /**
+ * 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,
+ 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 (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) {
+ 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();
+ Log.d(LOGTAG, "icSendComposition(\"" + text + "\", " +
+ composingStart + ", " + composingEnd + ")");
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, " range = " + composingStart + "-" + composingEnd);
+ Log.d(LOGTAG, " selection = " + selStart + "-" + selEnd);
+ }
+
+ if (selEnd >= composingStart && selEnd <= composingEnd) {
+ mFocusedChild.onImeAddCompositionRange(
+ selEnd - composingStart, selEnd - composingStart,
+ IME_RANGE_CARETPOSITION, 0, 0, false, 0, 0, 0);
+ }
+
+ int rangeStart = composingStart;
+ TextPaint tp = new TextPaint();
+ 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 {
+ int rangeType, rangeStyles = 0, 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;
+ }
+ 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 (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();
+ 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 KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ if (GamepadUtils.isSonyXperiaGamepadKeyEvent(event)) {
+ return GamepadUtils.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(final int requestMode) {
+ try {
+ if (mFocusedChild != null) {
+ mFocusedChild.onImeRequestCursorUpdates(requestMode);
+ }
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call 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, 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);
+ 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, 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.
+ }
+
+ default:
+ throw new IllegalArgumentException("Invalid notifyIME type: " + type);
+ }
+
+ if (mListener != null) {
+ mListener.notifyIME(type);
+ }
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIMEContext(final IBinder token, final int state, final String typeHint,
+ final String modeHint, final String actionHint,
+ final String autocapitalize,
+ 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(final int originalState, final String typeHint,
+ final String modeHint, final String actionHint,
+ final String autocapitalize,
+ 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.
+ 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 (state == SessionTextInput.EditableListener.IME_STATE_DISABLED ||
+ mFocusedChild == null) {
+ 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 (!typeHint.equalsIgnoreCase("text") && modeHint.length() == 0) {
+ // auto-capitalized mode is the default for types other than text (bug 871884)
+ 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;
+ }
+
+ 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) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ Log.d(LOGTAG, "onSelectionChange(" + start + ", " + end + ")");
+ }
+
+ 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;
+
+ 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) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ 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;
+ }
+
+ final int currentLength = mText.getCurrentText().length();
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ final int newEnd = start + text.length();
+
+ if (start == 0 && unboundedOldEnd > currentLength) {
+ // | 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) {
+ 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) {
+ // 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);
+ }
+ });
+ }
+
+ // InvocationHandler interface
+
+ static String getConstantName(final Class<?> cls, final String prefix, final Object value) {
+ for (Field fld : cls.getDeclaredFields()) {
+ try {
+ if (fld.getName().startsWith(prefix) &&
+ fld.get(null).equals(value)) {
+ return fld.getName();
+ }
+ } catch (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 {
+ 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) {
+ StringBuilder log = new StringBuilder(method.getName());
+ log.append("(");
+ if (args != null) {
+ for (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..86c1f0b284
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
@@ -0,0 +1,174 @@
+/* -*- 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 org.mozilla.gecko.util.ThreadUtils;
+
+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 androidx.annotation.UiThread;
+import android.util.Log;
+
+/**
+ * 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();
+ ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ 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;
+ }
+
+ 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..bc186910f6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
@@ -0,0 +1,726 @@
+/* -*- 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.Handler;
+import android.os.Looper;
+import androidx.annotation.NonNull;
+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 org.mozilla.gecko.Clipboard;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+/* 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
+ 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;
+ }
+ int selStart = Selection.getSelectionStart(editable);
+ 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:
+ 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.
+ 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;
+
+ Editable editable = getEditable();
+ if (editable == null) {
+ return null;
+ }
+ int selStart = Selection.getSelectionStart(editable);
+ int selEnd = Selection.getSelectionEnd(editable);
+
+ 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) {
+ 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, composition);
+ }
+ });
+ }
+
+ @TargetApi(21)
+ /* package */ void updateCompositionRectsOnUi(final View view,
+ final RectF[] rects,
+ final CharSequence composition) {
+ if (mCursorAnchorInfoBuilder == null) {
+ mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
+ }
+ mCursorAnchorInfoBuilder.reset();
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenMatrix(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);
+
+ 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
+ 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 (InterruptedException e) {
+ }
+ }
+ return sBackgroundHandler;
+ }
+
+ private synchronized boolean canReturnCustomHandler() {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return false;
+ }
+ for (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() {
+ // Not supported at the moment.
+ super.closeConnection();
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return null;
+ }
+
+ Context context = getView().getContext();
+ 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));
+ }
+
+ 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;
+ }
+ int a = getComposingSpanStart(content),
+ 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) {
+ 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+
+ Context viewContext = getView().getContext();
+ AudioManager am = (AudioManager)viewContext.getSystemService(Context.AUDIO_SERVICE);
+ am.dispatchMediaKeyEvent(event);
+ }
+ break;
+ }
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void notifyIME(final 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;
+
+ default:
+ if (DEBUG) {
+ throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type);
+ }
+ break;
+ }
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public synchronized void notifyIMEContext(final int state, final String typeHint,
+ final String modeHint, final String actionHint,
+ 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..426d27c914
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
@@ -0,0 +1,208 @@
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.util.LinkedList;
+
+/**
+ * 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.
+ */
+ private GeckoInputStream(final @NonNull 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();
+
+ int expect = Integer.SIZE / 8;
+ byte[] bytes = new byte[expect];
+
+ int count = 0;
+ while (count < expect) {
+ 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();
+
+ 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) {
+ mSupport.resume();
+ mResumed = true;
+ }
+
+ try {
+ wait(mReadTimeout);
+ } catch (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")
+ private synchronized void appendBuffer(final byte[] buf) throws IOException {
+ ThreadUtils.assertOnGeckoThread();
+
+ 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..bbad78f340
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java
@@ -0,0 +1,1008 @@
+package org.mozilla.geckoview;
+
+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;
+
+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;
+
+/**
+ * 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);
+ }
+ }
+
+ /**
+ * A GeckoResult that resolves to AllowOrDeny.ALLOW
+ */
+ public static final GeckoResult<AllowOrDeny> ALLOW = GeckoResult.fromValue(AllowOrDeny.ALLOW);
+
+ /**
+ * A GeckoResult that resolves to AllowOrDeny.DENY
+ */
+ public static final GeckoResult<AllowOrDeny> DENY = GeckoResult.fromValue(AllowOrDeny.DENY);
+
+ // 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() {
+ int result = 17;
+ result = 31 * result + (mComplete ? 1 : 0);
+ result = 31 * result + (mValue != null ? mValue.hashCode() : 0);
+ result = 31 * result + (mError != null ? mError.hashCode() : 0);
+ return result;
+ }
+
+ // 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}.
+ *
+ * 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);
+ }
+
+ /* 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}.
+ *
+ * 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 (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.
+ *
+ * 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.
+ *
+ * If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * 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) {
+ Dispatcher dispatcher = mListeners.keyAt(i);
+ 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
+ */
+ private void completeFrom(final 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.
+ *
+ * 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 (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.
+ *
+ * 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.
+ *
+ * If this result is already complete, the returned result will always resolve to false.
+ *
+ * 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 (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..8fca9c9240
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
@@ -0,0 +1,873 @@
+/* -*- 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.lifecycle.ProcessLifecycleOwner;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleObserver;
+import androidx.lifecycle.OnLifecycleEvent;
+
+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.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import android.content.res.Configuration;
+import android.os.Build;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.os.Process;
+import android.provider.Settings;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.collection.ArrayMap;
+import android.util.Log;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.GeckoScreenOrientation;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.annotation.WrapForJNI;
+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;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.util.List;
+import java.util.Map;
+
+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 boolean indicating whether or not the crash was fatal or not. If true, the
+ * main application process was affected by the crash. If false, only an internal
+ * process used by Gecko has crashed and the application may be able to recover.
+ * @see GeckoSession.ContentDelegate#onCrash(GeckoSession)
+ */
+ public static final String EXTRA_CRASH_FATAL = "fatal";
+
+ 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;
+ // Monitor network status and send change notifications to Gecko
+ // while active.
+ GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext());
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ void onPause() {
+ Log.d(LOGTAG, "Lifecycle: onPause");
+ mPaused = true;
+ // 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.
+ *
+ * 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 StorageController mStorageController;
+ private final WebExtensionController mWebExtensionController;
+ private WebPushController mPushController;
+ private final ContentBlockingController mContentBlockingController;
+ private final Autocomplete.LoginStorageProxy mLoginStorageProxy;
+ private final ProfilerController mProfilerController;
+
+ private GeckoRuntime() {
+ mWebExtensionController = new WebExtensionController(this);
+ mContentBlockingController = new ContentBlockingController();
+ mLoginStorageProxy = new Autocomplete.LoginStorageProxy();
+ mProfilerController = new ProfilerController();
+
+ 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.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private static @NonNull GeckoResult<String> serviceWorkerOpenWindow(final @NonNull String url) {
+ if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) {
+ 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()) {
+ result.completeExceptionally(new RuntimeException("Returned GeckoSession must be open."));
+ } else {
+ 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:ContentCrashReport".equals(event) && crashHandler != null) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ 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_FATAL, message.getBoolean(EXTRA_CRASH_FATAL, true));
+
+ 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:ContentCrashReport");
+
+ flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ } catch (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);
+
+ final GeckoThread.InitInfo info = new GeckoThread.InitInfo();
+ info.args = settings.getArguments();
+ info.extras = settings.getExtras();
+ info.flags = flags;
+
+ // Bug 1605454: Temporary change for Fenix experiment that disables webrender
+ // Once the experiment ends or experimenter gets implemented in Gecko, this should be removed
+ // and replaced by :
+ // info.prefs = settings.getPrefsMap();
+ final Map<String, Object> prefMap = new ArrayMap<String, Object>();
+ prefMap.putAll(settings.getPrefsMap());
+ if (info.extras.getInt("forcedisablewebrender") == 1) {
+ prefMap.put("gfx.webrender.force-disabled", true);
+ }
+ info.prefs = prefMap;
+ // End of Bug 1605454 hack
+
+ // 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);
+ debugConfig.mergeIntoInitInfo(info);
+ } catch (DebugConfig.ConfigException e) {
+ Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e);
+ } catch (FileNotFoundException e) {
+ }
+ }
+ }
+
+ 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");
+
+ // 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());
+ 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.
+ *
+ * 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.
+ *
+ * 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");
+ }
+
+ return runtime;
+ }
+
+ /**
+ * Shutdown the runtime. This will invalidate all attached sessions.
+ */
+ @AnyThread
+ public void shutdown() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "shutdown");
+ }
+
+ GeckoSystemStateListener.getInstance().shutdown();
+ 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.LoginStorageDelegate} instance on this runtime.
+ * This delegate is required for handling login storage requests.
+ *
+ * @param delegate The {@link Autocomplete.LoginStorageDelegate} handling login storage
+ * requests.
+ */
+ @UiThread
+ public void setLoginStorageDelegate(
+ final @Nullable Autocomplete.LoginStorageDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mLoginStorageProxy.setDelegate(delegate);
+ }
+
+ /**
+ * Get the {@link Autocomplete.LoginStorageDelegate} instance set on this runtime.
+ *
+ * @return The {@link Autocomplete.LoginStorageDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable Autocomplete.LoginStorageDelegate getLoginStorageDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mLoginStorageProxy.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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /* package */ void setPref(final String name, final Object value) {
+ PrefsHelper.setPref(name, value, /* flush */ false);
+ }
+
+ /**
+ * Get the profile directory for this runtime. This is where Gecko stores
+ * internal data.
+ *
+ * @return Profile directory
+ */
+ @UiThread
+ public @Nullable File getProfileDir() {
+ ThreadUtils.assertOnUiThread();
+ return GeckoThread.getActiveProfile().getDir();
+ }
+
+ /**
+ * 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 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..12d2adfb05
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
@@ -0,0 +1,1200 @@
+/* -*- 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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Locale;
+
+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 androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoSystemStateListener;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import static android.os.Build.VERSION;
+
+@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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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;
+ }
+
+ /**
+ * 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.LoginStorageDelegate#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>
+ * <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>
+ * <li>Schedule work via {@link android.app.job.JobScheduler}. This will allow you to
+ * do substantial work in the background without execution limits.</li>
+ * </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;
+ }
+ }
+
+ 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<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<>(
+ "gl.msaa-level", 0);
+ /* 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 */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM;
+
+ /* 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);
+
+ 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.
+ *
+ * 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 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() {
+ ArrayList<String> locales = new ArrayList<String>();
+
+ // Explicitly-set app prefs come first:
+ if (mRequestedLocales != null) {
+ for (String locale : mRequestedLocales) {
+ locales.add(locale.toLowerCase(Locale.ROOT));
+ }
+ }
+ // OS prefs come second:
+ for (String locale : getDefaultLocales()) {
+ locale = locale.toLowerCase(Locale.ROOT);
+ if (!locales.contains(locale)) {
+ locales.add(locale);
+ }
+ }
+
+ return TextUtils.join(",", locales);
+ }
+
+ private static String[] getDefaultLocales() {
+ if (VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ 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.
+ *
+ * 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);
+ }
+
+ private final static float DEFAULT_FONT_SIZE_FACTOR = 1f;
+
+ private float sanitizeFontSizeFactor(final float fontSizeFactor) {
+ if (fontSizeFactor < 0) {
+ if (BuildConfig.DEBUG) {
+ 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})
+ /* package */ @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.LoginStorageDelegate#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;
+ }
+
+ @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, 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);
+ 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 (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..db4d62d8d5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
@@ -0,0 +1,6267 @@
+/* -*- 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 java.io.ByteArrayInputStream;
+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.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.annotation.WrapForJNI;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.NativeQueue;
+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 android.content.ContentResolver;
+import android.content.Context;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.Matrix;
+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 androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.LongDef;
+import androidx.annotation.Nullable;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringDef;
+import androidx.annotation.UiThread;
+import android.text.TextUtils;
+import android.util.Base64;
+import android.util.Log;
+import android.util.LongSparseArray;
+import android.util.SparseArray;
+import android.view.Surface;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.View;
+import android.view.ViewStructure;
+
+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 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;
+
+ private String mId = UUID.randomUUID().toString().replace("-", "");
+ /* 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 Surface mSurface;
+
+ // 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 mOffsetX;
+ private int mOffsetY;
+ 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 */ final static int FIRST_PAINT = 0;
+ // Sent from compositor when a layer has been updated
+ /* package */ final static int LAYERS_UPDATED = 1;
+ // Special message sent from UiCompositorControllerChild once it is open
+ /* package */ final static int COMPOSITOR_CONTROLLER_OPEN = 2;
+ // Special message sent from controller to query if the compositor controller is open.
+ /* package */ final static 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);
+
+ // 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);
+
+ @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);
+
+ @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);
+
+ 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",
+ }
+ ) {
+ @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 (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);
+ }
+ }
+ };
+
+ 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);
+ }
+ }
+
+ @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")) {
+ delegate.onLocationChange(GeckoSession.this,
+ message.getString("uri"));
+ }
+ 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(null);
+ return;
+ }
+
+ callback.resolveTo(result.map(session -> {
+ ThreadUtils.assertOnUiThread();
+ if (session == null) {
+ return null;
+ }
+
+ 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);
+ return session.getId();
+ }));
+ }
+ }
+ };
+
+ 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"));
+
+ 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) {
+ 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 String typeString = message.getString("perm");
+ final int type;
+ if ("geolocation".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_GEOLOCATION;
+ } else if ("desktop-notification".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION;
+ } else if ("persistent-storage".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_PERSISTENT_STORAGE;
+ } else if ("xr".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_XR;
+ } else if ("midi".equals(typeString)) {
+ // We can get this from WPT and presumably other content, but Gecko
+ // doesn't support Web MIDI.
+ callback.sendError("Unsupported");
+ return;
+ } else if ("autoplay-media-inaudible".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE;
+ } else if ("autoplay-media-audible".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE;
+ } else if ("media-key-system-access".equals(typeString)) {
+ type = PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS;
+ } else {
+ throw new IllegalArgumentException("Unknown permission request: " + typeString);
+ }
+ delegate.onContentPermissionRequest(
+ GeckoSession.this, message.getString("uri"),
+ type, new PermissionCallback(typeString, callback));
+ } else if ("GeckoView:MediaPermission".equals(event)) {
+ GeckoBundle[] videoBundles = message.getBundleArray("video");
+ 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",
+ }
+ ) {
+ @Override
+ public void handleMessage(final SelectionActionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ 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, callback);
+
+ 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);
+ }
+ }
+ };
+
+ private LongSparseArray<MediaElement> mMediaElements = new LongSparseArray<>();
+ /* package */ LongSparseArray<MediaElement> getMediaElements() {
+ return mMediaElements;
+ }
+ private final GeckoSessionHandler<MediaDelegate> mMediaHandler =
+ new GeckoSessionHandler<MediaDelegate>(
+ "GeckoViewMedia", this,
+ new String[]{
+ "GeckoView:MediaAdd",
+ "GeckoView:MediaRemove",
+ "GeckoView:MediaRemoveAll",
+ "GeckoView:MediaReadyStateChanged",
+ "GeckoView:MediaTimeChanged",
+ "GeckoView:MediaPlaybackStateChanged",
+ "GeckoView:MediaMetadataChanged",
+ "GeckoView:MediaProgress",
+ "GeckoView:MediaVolumeChanged",
+ "GeckoView:MediaRateChanged",
+ "GeckoView:MediaFullscreenChanged",
+ "GeckoView:MediaError",
+ "GeckoView:MediaRecordingStatusChanged",
+ }
+ ) {
+ @Override
+ public void handleMessage(final MediaDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:MediaAdd".equals(event)) {
+ final MediaElement element = new MediaElement(message.getLong("id"), GeckoSession.this);
+ delegate.onMediaAdd(GeckoSession.this, element);
+ return;
+ } else if ("GeckoView:MediaRemoveAll".equals(event)) {
+ for (int i = 0; i < mMediaElements.size(); i++) {
+ final long key = mMediaElements.keyAt(i);
+ delegate.onMediaRemove(GeckoSession.this, mMediaElements.get(key));
+ }
+ mMediaElements.clear();
+ return;
+ } else 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;
+ }
+
+ final long id = message.getLong("id", 0);
+ final MediaElement element = mMediaElements.get(id);
+ if (element == null) {
+ Log.w(LOGTAG, "MediaElement not found for '" + id + "'");
+ return;
+ }
+
+ if ("GeckoView:MediaTimeChanged".equals(event)) {
+ element.notifyTimeChange(message.getDouble("time"));
+ } else if ("GeckoView:MediaProgress".equals(event)) {
+ element.notifyLoadProgress(message);
+ } else if ("GeckoView:MediaMetadataChanged".equals(event)) {
+ element.notifyMetadataChange(message);
+ } else if ("GeckoView:MediaReadyStateChanged".equals(event)) {
+ element.notifyReadyStateChange(message.getInt("readyState"));
+ } else if ("GeckoView:MediaPlaybackStateChanged".equals(event)) {
+ element.notifyPlaybackStateChange(message.getString("playbackState"));
+ } else if ("GeckoView:MediaVolumeChanged".equals(event)) {
+ element.notifyVolumeChange(message.getDouble("volume"), message.getBoolean("muted"));
+ } else if ("GeckoView:MediaRateChanged".equals(event)) {
+ element.notifyPlaybackRateChange(message.getDouble("rate"));
+ } else if ("GeckoView:MediaFullscreenChanged".equals(event)) {
+ element.notifyFullscreenChange(message.getBoolean("fullscreen"));
+ } else if ("GeckoView:MediaRemove".equals(event)) {
+ delegate.onMediaRemove(GeckoSession.this, element);
+ mMediaElements.remove(element.getVideoId());
+ } else if ("GeckoView:MediaError".equals(event)) {
+ element.notifyError(message.getInt("code"));
+ } else {
+ throw new UnsupportedOperationException(event + " media message not implemented");
+ }
+ }
+ };
+
+ 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.
+ *
+ * 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,
+ int screenId, 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 = 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(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 GeckoSession session = (mOwner == null) ? null : 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);
+ }
+ GeckoResult<Boolean> res = new GeckoResult<>();
+
+ ThreadUtils.postToUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final NavigationDelegate delegate = session.getNavigationDelegate();
+
+ if (delegate == null) {
+ res.complete(false);
+ 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);
+ return;
+ }
+
+ reqResponse.accept(value -> {
+ if (value == AllowOrDeny.DENY) {
+ res.complete(true);
+ } else {
+ res.complete(false);
+ }
+ }, ex -> {
+ // This is incredibly ugly and unreadable because checkstyle sucks.
+ res.complete(false);
+ });
+ }
+ });
+
+ return res;
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void passExternalWebResponse(final WebResponse response) {
+ GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onExternalResponse(session, response);
+ }
+ }
+ }
+
+ private class Listener implements BundleEventListener {
+ /* package */ void registerListeners() {
+ getEventDispatcher().registerUiThreadListener(this,
+ "GeckoView:PinOnScreen",
+ "GeckoView:Prompt",
+ null);
+ }
+
+ @Override
+ public void handleMessage(final String event, final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage: event = " + event);
+ }
+
+ if ("GeckoView:PinOnScreen".equals(event)) {
+ GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned"));
+ } else if ("GeckoView:Prompt".equals(event)) {
+ handlePromptEvent(GeckoSession.this, message, callback);
+ }
+ }
+ }
+
+ 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);
+
+ mAutofillSupport = new Autofill.Support(this);
+ mAutofillSupport.registerListeners();
+
+ if (BuildConfig.DEBUG && 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);
+ }
+
+ /* package */ boolean equalsId(final GeckoSession other) {
+ if (other == null) {
+ return false;
+ }
+
+ return mId.equals(other.mId);
+ }
+
+ /**
+ * Return whether this session is open.
+ *
+ * @return True if session is open.
+ * @see #open
+ * @see #close
+ */
+ @AnyThread
+ public boolean isOpen() {
+ 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.
+ *
+ * Call this when you are ready to use a GeckoSession instance.
+ *
+ * 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) {
+ 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 int screenId = mSettings.getScreenId();
+ final boolean isPrivate = mSettings.getUsePrivateMode();
+
+ 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, screenId, 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,
+ screenId, isPrivate);
+ }
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ false);
+ }
+
+ /**
+ * Closes the session.
+ *
+ * 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;
+ }
+
+ @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 })
+ /* package */ @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.
+ *
+ * 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.
+ *
+ * Note: the <code>Host</code> and <code>Connection</code>
+ * headers are still ignored.
+ *
+ * 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.
+ *
+ * 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})
+ /* package */ @interface HeaderFilter {}
+
+ /**
+ * Main entry point for loading URIs into a {@link GeckoSession}.
+ *
+ * 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}.
+ *
+ * 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.
+ *
+ * Note: only CORS safelisted headers are allowed by default. To modify this
+ * behavior use {@link #headerFilter}.
+ *
+ * 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 (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 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;
+ }
+
+ 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.
+ *
+ * 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.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ final GeckoResult<AllowOrDeny> result = new GeckoResult<>();
+
+ 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.
+ */
+ @AnyThread
+ public void goBack() {
+ mEventDispatcher.dispatch("GeckoView:GoBack", null);
+ }
+
+ /**
+ * Go forward in history.
+ */
+ @AnyThread
+ public void goForward() {
+ mEventDispatcher.dispatch("GeckoView:GoForward", null);
+ }
+
+ /**
+ * 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})
+ /* package */ @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})
+ /* package */ @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");
+
+ final GeckoBundle rectBundle = bundle.getBundle("clientRect");
+ if (rectBundle == null) {
+ clientRect = null;
+ } else {
+ clientRect = new RectF((float) rectBundle.getDouble("left"),
+ (float) rectBundle.getDouble("top"),
+ (float) rectBundle.getDouble("right"),
+ (float) rectBundle.getDouble("bottom"));
+ }
+ }
+
+ /**
+ * 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.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);
+ }
+
+ /**
+ * 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.
+ * @throws JSONException if the value is not a valid json
+ */
+ public static @NonNull SessionState fromString(final @NonNull String value) throws JSONException {
+ return new SessionState(GeckoBundle.fromJSONObject(new JSONObject(value)));
+ }
+
+ @Override
+ public 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 (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 (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 (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);
+ }
+
+ // This is the GeckoDisplay acquired via acquireDisplay(), if any.
+ private GeckoDisplay mDisplay;
+ /* package */ GeckoDisplay getDisplay() {
+ return mDisplay;
+ }
+
+ /**
+ * Acquire the GeckoDisplay instance for providing the session with a drawing Surface.
+ * Be sure to call {@link GeckoDisplay#surfaceChanged(Surface, int, int)} 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();
+ }
+
+ /* package */ static void handlePromptEvent(final GeckoSession session,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ 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 String mode = message.getString("mode");
+ final String title = message.getString("title");
+ final String msg = message.getString("msg");
+ GeckoResult<PromptDelegate.PromptResponse> res = null;
+
+ switch (type) {
+ case "alert": {
+ final PromptDelegate.AlertPrompt prompt =
+ new PromptDelegate.AlertPrompt(title, msg);
+ res = delegate.onAlertPrompt(session, prompt);
+ break;
+ }
+ case "beforeUnload": {
+ final PromptDelegate.BeforeUnloadPrompt prompt =
+ new PromptDelegate.BeforeUnloadPrompt();
+ res = delegate.onBeforeUnloadPrompt(session, prompt);
+ break;
+ }
+ case "repost": {
+ final PromptDelegate.RepostConfirmPrompt prompt =
+ new PromptDelegate.RepostConfirmPrompt();
+ res = delegate.onRepostConfirmPrompt(session, prompt);
+ break;
+ }
+ case "button": {
+ final PromptDelegate.ButtonPrompt prompt =
+ new PromptDelegate.ButtonPrompt(title, msg);
+ res = delegate.onButtonPrompt(session, prompt);
+ break;
+ }
+ case "text": {
+ final String defaultValue = message.getString("value");
+ final PromptDelegate.TextPrompt prompt =
+ new PromptDelegate.TextPrompt(title, msg, defaultValue);
+ res = delegate.onTextPrompt(session, prompt);
+ break;
+ }
+ case "auth": {
+ final PromptDelegate.AuthPrompt.AuthOptions authOptions =
+ new PromptDelegate.AuthPrompt.AuthOptions(message.getBundle("options"));
+ final PromptDelegate.AuthPrompt prompt =
+ new PromptDelegate.AuthPrompt(title, msg, authOptions);
+ res = delegate.onAuthPrompt(session, prompt);
+ break;
+ }
+ case "choice": {
+ final int intMode;
+ if ("menu".equals(mode)) {
+ intMode = PromptDelegate.ChoicePrompt.Type.MENU;
+ } else if ("single".equals(mode)) {
+ intMode = PromptDelegate.ChoicePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = PromptDelegate.ChoicePrompt.Type.MULTIPLE;
+ } else {
+ callback.sendError("Invalid mode");
+ return;
+ }
+
+ GeckoBundle[] choiceBundles = message.getBundleArray("choices");
+ PromptDelegate.ChoicePrompt.Choice choices[];
+ if (choiceBundles == null || choiceBundles.length == 0) {
+ choices = new PromptDelegate.ChoicePrompt.Choice[0];
+ } else {
+ choices = new PromptDelegate.ChoicePrompt.Choice[choiceBundles.length];
+ for (int i = 0; i < choiceBundles.length; i++) {
+ choices[i] = new PromptDelegate.ChoicePrompt.Choice(choiceBundles[i]);
+ }
+ }
+
+ final PromptDelegate.ChoicePrompt prompt =
+ new PromptDelegate.ChoicePrompt(title, msg, intMode, choices);
+ res = delegate.onChoicePrompt(session, prompt);
+ break;
+ }
+ case "color": {
+ final String defaultValue = message.getString("value");
+ final PromptDelegate.ColorPrompt prompt =
+ new PromptDelegate.ColorPrompt(title, defaultValue);
+ res = delegate.onColorPrompt(session, prompt);
+ break;
+ }
+ case "datetime": {
+ final int intMode;
+ if ("date".equals(mode)) {
+ intMode = PromptDelegate.DateTimePrompt.Type.DATE;
+ } else if ("month".equals(mode)) {
+ intMode = PromptDelegate.DateTimePrompt.Type.MONTH;
+ } else if ("week".equals(mode)) {
+ intMode = PromptDelegate.DateTimePrompt.Type.WEEK;
+ } else if ("time".equals(mode)) {
+ intMode = PromptDelegate.DateTimePrompt.Type.TIME;
+ } else if ("datetime-local".equals(mode)) {
+ intMode = PromptDelegate.DateTimePrompt.Type.DATETIME_LOCAL;
+ } else {
+ callback.sendError("Invalid mode");
+ return;
+ }
+
+ final String defaultValue = message.getString("value");
+ final String minValue = message.getString("min");
+ final String maxValue = message.getString("max");
+ final PromptDelegate.DateTimePrompt prompt =
+ new PromptDelegate.DateTimePrompt(title, intMode, defaultValue, minValue, maxValue);
+ res = delegate.onDateTimePrompt(session, prompt);
+ break;
+ }
+ case "file": {
+ final int intMode;
+ if ("single".equals(mode)) {
+ intMode = PromptDelegate.FilePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = PromptDelegate.FilePrompt.Type.MULTIPLE;
+ } else {
+ callback.sendError("Invalid mode");
+ return;
+ }
+
+ String[] mimeTypes = message.getStringArray("mimeTypes");
+ int capture = message.getInt("capture");
+ final PromptDelegate.FilePrompt prompt =
+ new PromptDelegate.FilePrompt(title, intMode, capture, mimeTypes);
+ res = delegate.onFilePrompt(session, prompt);
+ break;
+ }
+ case "popup": {
+ final String targetUri = message.getString("targetUri");
+ final PromptDelegate.PopupPrompt prompt =
+ new PromptDelegate.PopupPrompt(targetUri);
+ res = delegate.onPopupPrompt(session, prompt);
+ break;
+ }
+ case "share": {
+ final String text = message.getString("text");
+ final String uri = message.getString("uri");
+ final PromptDelegate.SharePrompt prompt =
+ new PromptDelegate.SharePrompt(title, text, uri);
+ res = delegate.onSharePrompt(session, prompt);
+ break;
+ }
+ case "Autocomplete:Save:Login": {
+ final int hint = message.getInt("hint");
+ final GeckoBundle[] loginBundles =
+ message.getBundleArray("logins");
+
+ if (loginBundles == null) {
+ break;
+ }
+
+ 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);
+ }
+
+ final PromptDelegate.AutocompleteRequest
+ <Autocomplete.LoginSaveOption> request =
+ new PromptDelegate.AutocompleteRequest<>(options);
+
+ res = delegate.onLoginSave(session, request);
+ break;
+ }
+ case "Autocomplete:Select:Login": {
+ final GeckoBundle[] optionBundles =
+ message.getBundleArray("options");
+
+ if (optionBundles == null) {
+ break;
+ }
+
+ final Autocomplete.LoginSelectOption[] options =
+ new Autocomplete.LoginSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.LoginSelectOption.fromBundle(
+ optionBundles[i]);
+ }
+
+ final PromptDelegate.AutocompleteRequest
+ <Autocomplete.LoginSelectOption> request =
+ new PromptDelegate.AutocompleteRequest<>(options);
+
+ res = delegate.onLoginSelect(session, request);
+
+ break;
+ }
+ default: {
+ callback.sendError("Invalid type");
+ return;
+ }
+ }
+
+ 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."));
+ }
+ }
+
+ @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})
+ /* package */ @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})
+ /* package */ @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 (CertificateException e) {
+ Log.e(LOGTAG, "Failed to decode certificate", e);
+ }
+
+ certificate = decodedCert;
+ }
+
+ /**
+ * Empty constructor for tests
+ */
+ protected SecurityInformation() {
+ mixedModePassive = 0;
+ mixedModeActive = 0;
+ securityMode = 0;
+ 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 GeckoSession session, @NonNull 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 GeckoSession session, 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 GeckoSession session, 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 GeckoSession session,
+ @NonNull SecurityInformation securityInfo) {}
+
+ /**
+ * The browser session state has changed. This can happen in response to
+ * navigation, scrolling, or form data changes; the session state passed
+ * includes the most up to date information on all of these.
+ * @param session GeckoSession that initiated the callback.
+ * @param sessionState SessionState representing the latest browser state.
+ */
+ @UiThread
+ default void onSessionStateChange(@NonNull GeckoSession session,
+ @NonNull SessionState sessionState) {}
+ }
+
+ /**
+ * WebResponseInfo contains information about a single web response.
+ */
+ @AnyThread
+ static public class WebResponseInfo {
+ /**
+ * 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 GeckoSession session, @Nullable String title) {}
+
+ /**
+ * 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 GeckoSession session) {}
+
+ /**
+ * A page has requested to close
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onCloseRequest(@NonNull 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 GeckoSession session, 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 GeckoSession session, @NonNull String viewportFit) {}
+
+ /**
+ * Element details for onContextMenu callbacks.
+ */
+ public static class ContextElement {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO})
+ /* package */ @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 GeckoSession session,
+ int screenX, int screenY,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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 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 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 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 GeckoSession session) {}
+
+ /**
+ * Notification that the paint status has been reset.
+ *
+ * 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 GeckoSession session) {}
+
+ /**
+ * This is fired when the loaded document has a valid Web App Manifest present.
+ *
+ * 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 GeckoSession session, @NonNull JSONObject manifest) {}
+
+ /**
+ * A script has exceeded it's 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 GeckoSession geckoSession,
+ @NonNull String scriptFileName) {
+ return null;
+ }
+ }
+
+ 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";
+ /**
+ * 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.
+ */
+ public final @Nullable RectF clientRect;
+
+ /**
+ * Set of valid actions available through {@link Selection#execute(String)}
+ */
+ public final @NonNull @SelectionActionDelegateAction Collection<String> availableActions;
+
+ private final int mSeqNo;
+
+ private final EventCallback mEventCallback;
+
+ /* package */ Selection(final GeckoBundle bundle,
+ final @NonNull @SelectionActionDelegateAction Set<String> actions,
+ final EventCallback callback) {
+ 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");
+
+ final GeckoBundle rectBundle = bundle.getBundle("clientRect");
+ if (rectBundle == null) {
+ clientRect = null;
+ } else {
+ clientRect = new RectF((float) rectBundle.getDouble("left"),
+ (float) rectBundle.getDouble("top"),
+ (float) rectBundle.getDouble("right"),
+ (float) rectBundle.getDouble("bottom"));
+ }
+
+ availableActions = actions;
+ mSeqNo = bundle.getInt("seqNo");
+ mEventCallback = callback;
+ }
+
+ /**
+ * Empty constructor for tests.
+ */
+ protected Selection() {
+ flags = 0;
+ text = "";
+ clientRect = null;
+ availableActions = new HashSet<>();
+ mSeqNo = 0;
+ mEventCallback = 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 GeckoBundle response = new GeckoBundle(2);
+ response.putString("id", action);
+ response.putInt("seqNo", mSeqNo);
+ mEventCallback.sendSuccess(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);
+ }
+
+ /**
+ * 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.
+ *
+ * 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}
+ *
+ * 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @SelectionActionDelegateHideReason int reason) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ SelectionActionDelegate.ACTION_HIDE,
+ SelectionActionDelegate.ACTION_CUT,
+ SelectionActionDelegate.ACTION_COPY,
+ SelectionActionDelegate.ACTION_DELETE,
+ SelectionActionDelegate.ACTION_PASTE,
+ SelectionActionDelegate.ACTION_SELECT_ALL,
+ SelectionActionDelegate.ACTION_UNSELECT,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_START,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_END})
+ /* package */ @interface SelectionActionDelegateAction {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true, value = {
+ SelectionActionDelegate.FLAG_IS_COLLAPSED,
+ SelectionActionDelegate.FLAG_IS_EDITABLE})
+ /* package */ @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})
+ /* package */ @interface SelectionActionDelegateHideReason {}
+
+ 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.
+ */
+ @UiThread
+ default void onLocationChange(@NonNull GeckoSession session, @Nullable String url) {}
+
+ /**
+ * 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 GeckoSession session, 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 GeckoSession session, 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 = 0;
+ 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.
+ *
+ * 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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
+ * @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 GeckoSession session,
+ @Nullable String uri,
+ @NonNull WebRequestError error) {
+ return null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({NavigationDelegate.TARGET_WINDOW_NONE, NavigationDelegate.TARGET_WINDOW_CURRENT,
+ NavigationDelegate.TARGET_WINDOW_NEW})
+ /* package */ @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);
+ }
+ }
+
+ // Prompt classes.
+ public class BasePrompt {
+ private boolean mIsCompleted;
+ private boolean mIsConfirmed;
+ private GeckoBundle mResult;
+
+ /**
+ * The title of this prompt; may be null.
+ */
+ public final @Nullable String title;
+
+ private BasePrompt(@Nullable final String title) {
+ this.title = title;
+ mIsConfirmed = false;
+ mIsCompleted = false;
+ }
+
+ @UiThread
+ protected @NonNull PromptResponse confirm() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ mIsCompleted = true;
+ mIsConfirmed = true;
+ 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.");
+ }
+
+ mIsCompleted = true;
+ return new PromptResponse(this);
+ }
+
+ /* 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() {
+ super(null);
+ }
+
+ /**
+ * 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() {
+ super(null);
+ }
+
+ /**
+ * 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(@Nullable final String title,
+ @Nullable final String message) {
+ super(title);
+ 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})
+ /* package */ @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(@Nullable final String title,
+ @Nullable final String message) {
+ super(title);
+ 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(@Nullable final String title,
+ @Nullable final String message,
+ @Nullable final String defaultValue) {
+ super(title);
+ 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})
+ /* package */ @interface AuthFlag {}
+
+ /**
+ * Auth prompt flags.
+ */
+ public static class Flags {
+ /**
+ * The auth prompt is for a network host.
+ */
+ public static final int HOST = 1;
+ /**
+ * The auth prompt is for a proxy.
+ */
+ public static final int PROXY = 2;
+ /**
+ * The auth prompt should only request a password.
+ */
+ public static final int ONLY_PASSWORD = 8;
+ /**
+ * The auth prompt is the result of a previous failed login.
+ */
+ public static final int PREVIOUS_FAILED = 16;
+ /**
+ * The auth prompt is for a cross-origin sub-resource.
+ */
+ public static final int CROSS_ORIGIN_SUB_RESOURCE = 32;
+
+ protected Flags() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE})
+ /* package */ @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 = 0;
+ 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(@Nullable final String title,
+ @Nullable final String message,
+ @NonNull final AuthOptions authOptions) {
+ super(title);
+ 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");
+
+ 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})
+ /* package */ @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(@Nullable final String title,
+ @Nullable final String message,
+ @ChoiceType final int type,
+ @NonNull final Choice[] choices) {
+ super(title);
+ 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;
+
+ protected ColorPrompt(@Nullable final String title,
+ @Nullable final String defaultValue) {
+ super(title);
+ this.defaultValue = defaultValue;
+ }
+
+ /**
+ * 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})
+ /* package */ @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;
+
+ protected DateTimePrompt(@Nullable final String title,
+ @DatetimeType final int type,
+ @Nullable final String defaultValue,
+ @Nullable final String minValue,
+ @Nullable final String maxValue) {
+ super(title);
+ this.type = type;
+ this.defaultValue = defaultValue;
+ this.minValue = minValue;
+ this.maxValue = maxValue;
+ }
+
+ /**
+ * 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})
+ /* package */ @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})
+ /* package */ @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(@Nullable final String title,
+ @FileType final int type,
+ @CaptureType final int capture,
+ @Nullable final String[] mimeTypes) {
+ super(title);
+ 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(@Nullable final String targetUri) {
+ super(null);
+ 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})
+ /* package */ @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(@Nullable final String title,
+ @Nullable final String text,
+ @Nullable final String uri) {
+ super(title);
+ 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 T[] options) {
+ super(null);
+ 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.
+ *
+ * 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.
+ *
+ * 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}.
+ *
+ * Confirm the request with an {@link Autocomplete.Option}
+ * to trigger a
+ * {@link Autocomplete.LoginStorageDelegate#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.
+ *
+ * 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 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}
+ *
+ * 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.
+ *
+ * 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;
+ }
+ }
+
+ /**
+ * 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 GeckoSession session, int scrollX, 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(this);
+ }
+ 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 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.
+ *
+ *
+ * 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;
+
+ /**
+ * 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 GeckoSession session,
+ @Nullable String[] permissions,
+ @NonNull Callback callback) {
+ callback.reject();
+ }
+
+ /**
+ * Request content permission.
+ *
+ * Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted
+ * for a site, it cannot be revoked. If the permission has previously been granted, it is
+ * the responsibility of the consuming app to remember the permission and prevent the prompt
+ * from being redisplayed to the user.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param uri The URI of the content requesting the permission.
+ * @param type The type of the requested permission; possible values are,
+ * PERMISSION_GEOLOCATION
+ * PERMISSION_DESKTOP_NOTIFICATION
+ * PERMISSION_PERSISTENT_STORAGE
+ * PERMISSION_XR
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onContentPermissionRequest(@NonNull GeckoSession session, @Nullable String uri,
+ @Permission int type, @NonNull Callback callback) {
+ callback.reject();
+ }
+
+ class MediaSource {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SOURCE_CAMERA, SOURCE_SCREEN,
+ SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE,
+ SOURCE_OTHER})
+ /* package */ @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})
+ /* package */ @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 the origin-specific source identifier.
+ */
+ public final @NonNull String id;
+
+ /**
+ * A string giving the non-origin-specific source identifier.
+ */
+ public final @NonNull String rawId;
+
+ /**
+ * 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");
+ rawId = media.getString("rawId");
+ name = media.getString("name");
+ source = getSourceFromString(media.getString("mediaSource"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /**
+ * Empty constructor for tests.
+ */
+ protected MediaSource() {
+ id = null;
+ rawId = null;
+ name = null;
+ source = 0;
+ type = 0;
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * 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 GeckoSession session, @NonNull String uri,
+ @Nullable MediaSource[] video, @Nullable MediaSource[] audio,
+ @NonNull 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})
+ /* package */ @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 GeckoSession session, @RestartReason 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 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 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 GeckoSession session, int selStart, int selEnd,
+ int compositionStart, 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 GeckoSession session,
+ @NonNull ExtractedTextRequest request,
+ @NonNull 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 GeckoSession session,
+ @NonNull CursorAnchorInfo info) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TextInputDelegate.RESTART_REASON_FOCUS, TextInputDelegate.RESTART_REASON_BLUR,
+ TextInputDelegate.RESTART_REASON_CONTENT_CHANGE})
+ /* package */ @interface RestartReason {}
+
+ /* package */ void onSurfaceChanged(final Surface surface, final int x, final int y, final int width,
+ final int height) {
+ ThreadUtils.assertOnUiThread();
+
+ mOffsetX = x;
+ mOffsetY = y;
+ mWidth = width;
+ mHeight = height;
+
+ if (mCompositorReady) {
+ mCompositor.syncResumeResizeCompositor(x, y, width, height, surface);
+ 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.
+ mSurface = surface;
+
+ // 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.
+ mSurface = 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 (mSurface != 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(mSurface, mOffsetX, mOffsetY, mWidth, mHeight);
+ }
+
+ 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();
+ }
+ 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 (mSurface != null) {
+ // If we have a valid surface, resume the
+ // compositor now that the compositor is ready.
+ onSurfaceChanged(mSurface, mOffsetX, mOffsetY, mWidth, mHeight);
+ mSurface = 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);
+ }
+ }
+
+ /**
+ * 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 })
+ /* package */ @interface RecordingStatus {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(flag = true,
+ value = {Type.CAMERA, Type.MICROPHONE})
+ /* package */ @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;
+ }
+ }
+ /**
+ * An HTMLMediaElement has been created.
+ * @param session Session instance.
+ * @param element The media element that was just created.
+ */
+ @UiThread
+ default void onMediaAdd(@NonNull GeckoSession session, @NonNull MediaElement element) {}
+
+ /**
+ * An HTMLMediaElement has been unloaded.
+ * @param session Session instance.
+ * @param element The media element that was unloaded.
+ */
+ @UiThread
+ default void onMediaRemove(@NonNull GeckoSession session, @NonNull MediaElement element) {}
+
+ /**
+ * 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 GeckoSession session, @NonNull 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 GeckoSession session,
+ @NonNull String url,
+ @Nullable String lastVisitedURL,
+ @VisitFlags 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 GeckoSession session,
+ @NonNull String[] urls) {
+ return null;
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ default void onHistoryStateChange(@NonNull GeckoSession session, @NonNull 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
+ })
+ /* package */ @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();
+ }
+
+ /**
+ * Perform autofill using the specified values.
+ *
+ * @param values Map of autofill IDs to values.
+ */
+ @UiThread
+ public void autofill(final @NonNull SparseArray<CharSequence> values) {
+ getAutofillSupport().autofill(values);
+ }
+
+ /**
+ * 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();
+ }
+
+ 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 (JSONException e) {
+ Log.w(LOGTAG, "Failed to fixup web app manifest", e);
+ }
+
+ return manifest;
+ }
+}
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..ce92ae27c1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java
@@ -0,0 +1,108 @@
+/* -*- 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 org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import androidx.annotation.UiThread;
+import android.util.Log;
+
+/* 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..a542ab320c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
@@ -0,0 +1,734 @@
+/* -*- 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 org.mozilla.gecko.util.GeckoBundle;
+
+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;
+
+@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.
+ *
+ * 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 && mSession.isOpen()) {
+ 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.isOpen()) {
+ 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..94a4c7266c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
@@ -0,0 +1,40 @@
+/* -*- 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..001ce0df99
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
@@ -0,0 +1,904 @@
+/* -*- 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 org.mozilla.gecko.AndroidGamepadManager;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.SurfaceViewWrapper;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.content.Context;
+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 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 android.util.AttributeSet;
+import android.util.DisplayMetrics;
+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.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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@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;
+ 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;
+ mDisplay.surfaceChanged(wrapper.getSurface(),
+ wrapper.getWidth(), wrapper.getHeight());
+ 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,
+ final int width, final int height) {
+ if (mDisplay != null) {
+ mDisplay.surfaceChanged(surface, width, height);
+ 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 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 = ActivityUtils.getActivityFromContext(getContext());
+ if (activity != null) {
+ mSelectionActionDelegate = new BasicSelectionActionDelegate(activity);
+ }
+
+ mAutofillDelegate = new AndroidAutofillDelegate();
+ }
+
+ /**
+ * 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}.
+ *
+ * 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}.
+ *
+ * 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})
+ /* protected */ @interface ViewBackend {}
+
+ /**
+ * Set which view should be used by this GeckoView instance to display content.
+ *
+ * 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());
+ }
+
+ /**
+ * 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.
+ *
+ * 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).
+ *
+ * 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
+ final static 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.
+ *
+ * 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;
+ }
+
+ GeckoSession session = mSession;
+ mSession.releaseDisplay(mDisplay.release());
+ mSession.getOverscrollEdgeEffect().setInvalidationCallback(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 (isFocused()) {
+ mSession.setFocused(false);
+ }
+ mSession = null;
+ return session;
+ }
+
+ /**
+ * 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 (mSession != null && mSession.isOpen()) {
+ throw new IllegalStateException("Current session is open");
+ }
+
+ releaseSession();
+
+ mSession = session;
+
+ // 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().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 (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 (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) {
+ // onConfigurationChanged is not called for 180 degree orientation changes,
+ // we will miss such rotations and the screen orientation will not be
+ // updated.
+ 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 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.
+ *
+ * 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 One of the {@link PanZoomController#INPUT_RESULT_UNHANDLED INPUT_RESULT_*}) indicating how the event was handled.
+ */
+ public @NonNull GeckoResult<Integer> onTouchEventForResult(final @NonNull MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return GeckoResult.fromValue(PanZoomController.INPUT_RESULT_UNHANDLED);
+ }
+
+ // 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().onTouchEventForResult(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) {
+ super.onProvideAutofillVirtualStructure(structure, flags);
+
+ if (mSession == null) {
+ return;
+ }
+
+ final Autofill.Session autofillSession = mSession.getAutofillSession();
+ autofillSession.fillViewStructure(this, structure, flags);
+ }
+
+ @Override
+ @TargetApi(26)
+ public void autofill(@NonNull final SparseArray<AutofillValue> values) {
+ super.autofill(values);
+
+ if (mSession == 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());
+ }
+ }
+ mSession.autofill(strValues);
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being
+ * rendered.
+ *
+ * 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.
+ *
+ * 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;
+ }
+
+ private class AndroidAutofillDelegate implements Autofill.Delegate {
+
+ private Rect displayRectForId(@NonNull final GeckoSession session,
+ @NonNull final Autofill.Node node) {
+ if (node == null) {
+ return new Rect(0, 0, 0, 0);
+ }
+
+ 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 onAutofill(@NonNull final GeckoSession session,
+ final int notification,
+ final Autofill.Node node) {
+ ThreadUtils.assertOnUiThread();
+ if (Build.VERSION.SDK_INT < 26) {
+ return;
+ }
+
+ final AutofillManager manager =
+ GeckoView.this.getContext().getSystemService(AutofillManager.class);
+ if (manager == null) {
+ return;
+ }
+
+ switch (notification) {
+ case Autofill.Notify.SESSION_STARTED:
+ // This line seems necessary for auto-fill to work on the initial page.
+ case Autofill.Notify.SESSION_CANCELED:
+ manager.cancel();
+ break;
+ case Autofill.Notify.SESSION_COMMITTED:
+ manager.commit();
+ break;
+ case Autofill.Notify.NODE_FOCUSED:
+ manager.notifyViewEntered(
+ GeckoView.this, node.getId(),
+ displayRectForId(session, node));
+ break;
+ case Autofill.Notify.NODE_BLURRED:
+ manager.notifyViewExited(GeckoView.this, node.getId());
+ break;
+ case Autofill.Notify.NODE_UPDATED:
+ manager.notifyValueChanged(
+ GeckoView.this,
+ node.getId(),
+ AutofillValue.forText(node.getValue()));
+ break;
+ }
+ }
+ }
+}
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..939e0c1360
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java
@@ -0,0 +1,195 @@
+/* -*- 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,
+ })
+ /* package */ @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.matches("(http|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..c5a619c50d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
@@ -0,0 +1,45 @@
+/* -*- 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.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.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return mCollection.getBitmap(size);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaElement.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaElement.java
new file mode 100644
index 0000000000..d6f5509c1b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaElement.java
@@ -0,0 +1,590 @@
+/* -*- 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 androidx.annotation.UiThread;
+
+import org.mozilla.gecko.util.GeckoBundle;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Locale;
+
+/**
+ * GeckoSession applications can use this class to handle media events
+ * and control the HTMLMediaElement externally.
+ **/
+@AnyThread
+public class MediaElement {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({MEDIA_STATE_PLAY, MEDIA_STATE_PLAYING, MEDIA_STATE_PAUSE,
+ MEDIA_STATE_ENDED, MEDIA_STATE_SEEKING, MEDIA_STATE_SEEKED,
+ MEDIA_STATE_STALLED, MEDIA_STATE_SUSPEND, MEDIA_STATE_WAITING,
+ MEDIA_STATE_ABORT, MEDIA_STATE_EMPTIED})
+ /* package */ @interface MediaStateFlags {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({MEDIA_READY_STATE_HAVE_NOTHING, MEDIA_READY_STATE_HAVE_METADATA,
+ MEDIA_READY_STATE_HAVE_CURRENT_DATA, MEDIA_READY_STATE_HAVE_FUTURE_DATA,
+ MEDIA_READY_STATE_HAVE_ENOUGH_DATA})
+
+ /* package */ @interface ReadyStateFlags {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({MEDIA_ERROR_NETWORK_NO_SOURCE, MEDIA_ERROR_ABORTED, MEDIA_ERROR_NETWORK,
+ MEDIA_ERROR_DECODE, MEDIA_ERROR_SRC_NOT_SUPPORTED})
+ /* package */ @interface MediaErrorFlags {}
+
+ /**
+ * The media is no longer paused, as a result of the play method, or the autoplay attribute.
+ */
+ public static final int MEDIA_STATE_PLAY = 0;
+ /**
+ * Sent when the media has enough data to start playing, after the play event,
+ * but also when recovering from being stalled, when looping media restarts,
+ * and after seeked, if it was playing before seeking.
+ */
+ public static final int MEDIA_STATE_PLAYING = 1;
+ /**
+ * Sent when the playback state is changed to paused.
+ */
+ public static final int MEDIA_STATE_PAUSE = 2;
+ /**
+ * Sent when playback completes.
+ */
+ public static final int MEDIA_STATE_ENDED = 3;
+ /**
+ * Sent when a seek operation begins.
+ */
+ public static final int MEDIA_STATE_SEEKING = 4;
+ /**
+ * Sent when a seek operation completes.
+ */
+ public static final int MEDIA_STATE_SEEKED = 5;
+ /**
+ * Sent when the user agent is trying to fetch media data,
+ * but data is unexpectedly not forthcoming.
+ */
+ public static final int MEDIA_STATE_STALLED = 6;
+ /**
+ * Sent when loading of the media is suspended. This may happen either because
+ * the download has completed or because it has been paused for any other reason.
+ */
+ public static final int MEDIA_STATE_SUSPEND = 7;
+ /**
+ * Sent when the requested operation (such as playback) is delayed
+ * pending the completion of another operation (such as a seek).
+ */
+ public static final int MEDIA_STATE_WAITING = 8;
+ /**
+ * Sent when playback is aborted; for example, if the media is playing
+ * and is restarted from the beginning, this event is sent.
+ */
+ public static final int MEDIA_STATE_ABORT = 9;
+ /**
+ * The media has become empty. For example, this event is sent if the media
+ * has already been loaded, and the load() method is called to reload it.
+ */
+ public static final int MEDIA_STATE_EMPTIED = 10;
+
+
+ /**
+ * No information is available about the media resource.
+ */
+ public static final int MEDIA_READY_STATE_HAVE_NOTHING = 0;
+ /**
+ * Enough of the media resource has been retrieved that the metadata
+ * attributes are available.
+ */
+ public static final int MEDIA_READY_STATE_HAVE_METADATA = 1;
+ /**
+ * Data is available for the current playback position,
+ * but not enough to actually play more than one frame.
+ */
+ public static final int MEDIA_READY_STATE_HAVE_CURRENT_DATA = 2;
+ /**
+ * Data for the current playback position as well as for at least a little
+ * bit of time into the future is available.
+ */
+ public static final int MEDIA_READY_STATE_HAVE_FUTURE_DATA = 3;
+ /**
+ * Enough data is available—and the download rate is high enough that the media
+ * can be played through to the end without interruption.
+ */
+ public static final int MEDIA_READY_STATE_HAVE_ENOUGH_DATA = 4;
+
+
+ /**
+ * Media source not found or unable to select any of the child elements
+ * for playback during resource selection.
+ */
+ public static final int MEDIA_ERROR_NETWORK_NO_SOURCE = 0;
+ /**
+ * The fetching of the associated resource was aborted by the user's request.
+ */
+ public static final int MEDIA_ERROR_ABORTED = 1;
+ /**
+ * Some kind of network error occurred which prevented the media from being
+ * successfully fetched, despite having previously been available.
+ */
+ public static final int MEDIA_ERROR_NETWORK = 2;
+ /**
+ * Despite having previously been determined to be usable,
+ * an error occurred while trying to decode the media resource, resulting in an error.
+ */
+ public static final int MEDIA_ERROR_DECODE = 3;
+ /**
+ * The associated resource or media provider object has been found to be unsuitable.
+ */
+ public static final int MEDIA_ERROR_SRC_NOT_SUPPORTED = 4;
+
+ /**
+ * Data class with the Metadata associated to a Media Element.
+ **/
+ public static class Metadata {
+ /**
+ * Contains the current media source URI.
+ */
+ public final @Nullable String currentSource;
+
+ /**
+ * Indicates the duration of the media in seconds.
+ */
+ public final double duration;
+
+ /**
+ * Indicates the width of the video in device pixels.
+ */
+ public final long width;
+
+ /**
+ * Indicates the height of the video in device pixels.
+ */
+ public final long height;
+
+ /**
+ * Indicates if seek operations are compatible with the media.
+ */
+ public final boolean isSeekable;
+
+ /**
+ * Indicates the number of audio tracks included in the media.
+ */
+ public final int audioTrackCount;
+
+ /**
+ * Indicates the number of video tracks included in the media.
+ */
+ public final int videoTrackCount;
+
+ /* package */ Metadata(final GeckoBundle bundle) {
+ currentSource = bundle.getString("src", "");
+ duration = bundle.getDouble("duration", 0);
+ width = bundle.getLong("width", 0);
+ height = bundle.getLong("height", 0);
+ isSeekable = bundle.getBoolean("seekable", false);
+ audioTrackCount = bundle.getInt("audioTrackCount", 0);
+ videoTrackCount = bundle.getInt("videoTrackCount", 0);
+ }
+
+ /**
+ * Empty constructor for tests.
+ */
+ protected Metadata() {
+ currentSource = "";
+ duration = 0;
+ width = 0;
+ height = 0;
+ isSeekable = false;
+ audioTrackCount = 0;
+ videoTrackCount = 0;
+ }
+ }
+
+ /**
+ * Data class that indicates infomation about a media load progress event.
+ **/
+ public static class LoadProgressInfo {
+ /**
+ * Class used to represent a set of time ranges.
+ */
+ public class TimeRange {
+ protected TimeRange(final double start, final double end) {
+ this.start = start;
+ this.end = end;
+ }
+
+ /**
+ * The start time of the range in seconds.
+ */
+ public final double start;
+ /**
+ * The end time of the range in seconds.
+ */
+ public final double end;
+ }
+
+ /**
+ * The number of bytes transferred since the beginning of the operation
+ * or -1 if the data is not computable.
+ */
+ public final long loadedBytes;
+
+ /**
+ * The total number of bytes of content that will be transferred during the operation
+ * or -1 if the data is not computable.
+ */
+ public final long totalBytes;
+
+ /**
+ * The ranges of the media source that the browser has currently buffered.
+ * Null if the browser has not buffered any time range or the data is not computable.
+ */
+ public final @Nullable TimeRange[] buffered;
+
+ /* package */ LoadProgressInfo(final GeckoBundle bundle) {
+ loadedBytes = bundle.getLong("loadedBytes", -1);
+ totalBytes = bundle.getLong("loadedBytes", -1);
+ double[] starts = bundle.getDoubleArray("timeRangeStarts");
+ double[] ends = bundle.getDoubleArray("timeRangeEnds");
+ if (starts == null || ends == null) {
+ buffered = null;
+ return;
+ }
+
+ if (starts.length != ends.length) {
+ throw new AssertionError("timeRangeStarts and timeRangeEnds length do not match");
+ }
+
+ buffered = new TimeRange[starts.length];
+ for (int i = 0; i < starts.length; ++i) {
+ buffered[i] = new TimeRange(starts[i], ends[i]);
+ }
+ }
+
+ /**
+ * Empty constructor for tests.
+ */
+ protected LoadProgressInfo() {
+ loadedBytes = 0;
+ totalBytes = 0;
+ buffered = null;
+ }
+ }
+
+ /**
+ * This interface allows apps to handle media events.
+ **/
+ public interface Delegate {
+ /**
+ * The media playback state has changed.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param mediaState The playback state of the media.
+ * One of the {@link #MEDIA_STATE_PLAY MEDIA_STATE_*} flags.
+ */
+ @UiThread
+ default void onPlaybackStateChange(@NonNull MediaElement mediaElement,
+ @MediaStateFlags int mediaState) {}
+
+ /**
+ * The readiness state of the media has changed.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param readyState The readiness state of the media.
+ * One of the {@link #MEDIA_READY_STATE_HAVE_NOTHING MEDIA_READY_STATE_*} flags.
+ */
+ @UiThread
+ default void onReadyStateChange(@NonNull MediaElement mediaElement,
+ @ReadyStateFlags int readyState) {}
+
+ /**
+ * The media metadata has loaded or changed.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param metaData The MetaData values of the media.
+ */
+ @UiThread
+ default void onMetadataChange(@NonNull MediaElement mediaElement,
+ @NonNull Metadata metaData) {}
+
+ /**
+ * Indicates that a loading operation is in progress for the media.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param progressInfo Information about the load progress and buffered ranges.
+ */
+ @UiThread
+ default void onLoadProgress(@NonNull MediaElement mediaElement,
+ @NonNull LoadProgressInfo progressInfo) {}
+
+ /**
+ * The media audio volume has changed.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param volume The volume of the media.
+ * @param muted True if the media is muted.
+ */
+ @UiThread
+ default void onVolumeChange(@NonNull MediaElement mediaElement, double volume,
+ boolean muted) {}
+
+ /**
+ * The current playback time has changed. This event is usually dispatched every 250ms.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param time The current playback time in seconds.
+ */
+ @UiThread
+ default void onTimeChange(@NonNull MediaElement mediaElement, double time) {}
+
+ /**
+ * The media playback speed has changed.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param rate The current playback rate. A value of 1.0 indicates normal speed.
+ */
+ @UiThread
+ default void onPlaybackRateChange(@NonNull MediaElement mediaElement, double rate) {}
+
+ /**
+ * A media element has entered or exited fullscreen mode.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param fullscreen True if the media has entered full screen mode.
+ */
+ @UiThread
+ default void onFullscreenChange(@NonNull MediaElement mediaElement, boolean fullscreen) {}
+
+ /**
+ * An error has occurred.
+ *
+ * @param mediaElement A reference to the MediaElement that dispatched the event.
+ * @param errorCode The error code.
+ * One of the {@link #MEDIA_ERROR_NETWORK_NO_SOURCE MEDIA_ERROR_*} flags.
+ */
+ @UiThread
+ default void onError(@NonNull MediaElement mediaElement, @MediaErrorFlags int errorCode) {}
+ }
+
+ /* package */ long getVideoId() {
+ return mVideoId;
+ }
+
+ /**
+ * Gets the current the media callback handler.
+ *
+ * @return the current media callback handler.
+ */
+ public @Nullable MediaElement.Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ /**
+ * Sets the media callback handler.
+ * This will replace the current handler.
+ *
+ * @param delegate An implementation of MediaDelegate.
+ */
+ public void setDelegate(final @Nullable MediaElement.Delegate delegate) {
+ if (mDelegate == delegate) {
+ return;
+ }
+ MediaElement.Delegate oldDelegate = mDelegate;
+ mDelegate = delegate;
+ if (oldDelegate != null && mDelegate == null) {
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaUnobserve", createMessage());
+ mSession.getMediaElements().remove(mVideoId);
+ } else if (oldDelegate == null) {
+ mSession.getMediaElements().put(mVideoId, this);
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaObserve", createMessage());
+ }
+ }
+
+ /**
+ * Pauses the media.
+ */
+ public void pause() {
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaPause", createMessage());
+ }
+
+ /**
+ * Plays the media.
+ */
+ public void play() {
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaPlay", createMessage());
+ }
+
+ /**
+ * Seek the media to a given time.
+ *
+ * @param time Seek time in seconds.
+ */
+ public void seek(final double time) {
+ final GeckoBundle message = createMessage();
+ message.putDouble("time", time);
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaSeek", message);
+ }
+
+ /**
+ * Set the volume at which the media will be played.
+ *
+ * @param volume A Volume value. It must fall between 0 and 1, where 0 is effectively muted
+ * and 1 is the loudest possible value.
+ */
+ public void setVolume(final double volume) {
+ final GeckoBundle message = createMessage();
+ message.putDouble("volume", volume);
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaSetVolume", message);
+ }
+
+ /**
+ * Mutes the media.
+ *
+ * @param muted True in order to mute the audio.
+ */
+ public void setMuted(final boolean muted) {
+ final GeckoBundle message = createMessage();
+ message.putBoolean("muted", muted);
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaSetMuted", message);
+ }
+
+ /**
+ * Sets the playback rate at which the media will be played.
+ *
+ * @param playbackRate The rate at which the media will be played.
+ * A value of 1.0 indicates normal speed.
+ */
+ public void setPlaybackRate(final double playbackRate) {
+ final GeckoBundle message = createMessage();
+ message.putDouble("playbackRate", playbackRate);
+ mSession.getEventDispatcher().dispatch("GeckoView:MediaSetPlaybackRate", message);
+ }
+
+ // Helper methods used for event observers to update the current video state
+
+ @UiThread
+ /* package */ void notifyPlaybackStateChange(final String event) {
+ @MediaStateFlags int state;
+ switch (event.toLowerCase(Locale.ROOT)) {
+ case "play":
+ state = MEDIA_STATE_PLAY;
+ break;
+ case "playing":
+ state = MEDIA_STATE_PLAYING;
+ break;
+ case "pause":
+ state = MEDIA_STATE_PAUSE;
+ break;
+ case "ended":
+ state = MEDIA_STATE_ENDED;
+ break;
+ case "seeking":
+ state = MEDIA_STATE_SEEKING;
+ break;
+ case "seeked":
+ state = MEDIA_STATE_SEEKED;
+ break;
+ case "stalled":
+ state = MEDIA_STATE_STALLED;
+ break;
+ case "suspend":
+ state = MEDIA_STATE_SUSPEND;
+ break;
+ case "waiting":
+ state = MEDIA_STATE_WAITING;
+ break;
+ case "abort":
+ state = MEDIA_STATE_ABORT;
+ break;
+ case "emptied":
+ state = MEDIA_STATE_EMPTIED;
+ break;
+ default:
+ throw new UnsupportedOperationException(event + " HTMLMediaElement event not implemented");
+ }
+
+ if (mDelegate != null) {
+ mDelegate.onPlaybackStateChange(this, state);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyReadyStateChange(final int readyState) {
+ if (mDelegate != null) {
+ mDelegate.onReadyStateChange(this, readyState);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyLoadProgress(final GeckoBundle message) {
+ if (mDelegate != null) {
+ mDelegate.onLoadProgress(this, new LoadProgressInfo(message));
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyTimeChange(final double currentTime) {
+ if (mDelegate != null) {
+ mDelegate.onTimeChange(this, currentTime);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyVolumeChange(final double volume, final boolean muted) {
+ if (mDelegate != null) {
+ mDelegate.onVolumeChange(this, volume, muted);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyPlaybackRateChange(final double rate) {
+ if (mDelegate != null) {
+ mDelegate.onPlaybackRateChange(this, rate);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyMetadataChange(final GeckoBundle message) {
+ if (mDelegate != null) {
+ mDelegate.onMetadataChange(this, new Metadata(message));
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyFullscreenChange(final boolean fullscreen) {
+ if (mDelegate != null) {
+ mDelegate.onFullscreenChange(this, fullscreen);
+ }
+ }
+
+ @UiThread
+ /* package */ void notifyError(final int aCode) {
+ if (mDelegate != null) {
+ mDelegate.onError(this, aCode);
+ }
+ }
+
+ private GeckoBundle createMessage() {
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putLong("id", mVideoId);
+ return bundle;
+ }
+
+ /* package */ MediaElement(final long videoId, final GeckoSession session) {
+ mVideoId = videoId;
+ mSession = session;
+ }
+
+ final protected @NonNull GeckoSession mSession;
+ final protected long mVideoId;
+ protected @Nullable MediaElement.Delegate mDelegate;
+}
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..7592af8397
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
@@ -0,0 +1,742 @@
+/* -*- 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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.util.Log;
+
+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.
+ *
+ * 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 GeckoSession session,
+ @NonNull MediaSession mediaSession) {}
+
+ /**
+ * Notify that the given media session has become inactive.
+ * Inactive media sessions can not be controlled.
+ *
+ * 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull MediaSession mediaSession,
+ @NonNull 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 GeckoSession session,
+ @NonNull MediaSession mediaSession,
+ @MSFeature 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull 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 GeckoSession session,
+ @NonNull MediaSession mediaSession,
+ @NonNull 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 GeckoSession session,
+ @NonNull MediaSession mediaSession,
+ boolean enabled,
+ @Nullable 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
+ })
+ /* package */ @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/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
new file mode 100644
index 0000000000..887bbb7502
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
@@ -0,0 +1,210 @@
+/* -*- 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 org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.widget.EdgeEffect;
+
+import java.lang.reflect.Field;
+
+@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 final GeckoSession mSession;
+ private Runnable mInvalidationCallback;
+ private int mWidth;
+ private int mHeight;
+
+ /* package */ OverscrollEdgeEffect(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * 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();
+
+ final PorterDuffXfermode mode = new PorterDuffXfermode(PorterDuff.Mode.SRC);
+ Field paintField = null;
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ try {
+ paintField = EdgeEffect.class.getDeclaredField("mPaint");
+ paintField.setAccessible(true);
+ } catch (NoSuchFieldException e) {
+ }
+ }
+
+ for (int i = 0; i < mEdges.length; i++) {
+ mEdges[i] = new EdgeEffect(context);
+
+ if (paintField == null) {
+ continue;
+ }
+
+ try {
+ final Paint p = (Paint) paintField.get(mEdges[i]);
+
+ // The Android EdgeEffect class uses a mode of SRC_ATOP here, which means
+ // it will only draw the effect where there are non-transparent pixels in
+ // the destination. Since the LayerView itself is fully transparent, it
+ // doesn't display at all. We need to use SRC instead.
+ p.setXfermode(mode);
+ } catch (IllegalAccessException e) {
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ 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();
+
+ 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);
+ 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..7c3054baa2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java
@@ -0,0 +1,806 @@
+/* -*- 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 org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Rect;
+import android.os.SystemClock;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+import androidx.annotation.IntDef;
+import android.util.Log;
+import android.util.Pair;
+import android.view.MotionEvent;
+import android.view.InputDevice;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import java.util.ArrayList;
+
+@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 final String PREF_MOUSE_AS_TOUCH = "ui.android.mouse_as_touch";
+ private static boolean sTreatMouseAsTouch = true;
+
+ 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})
+ /* package */ @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})
+ /* package */ @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;
+
+ 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);
+
+ 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<Integer> 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);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeMouseEvent(final int eventType, final int clientX,
+ final int clientY) {
+ synthesizeNativePointer(InputDevice.SOURCE_MOUSE,
+ PointerInfo.RESERVED_MOUSE_POINTER_ID,
+ eventType, clientX, clientY, 0, 0);
+ }
+
+ @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<Integer> result) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event));
+ if (result != null) {
+ result.complete(INPUT_RESULT_HANDLED);
+ }
+ return;
+ }
+
+ final int action = event.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ mLastDownTime = event.getDownTime();
+ } else if (mLastDownTime != event.getDownTime()) {
+ if (result != null) {
+ result.complete(INPUT_RESULT_UNHANDLED);
+ }
+ 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();
+ initMouseAsTouch();
+ }
+
+ private static void initMouseAsTouch() {
+ PrefsHelper.PrefHandler prefHandler = new PrefsHelper.PrefHandlerBase() {
+ @Override
+ public void prefValue(final String pref, final int value) {
+ if (!PREF_MOUSE_AS_TOUCH.equals(pref)) {
+ return;
+ }
+ if (value == 0) {
+ sTreatMouseAsTouch = false;
+ } else if (value == 1) {
+ sTreatMouseAsTouch = true;
+ } else if (value == 2) {
+ Context c = GeckoAppShell.getApplicationContext();
+ 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);
+ }
+ }
+ };
+ PrefsHelper.addObserver(new String[] { PREF_MOUSE_AS_TOUCH }, prefHandler);
+ PrefsHelper.getPref(PREF_MOUSE_AS_TOUCH, prefHandler);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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 (!sTreatMouseAsTouch && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ 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.
+ *
+ * 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 one of the
+ * {@link PanZoomController#INPUT_RESULT_UNHANDLED INPUT_RESULT_*}) constants indicating
+ * how the event was handled.
+ */
+ public @NonNull GeckoResult<Integer> onTouchEventForResult(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!sTreatMouseAsTouch && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return GeckoResult.fromValue(handleMouseEvent(event));
+ }
+
+ final GeckoResult<Integer> 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;
+ }
+
+ ArrayList<Pair<Integer, MotionEvent>> events = mQueuedEvents;
+ mQueuedEvents = null;
+ for (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 MotionEvent.PointerCoords getCoords() {
+ 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) {
+ 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;
+ }
+
+ MotionEvent.PointerProperties[] getPointerProperties(final int source) {
+ 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) {
+ 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) {
+ 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) {
+ 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
+ PointerInfo info = mPointerState.pointers.get(pointerIndex);
+ info.surfaceX = surfaceX;
+ info.surfaceY = surfaceY;
+ info.pressure = pressure;
+ info.orientation = orientation;
+
+ // 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);
+ boolean isButtonDown = (source == InputDevice.SOURCE_MOUSE) &&
+ (eventType == MotionEvent.ACTION_DOWN ||
+ eventType == MotionEvent.ACTION_MOVE);
+ 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*/ (isButtonDown ? MotionEvent.BUTTON_PRIMARY : 0),
+ /*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..847c710a54
--- /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..0e7eff6978
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
@@ -0,0 +1,170 @@
+/* -*- 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 org.mozilla.gecko.GeckoJavaSampler;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.NonNull;
+import androidx.annotation.UiThread;
+
+/**
+ * ProfilerController is used to manage GeckoProfiler related features.
+ *
+ * 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);
+ }
+}
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..0e8de5e6a0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
@@ -0,0 +1,273 @@
+/* -*- 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 java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.collection.ArrayMap;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * Base class for (nested) runtime settings.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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..c2539e7e05
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.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.NonNull;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.GeckoThread;
+
+/**
+ * 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 */ final static 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..918af303fd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
@@ -0,0 +1,156 @@
+/* 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})
+ /* package */ @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..7f8ca1b181
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
@@ -0,0 +1,1034 @@
+/* -*- 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 org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.PrefsHelper;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+import android.content.Context;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.os.Build;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+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.RangeInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo;
+import android.view.accessibility.AccessibilityNodeInfo.CollectionInfo;
+import android.view.accessibility.AccessibilityNodeProvider;
+
+import java.util.Iterator;
+import java.util.LinkedList;
+
+@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" };
+
+ static private 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() ?
+ 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);
+ 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
+ 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) {
+ boolean extendSelection = arguments.getBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN);
+ 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;
+ }
+ int selectionStart = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT);
+ 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:
+ 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) {
+ AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId);
+ populateNodeFromBundle(node, nativeProvider.getNodeInfo(virtualViewId), false);
+ return node;
+ }
+
+ private AccessibilityNodeInfo getNodeFromCache(final int virtualViewId) {
+ synchronized (SessionAccessibility.this) {
+ AccessibilityNodeInfo node = null;
+ for (SparseArray<GeckoBundle> cache : mCaches) {
+ 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");
+ 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
+ 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
+ int[] children = nodeInfo.getIntArray("children");
+ if (node.getChildCount() == 0 && children != null) {
+ for (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
+ 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
+ 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
+ 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
+ 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();
+ }
+
+ /**
+ * 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 final String FORCE_ACCESSIBILITY_PREF = "accessibility.force_disabled";
+
+ private static volatile boolean sEnabled;
+ private static volatile boolean sTouchExplorationEnabled;
+ /* package */ static volatile boolean sForceEnabled;
+
+ static {
+ final Context context = GeckoAppShell.getApplicationContext();
+ AccessibilityManager accessibilityManager =
+ (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+
+ accessibilityManager.addAccessibilityStateChangeListener(enabled ->
+ updateAccessibilitySettings());
+
+ if (Build.VERSION.SDK_INT >= 19) {
+ accessibilityManager.addTouchExplorationStateChangeListener(enabled ->
+ updateAccessibilitySettings());
+ }
+
+ PrefsHelper.PrefHandler prefHandler = new PrefsHelper.PrefHandlerBase() {
+ @Override
+ public void prefValue(final String pref, final int value) {
+ if (pref.equals(FORCE_ACCESSIBILITY_PREF)) {
+ sForceEnabled = value < 0;
+ dispatch();
+ }
+ }
+ };
+ PrefsHelper.addObserver(new String[]{ FORCE_ACCESSIBILITY_PREF }, prefHandler);
+ }
+
+ public static boolean isPlatformEnabled() {
+ return sEnabled;
+ }
+
+ 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() {
+ final GeckoBundle ret = new GeckoBundle(2);
+ ret.putBoolean("touchEnabled", isTouchExplorationEnabled());
+ ret.putBoolean("enabled", isEnabled());
+ EventDispatcher.getInstance().dispatch("GeckoView:AccessibilityEnabled", ret);
+
+ 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.getRawX(), event.getRawY());
+
+ 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;
+ }
+
+ GeckoBundle cachedBundle = getMostRecentBundle(sourceId);
+ if (cachedBundle == null && sourceId != View.NO_ID) {
+ // 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);
+ if (className == CLASSNAME_UNKNOWN && cachedBundle != null) {
+ event.setClassName(getClassName(cachedBundle.getInt("className")));
+ } else {
+ event.setClassName(getClassName(className));
+ }
+
+ 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;
+ 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.
+ CharSequence afterText = text.subSequence(mEndOffset, text.length());
+ if (TextUtils.getTrimmedLength(afterText) == 0) {
+ mAtLastWord = true;
+ }
+ }
+ break;
+ }
+
+ try {
+ ((ViewParent) mView).requestSendAccessibilityEvent(mView, event);
+ } catch (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) {
+ Iterator<SparseArray<GeckoBundle>> iter = mCaches.descendingIterator();
+ while (iter.hasNext()) {
+ 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) {
+ final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity);
+ if (forward && id == mLastAccessibilityFocusable) {
+ return false;
+ }
+
+ if (!forward) {
+ if (id == View.NO_ID) {
+ return false;
+ }
+
+ 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;
+ }
+
+ /* 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 GeckoBundle getNodeInfo(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 = "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 (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 (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 (GeckoBundle bundle : bundles) {
+ 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;
+ }
+ }
+ }
+}
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..52dc80f6eb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java
@@ -0,0 +1,134 @@
+/* -*- 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 org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.FinderFindFlags;
+import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags;
+import org.mozilla.geckoview.GeckoSession.FinderResult;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import android.util.Pair;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * {@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..37342aac69
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java
@@ -0,0 +1,412 @@
+/* -*- 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 androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+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 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.
+ @WrapForJNI final int ONE_SHOT = 1;
+ // START_MONITOR start the monitor for composing character rects. If is is
+ // updaed, call updateCompositionRects()
+ @WrapForJNI final int START_MONITOR = 2;
+ // ENDT_MONITOR stops the monitor for composing character rects.
+ @WrapForJNI 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(int requestMode);
+ }
+
+ // Interface to access GeckoInputConnection from GeckoEditable.
+ /* package */ interface EditableListener {
+ // IME notification type for notifyIME(), corresponding to NotificationToIME enum.
+ @WrapForJNI final int NOTIFY_IME_OF_TOKEN = -3;
+ @WrapForJNI final int NOTIFY_IME_OPEN_VKB = -2;
+ @WrapForJNI final int NOTIFY_IME_REPLY_EVENT = -1;
+ @WrapForJNI final int NOTIFY_IME_OF_FOCUS = 1;
+ @WrapForJNI final int NOTIFY_IME_OF_BLUR = 2;
+ @WrapForJNI final int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8;
+ @WrapForJNI final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;
+
+ // IME enabled state for notifyIMEContext().
+ final int IME_STATE_UNKNOWN = -1;
+ final int IME_STATE_DISABLED = 0;
+ final int IME_STATE_ENABLED = 1;
+ final int IME_STATE_PASSWORD = 2;
+
+ // Flags for notifyIMEContext().
+ @WrapForJNI final int IME_FLAG_PRIVATE_BROWSING = 1;
+ @WrapForJNI final int IME_FLAG_USER_ACTION = 2;
+
+ void notifyIME(int type);
+ void notifyIMEContext(int state, String typeHint, String modeHint,
+ String actionHint, int flag);
+ void onSelectionChange();
+ void onTextChange();
+ void onDiscardComposition();
+ void onDefaultKeyEvent(KeyEvent event);
+ void updateCompositionRects(final RectF[] aRects);
+ }
+
+ 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 (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.
+ *
+ * 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..e512ee0438
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
@@ -0,0 +1,18 @@
+/* -*- 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..438caf5fc7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
@@ -0,0 +1,184 @@
+/* -*- 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 java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.math.BigInteger;
+import java.util.Locale;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * Manage runtime storage data.
+ *
+ * Retrieve an instance via {@link GeckoRuntime#getStorageController}.
+ */
+public final class 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 })
+ /* package */ @interface StorageControllerClearFlags {}
+
+ /**
+ * Clear data for all hosts.
+ *
+ * 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.
+ *
+ * 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 for the given context ID.
+ * Use {@link GeckoSessionSettings.Builder#contextId}.to set a context ID
+ * for a session.
+ *
+ * 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 @NonNull 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);
+ }
+}
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..258f99aa5e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
@@ -0,0 +1,529 @@
+/* -*- 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 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;
+
+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;
+
+/* 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) {
+ 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!");
+ }
+
+ ArrayList<WebAuthnPublicCredential> credList =
+ new ArrayList<WebAuthnPublicCredential>();
+
+ byte[] transportBytes = new byte[transportList.remaining()];
+ transportList.get(transportBytes);
+
+ for (int i = 0; i < idObjectList.length; i++) {
+ final ByteBuffer id = (ByteBuffer)idObjectList[i];
+ 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,
+ }
+
+ 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"));
+ }
+
+ PublicKeyCredentialCreationOptions.Builder requestBuilder =
+ new PublicKeyCredentialCreationOptions.Builder();
+
+ List<PublicKeyCredentialParameters> params =
+ new ArrayList<PublicKeyCredentialParameters>();
+
+ // WebAuthn supports more algorithms
+ for (Algorithm algo : SUPPORTED_ALGORITHMS) {
+ params.add(new PublicKeyCredentialParameters(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ algo.getAlgoValue()));
+ }
+
+ PublicKeyCredentialUserEntity user =
+ new PublicKeyCredentialUserEntity(userId,
+ credentialBundle.getString("userName", ""),
+ credentialBundle.getString("userIcon", ""),
+ credentialBundle.getString("userDisplayName", ""));
+
+ AttestationConveyancePreference pref =
+ AttestationConveyancePreference.NONE;
+ 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;
+ }
+
+ AuthenticatorSelectionCriteria.Builder selBuild =
+ new AuthenticatorSelectionCriteria.Builder();
+ if (extensions.containsKey("requirePlatformAttachment")) {
+ if (authenticatorSelection.getInt("requirePlatformAttachment") == 1) {
+ selBuild.setAttachment(Attachment.PLATFORM);
+ }
+ }
+ AuthenticatorSelectionCriteria sel = selBuild.build();
+
+ AuthenticationExtensions.Builder extBuilder =
+ new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(
+ new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ AuthenticationExtensions ext = extBuilder.build();
+
+ // requireResidentKey andrequireUserVerification are not yet
+ // consumed by Android's API
+
+ List<PublicKeyCredentialDescriptor> excludedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) {
+ excludedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ PublicKeyCredentialRpEntity rp =
+ new PublicKeyCredentialRpEntity(
+ credentialBundle.getString("rpId"),
+ credentialBundle.getString("rpName", ""),
+ credentialBundle.getString("rpIcon", ""));
+
+ 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();
+
+ Uri origin = Uri.parse(credentialBundle.getString("origin"));
+
+ BrowserPublicKeyCredentialCreationOptions browserOptions =
+ new BrowserPublicKeyCredentialCreationOptions.Builder()
+ .setPublicKeyCredentialCreationOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ 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.
+ 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.
+ Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(requestOptions);
+ }
+
+ GeckoResult<MakeCredentialResponse> result = new GeckoResult<>();
+
+ intentTask.addOnSuccessListener(pendingIntent -> {
+ GeckoRuntime.getInstance().startActivityForResult(pendingIntent).accept(intent -> {
+ WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ if (rspData != null) {
+ 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);
+ 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 void webAuthnMakeCredential(final GeckoBundle credentialBundle,
+ final ByteBuffer userId,
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ ArrayList<WebAuthnPublicCredential> excludeList;
+
+ // TODO: Return a GeckoResult instead, Bug 1550116
+
+ byte[] challBytes = new byte[challenge.remaining()];
+ byte[] userBytes = new byte[userId.remaining()];
+ try {
+ challenge.get(challBytes);
+ userId.get(userBytes);
+
+ excludeList = WebAuthnPublicCredential.CombineBuffers(idList,
+ transportList);
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ webAuthnMakeCredentialReturnError("UNKNOWN_ERR");
+ return;
+ }
+
+ try {
+ GeckoResult<MakeCredentialResponse> result = makeCredential(credentialBundle, userBytes, challBytes,
+ excludeList.toArray(new WebAuthnPublicCredential[0]),
+ authenticatorSelection, extensions);
+ result.accept(cred -> {
+ webAuthnMakeCredentialFinish(cred.clientDataJson, cred.keyHandle, cred.attestationObject);
+ }, e -> {
+ webAuthnGetAssertionReturnError(e.getMessage());
+ });
+ } catch (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);
+ webAuthnMakeCredentialReturnError("UNKNOWN_ERR");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ /* package */ static native void webAuthnMakeCredentialFinish(final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] attestationObject);
+ @WrapForJNI(dispatchTo = "gecko")
+ /* package */ static native void webAuthnMakeCredentialReturnError(String errorCode);
+
+ 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;
+ }
+
+ byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA);
+ 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());
+ }
+
+ public 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"));
+ }
+
+ List<PublicKeyCredentialDescriptor> allowedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) {
+ allowedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ AuthenticationExtensions.Builder extBuilder =
+ new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(
+ new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ AuthenticationExtensions ext = extBuilder.build();
+
+ PublicKeyCredentialRequestOptions requestOptions =
+ new PublicKeyCredentialRequestOptions.Builder()
+ .setChallenge(challenge)
+ .setAllowList(allowedList)
+ .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0)
+ .setRpId(assertionBundle.getString("rpId"))
+ .setAuthenticationExtensions(ext)
+ .build();
+
+ Uri origin = Uri.parse(assertionBundle.getString("origin"));
+ BrowserPublicKeyCredentialRequestOptions browserOptions =
+ new BrowserPublicKeyCredentialRequestOptions.Builder()
+ .setPublicKeyCredentialRequestOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+
+ Task<PendingIntent> intentTask;
+ // See the makeCredential method for documentation about this
+ // conditional.
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(browserOptions);
+ } else {
+ Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(requestOptions);
+ }
+
+ GeckoResult<GetAssertionResponse> result = new GeckoResult<>();
+ intentTask.addOnSuccessListener(pendingIntent -> {
+ GeckoRuntime.getInstance().startActivityForResult(pendingIntent).accept(intent -> {
+ WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) {
+ byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ 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 void webAuthnGetAssertion(final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+ ArrayList<WebAuthnPublicCredential> allowList;
+
+ // TODO: Return a GeckoResult instead, Bug 1550116
+
+ byte[] challBytes = new byte[challenge.remaining()];
+ try {
+ challenge.get(challBytes);
+ allowList = WebAuthnPublicCredential.CombineBuffers(idList,
+ transportList);
+ } catch (RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ webAuthnGetAssertionReturnError("UNKNOWN_ERR");
+ return;
+ }
+
+ try {
+ getAssertion(challBytes,
+ allowList.toArray(new WebAuthnPublicCredential[0]),
+ assertionBundle, extensions).accept(response -> {
+ webAuthnGetAssertionFinish(response.clientDataJson, response.keyHandle, response.authData,
+ response.signature, response.userHandle);
+ }, e -> {
+ webAuthnGetAssertionReturnError(e.getMessage());
+ });
+ } catch (java.lang.Exception e) {
+ Log.w(LOGTAG, "Couldn't get assertion", e);
+ webAuthnGetAssertionReturnError("UNKNOWN_ERR");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ /* package */ static native void webAuthnGetAssertionFinish(final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] authData,
+ final byte[] signature,
+ final byte[] userHandle);
+ @WrapForJNI(dispatchTo = "gecko")
+ /* package */ static native void webAuthnGetAssertionReturnError(String errorCode);
+}
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..c913f1c94a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
@@ -0,0 +1,2610 @@
+package org.mozilla.geckoview;
+
+import android.graphics.Color;
+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 android.util.Log;
+
+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;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * 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();
+ }
+
+ private DelegateController mDelegateController = null;
+
+ /* package */ void setDelegateController(final DelegateController delegate) {
+ mDelegateController = delegate;
+ }
+
+ @Override
+ public String toString() {
+ return "WebExtension {" +
+ "location=" + location + ", " +
+ "id=" + id + ", " +
+ "flags=" + flags + "}";
+ }
+
+ private final static 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 })
+ /* package */ @interface WebExtensionFlags {}
+
+ /* package */ WebExtension(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;
+ }
+ }
+
+ /**
+ * Defines the message delegate for a Native App.
+ *
+ * This message delegate will receive messages from the background script
+ * for the native app specified in <code>nativeApp</code>.
+ *
+ * For messages from content scripts, set a session-specific message
+ * delegate using {@link SessionController#setMessageDelegate}.
+ *
+ * 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.
+ *
+ * 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) {
+ if (mDelegateController != null) {
+ 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)
+ @interface BrowsingDataTypes {}
+
+ /**
+ * This delegate is used to handle calls from the |browsingData| WebExtension API.
+ *
+ * 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.
+ *
+ * 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.
+ */
+ final public int sinceUnixTimestamp;
+ /**
+ * Data types that can be toggled in the browser's "Clear Data" UI.
+ * One or more flags from {@link Type}.
+ */
+ final public @BrowsingDataTypes long toggleableTypes;
+
+ /**
+ * Data types currently selected in the browser's "Clear Data" UI.
+ * One or more flags from {@link Type}.
+ */
+ final public @BrowsingDataTypes long selectedTypes;
+
+ /**
+ * Creates an instance of Settings.
+ *
+ * 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() {}
+ final public static long CACHE = 1 << 0;
+ final public static long COOKIES = 1 << 1;
+ final public static long DOWNLOADS = 1 << 2;
+ final public static long FORM_DATA = 1 << 3;
+ final public static long HISTORY = 1 << 4;
+ final public static long LOCAL_STORAGE = 1 << 5;
+ final public static 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.
+ *
+ * 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.
+ *
+ * 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 (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) {
+ GeckoBundle args = new GeckoBundle(1);
+ try {
+ args.putBundle("message", GeckoBundle.fromJSONObject(message));
+ } catch (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;
+ }
+
+ 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.
+ *
+ * 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 WebExtension source,
+ @NonNull 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.
+ *
+ * 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>.
+ *
+ * Whenever a field is not passed in by the extension that value will be <code>null</code>.
+ *
+ * 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>.
+ *
+ * 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 WebExtension source,
+ @NonNull 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 WebExtension source) {}
+ }
+
+ /**
+ * Get the tab delegate for this extension.
+ *
+ * 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.
+ *
+ * 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) {
+ if (mDelegateController != null) {
+ mDelegateController.onTabDelegate(delegate);
+ }
+ }
+
+ @UiThread
+ @Nullable
+ public BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mDelegateController.getBrowsingDataDelegate();
+ }
+
+ @UiThread
+ public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) {
+ if (mDelegateController != null) {
+ 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;
+ }
+
+ Sender o = (Sender) other;
+ return webExtensionId.equals(o.webExtensionId) &&
+ nativeApp.equals(o.nativeApp);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = 17;
+ result = 31 * result + (webExtensionId != null ? webExtensionId.hashCode() : 0);
+ result = 31 * result + (nativeApp != null ? nativeApp.hashCode() : 0);
+ return result;
+ }
+ }
+
+ // 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.
+ *
+ * If a delegate is already present, this delegate will replace the
+ * existing one.
+ *
+ * This message delegate will be responsible for handling messaging between
+ * a WebExtension content script running on the {@link GeckoSession}.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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 */ final static class Listener<TabDelegate> implements BundleEventListener {
+ final private HashMap<Sender, MessageDelegate> mMessageDelegates;
+ final private HashMap<String, ActionDelegate> mActionDelegates;
+ final private HashMap<String, BrowsingDataDelegate> mBrowsingDataDelegates;
+ final private HashMap<String, TabDelegate> mTabDelegates;
+ final private HashMap<String, DownloadDelegate> mDownloadDelegates;
+
+ final private GeckoSession mSession;
+ final private 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.
+ *
+ * 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})
+ /* package */ @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>
+ * <li>{@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent
+ * from a content script </li>
+ * </ul>
+ */
+ // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ?
+ public final @EnvType int environmentType;
+
+ /** URL of the frame that sent this message.
+ *
+ * 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 GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new WebExtension(bundle.getBundle("extension"));
+ }
+
+ /**
+ * Represents either a Browser Action or a Page Action from the
+ * WebExtension API.
+ *
+ * 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}.
+ *
+ * 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>
+ * <li><a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+ * WebExtensions/API/pageAction
+ * </a></li>
+ * </ul>
+ */
+ @AnyThread
+ public static class Action {
+ /**
+ * Title of this Action.
+ *
+ * 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>
+ */
+ final public @Nullable String title;
+ /**
+ * Icon for this Action.
+ *
+ * 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>
+ */
+ final public @Nullable Image icon;
+ /**
+ * URI of the Popup to display when the user taps on the icon for this
+ * Action.
+ *
+ * See also:
+ * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getPopup">
+ * pageAction/getPopup</a>,
+ * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getPopup">
+ * browserAction/getPopup</a>
+ */
+ final private @Nullable String mPopupUri;
+ /**
+ * Whether this action is enabled and should be visible.
+ *
+ * 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>.
+ *
+ * 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>
+ */
+ final public @Nullable Boolean enabled;
+ /**
+ * Badge text for this action.
+ *
+ * See also:
+ * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText">
+ * browserAction/getBadgeText</a>
+ */
+ final public @Nullable String badgeText;
+ /**
+ * Background color for the badge for this Action.
+ *
+ * This method will return an Android color int that can be used in
+ * {@link android.widget.TextView#setBackgroundColor(int)} and similar
+ * methods.
+ *
+ * See also:
+ * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor">
+ * browserAction/getBadgeBackgroundColor</a>
+ */
+ final public @Nullable Integer badgeBackgroundColor;
+ /**
+ * Text color for the badge for this Action.
+ *
+ * This method will return an Android color int that can be used in
+ * {@link android.widget.TextView#setTextColor(int)} and similar
+ * methods.
+ *
+ * See also:
+ * <a target=_blank href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor">
+ * browserAction/getBadgeTextColor</a>
+ */
+ final public @Nullable Integer badgeTextColor;
+
+ final private WebExtension mExtension;
+
+ /* package */ final static int TYPE_BROWSER_ACTION = 1;
+ /* package */ final static int TYPE_PAGE_ACTION = 2;
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION})
+ /* package */ @interface ActionType {}
+
+ /* package */ final @ActionType int type;
+
+ /* package */ Action(final @ActionType int type,
+ final GeckoBundle bundle, final WebExtension extension) {
+ mExtension = extension;
+ mPopupUri = bundle.getString("popup");
+
+ 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"
+ + "\tpopupUri: " + this.mPopupUri + ",\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;
+ mPopupUri = 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;
+ mPopupUri = source.mPopupUri != null ? source.mPopupUri : defaultValue.mPopupUri;
+ 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() {
+ if (mPopupUri != null && !mPopupUri.isEmpty()) {
+ final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate();
+ if (delegate == null) {
+ return;
+ }
+
+ GeckoResult<GeckoSession> popup = delegate.onTogglePopup(mExtension, this);
+ openPopup(popup);
+
+ // When popupUri is specified, the extension doesn't get a callback
+ return;
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", mExtension.id);
+
+ if (type == TYPE_BROWSER_ACTION) {
+ EventDispatcher.getInstance().dispatch(
+ "GeckoView:BrowserAction:Click", bundle);
+ } else if (type == TYPE_PAGE_ACTION) {
+ EventDispatcher.getInstance().dispatch(
+ "GeckoView:PageAction:Click", bundle);
+ } else {
+ throw new IllegalStateException("Unknown Action type");
+ }
+ }
+
+ /* package */ void openPopup(final GeckoResult<GeckoSession> popup) {
+ if (popup == null) {
+ return;
+ }
+
+ popup.accept(session -> {
+ if (session == null) {
+ return;
+ }
+
+ session.getSettings().setIsPopup(true);
+ session.loadUri(mPopupUri);
+ });
+ }
+ }
+
+ /**
+ * Receives updates whenever a Browser action or a Page action has been
+ * defined by an extension.
+ *
+ * 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.
+ *
+ * This method will be called whenever an extension that defines a
+ * browser action is registered or the properties of the Action are
+ * updated.
+ *
+ * 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.
+ *
+ * This method will be called whenever an extension that defines a page
+ * action is registered or the properties of the Action are updated.
+ *
+ * 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 extension did not have the expected ID. */
+ public static final int ERROR_INCORRECT_ID = -7;
+ /** 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_INCORRECT_ID,
+ ErrorCodes.ERROR_USER_CANCELED,
+ ErrorCodes.ERROR_POSTPONED,
+ })
+ /* package */ @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.
+ *
+ * This delegate will receive updates every time the default Action value
+ * changes.
+ *
+ * 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) {
+ if (mDelegateController != null) {
+ 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}.
+ *
+ * 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 final static int UNKNOWN = -1;
+ /** This extension is unsigned. */
+ public final static int MISSING = 0;
+ /** This extension has been preliminarily reviewed. */
+ public final static int PRELIMINARY = 1;
+ /** This extension has been fully reviewed. */
+ public final static int SIGNED = 2;
+ /** This extension is a system add-on. */
+ public final static int SYSTEM = 3;
+ /** This extension is signed with a "Mozilla Extensions" certificate. */
+ public final static int PRIVILEGED = 4;
+
+ /* package */ final static int LAST = PRIVILEGED;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ SignedStateFlags.UNKNOWN, SignedStateFlags.MISSING, SignedStateFlags.PRELIMINARY,
+ SignedStateFlags.SIGNED, SignedStateFlags.SYSTEM, SignedStateFlags.PRIVILEGED})
+ @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 final static int NOT_BLOCKED = 0;
+ /** This extension is in the blocklist but the problem is not severe
+ * enough to warant forcibly blocking. */
+ public final static int SOFTBLOCKED = 1;
+ /** This extension should be blocked and never used. */
+ public final static int BLOCKED = 2;
+ /** This extension is considered outdated, and there is a known update
+ * available. */
+ public final static int OUTDATED = 3;
+ /** This extension is vulnerable and there is an update. */
+ public final static int VULNERABLE_UPDATE_AVAILABLE = 4;
+ /** This extension is vulnerable and there is no update. */
+ public final static 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})
+ @interface BlocklistState {}
+
+ public static class DisabledFlags {
+ /** The extension has been disabled by the user */
+ public final static 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 final static 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 final static int APP = 1 << 3;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(flag = true,
+ value = { DisabledFlags.USER, DisabledFlags.BLOCKLIST,
+ DisabledFlags.APP })
+ @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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">
+ * Recommended Extensions program
+ * </a>
+ */
+ public final boolean isRecommended;
+ /** Blocklist status for this extension.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * This will be either equal to <code>0</code> if the extension
+ * is enabled or will contain one or more flags from {@link DisabledFlags}.
+ *
+ * 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);
+
+ 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
+
+ @IntDef(flag = true,
+ value = {Context.NONE, Context.BOOKMARK, Context.BROWSER_ACTION,
+ Context.PAGE_ACTION, Context.TAB, Context.TOOLS_MENU})
+
+ /* package */ @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.
+ *
+ * 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", "");
+ GeckoBundle[] items = bundle.getBundleArray("items");
+ this.items = new ArrayList<>();
+ if (items != null) {
+ for (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.
+ *
+ * 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 {
+
+ @IntDef(flag = false,
+ value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR})
+
+ /* package */ @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.
+ *
+ * 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 Download} instance
+ */
+ @AnyThread
+ @Nullable
+ default GeckoResult<WebExtension.Download> onDownload(@NonNull WebExtension source, @NonNull 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.
+ *
+ * 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) {
+ if (mDelegateController != null) {
+ mDelegateController.onDownloadDelegate(delegate);
+ }
+ }
+
+ /**
+ * Get the download delegate for this extension.
+ *
+ * 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 @NonNull 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) { }
+
+ /* package */ GeckoResult<Void> update(final DownloadInfo data) {
+ return null;
+ }
+
+ /* package */ interface Delegate {
+
+ default GeckoResult<Void> onPause(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onResume(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onCancel(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onErase(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onOpen(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onRemoveFile(WebExtension source, WebExtension.Download download) {
+ return null;
+ }
+ }
+
+ /* package */ interface DownloadInfo {
+ @IntDef(flag = true, value = { IN_PROGRESS, INTERRUPTED, COMPLETE })
+ /* package */ @interface DownloadStatusFlags {};
+
+ /**
+ * The app is currently receiving download data from the server.
+ */
+ /* package */ static final int IN_PROGRESS = 0;
+
+ /**
+ * An error broke the connection with the server.
+ */
+ /* package */ static final int INTERRUPTED = 1;
+
+ /**
+ * The download completed successfully.
+ */
+ /* package */ static final int COMPLETE = 1 << 1;
+
+ /**
+ * @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
+ */
+ default boolean paused() {
+ return false;
+ }
+
+ /**
+ * @return Date (in ISO 8601 format) representing
+ * the estimated number of milliseconds between the UNIX epoch
+ * and when this download is estimated to be completed
+ */
+ default Date estimatedEndTime() {
+ return null;
+ }
+
+ /**
+ * @return boolean indicating whether a currently-interrupted
+ * (e.g. paused) download can be resumed from the point where it was interrupted
+ */
+ default boolean canResume() {
+ return false;
+ }
+
+ /**
+ * @return number of bytes received so far from the host during the download;
+ * this does not take file compression into consideration
+ */
+ default long bytesReceived() {
+ return 0;
+ }
+
+ /**
+ * @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
+ */
+ default long totalBytes() {
+ return 0;
+ }
+
+ /**
+ * @return Date representing the number of milliseconds between
+ * the UNIX epoch and when this download ended.
+ * This is null if the download has not yet finished
+ */
+ default Date endTime() {
+ return null;
+ }
+
+ /**
+ * @return boolean indicating whether a downloaded file still exists
+ */
+ default boolean fileExists() {
+ return false;
+ }
+
+ /**
+ * @return one of {@link DownloadStatusFlags} to indicate
+ * whether the download is in progress, interrupted or complete
+ */
+ default @DownloadStatusFlags int status() {
+ return 0;
+ }
+ }
+ }
+
+ /**
+ * 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;
+
+ @IntDef(flag = true, value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT})
+ /* package */ @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");
+
+ WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri);
+
+ String method = optionsBundle.getString("method");
+ if (method != null) {
+ mainRequestBuilder.method(method);
+
+ if (method.equals("POST")) {
+ String body = optionsBundle.getString("body");
+ mainRequestBuilder.body(body);
+ }
+ }
+
+ GeckoBundle[] headers = optionsBundle.getBundleArray("headers");
+ if (headers != null) {
+ for (GeckoBundle header : headers) {
+ String value = header.getString("value");
+ if (value == null) {
+ value = header.getString("binaryValue");
+ }
+ mainRequestBuilder.addHeader(header.getString("name"), value);
+ }
+ }
+
+ WebRequest mainRequest = mainRequestBuilder.build();
+
+ int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE;
+ boolean incognito = optionsBundle.getBoolean("incognito");
+ if (incognito) {
+ downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE;
+ }
+
+ boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors");
+
+ int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY;
+ 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;
+ }
+ }
+
+ boolean saveAs = optionsBundle.getBoolean("saveAs");
+
+ 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);
+ }
+ }
+ }
+}
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..8f0c64ed34
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
@@ -0,0 +1,1286 @@
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+import android.os.Build;
+import android.util.Log;
+
+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;
+
+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;
+
+public class WebExtensionController {
+ private final static 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 HashMap<Integer, 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 {
+ final private 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
+ */
+ void onNewExtension(final WebExtension 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(WebExtension::fromBundle)
+ .map(ext -> {
+ mData.put(ext.id, ext);
+ mObserver.onNewExtension(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 void onNewExtension(final WebExtension extension) {
+ extension.setDelegateController(new DelegateController(extension));
+ }
+ }
+
+ /* 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);
+ }
+ }
+
+ /**
+ * 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 WebExtension currentlyInstalled,
+ @NonNull WebExtension updatedExtension,
+ @NonNull String[] newPermissions,
+ @NonNull String[] newOrigins) {
+ return null;
+ }
+
+ /*
+ TODO: Bug 1601420
+ default GeckoResult<AllowOrDeny> onOptionalPrompt(
+ WebExtension extension,
+ String[] optionalPermissions) {
+ return null;
+ } */
+ }
+
+ public interface DebuggerDelegate {
+ /**
+ * Called whenever the list of installed extensions has been modified using the debugger
+ * with tools like web-ext.
+ *
+ * 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.
+ *
+ * An installed extension will persist and will be available even when restarting the
+ * {@link GeckoRuntime}.
+ *
+ * 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>.
+ *
+ * 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}.
+ *
+ * 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(WebExtension::fromBundle,
+ 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(WebExtension::fromBundle)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Install a built-in extension.
+ *
+ * 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.
+ *
+ * Example: <p><code>
+ * controller.installBuiltIn("resource://android/assets/example/");
+ * </code></p>
+ *
+ * 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(WebExtension::fromBundle,
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Ensure that a built-in extension is installed.
+ *
+ * Similar to {@link #installBuiltIn}, except the extension is not re-installed if
+ * it's already present and it has the same version.
+ *
+ * Example: <p><code>
+ * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com");
+ * </code></p>
+ *
+ * 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(WebExtension::fromBundle,
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Uninstall an extension.
+ *
+ * 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 })
+ @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 final static 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 final static 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(WebExtension::fromBundle)
+ .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(WebExtension::fromBundle)
+ .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 (GeckoBundle bundle : bundles) {
+ final WebExtension extension = new WebExtension(bundle);
+ list.add(registerWebExtension(extension));
+ }
+
+ return list;
+ }
+
+ /**
+ * List installed extensions for this {@link GeckoRuntime}.
+ *
+ * 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.
+ *
+ * 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.
+ *
+ * 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(WebExtension::fromBundle,
+ 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 HashMap<>();
+ }
+
+ /* package */ WebExtension registerWebExtension(final WebExtension webExtension) {
+ if (webExtension != null) {
+ webExtension.setDelegateController(new DelegateController(webExtension));
+ 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;
+ }
+
+ final GeckoBundle senderBundle;
+ if ("GeckoView:WebExtension:Connect".equals(event) ||
+ "GeckoView:WebExtension:Message".equals(event)) {
+ senderBundle = bundle.getBundle("sender");
+ } else {
+ senderBundle = bundle;
+ }
+
+ extensionFromBundle(senderBundle).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;
+ }
+ final String nativeApp = bundle.getString("nativeApp");
+ if (nativeApp == null) {
+ if (BuildConfig.DEBUG) {
+ throw new RuntimeException("Missing required nativeApp message parameter.");
+ }
+ callback.sendError("Missing nativeApp parameter.");
+ return;
+ }
+
+ final WebExtension.MessageSender sender = fromBundle(extension, senderBundle, session);
+ if (sender == null) {
+ if (callback != null) {
+ if (BuildConfig.DEBUG) {
+ try {
+ Log.e(LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject());
+ } catch (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) {
+ throw new RuntimeException("Missing webExtensionId or locationURI");
+ }
+
+ Log.e(LOGTAG, "Missing webExtensionId or locationURI");
+ return;
+ }
+
+ final WebExtension extension = new WebExtension(extensionBundle);
+ extension.setDelegateController(new DelegateController(extension));
+
+ 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) {
+ throw new RuntimeException("Missing bundle");
+ }
+
+ Log.e(LOGTAG, "Missing bundle");
+ return;
+ }
+
+ final WebExtension currentExtension = new WebExtension(currentBundle);
+ currentExtension.setDelegateController(new DelegateController(currentExtension));
+
+ final WebExtension updatedExtension = new WebExtension(updatedBundle);
+ updatedExtension.setDelegateController(new DelegateController(updatedExtension));
+
+ 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 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");
+
+ WebExtension.DownloadRequest request = WebExtension.DownloadRequest.fromBundle(optionsBundle);
+
+ GeckoResult<WebExtension.Download> 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 id");
+ throw new IllegalArgumentException("downloads.download is not supported");
+ }
+ return value.id;
+ }));
+ }
+
+ /* 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 */ 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(null);
+ return;
+ }
+
+ message.callback.resolveTo(result.map(session -> {
+ if (session == null) {
+ return null;
+ }
+
+ if (session.isOpen()) {
+ throw new IllegalArgumentException("Must use an unopened GeckoSession instance");
+ }
+
+ session.open(mListener.runtime);
+
+ return session.getId();
+ }));
+ }
+
+ /* 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);
+ webExtension.setDelegateController(null);
+ 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 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) {
+ throw new RuntimeException("Missing or unknown envType.");
+ }
+
+ return null;
+ }
+
+ final String url = sender.getString("url");
+ boolean isTopLevel;
+ if (session == null) {
+ // This message is coming from the background 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.
+ if (!sender.containsKey("frameId") || !sender.containsKey("url") ||
+ // -1 is an invalid frame id
+ sender.getInt("frameId", -1) == -1) {
+ if (BuildConfig.DEBUG) {
+ throw new RuntimeException("Missing sender information.");
+ }
+
+ // 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 {
+ final public String webExtensionId;
+ final public String nativeApp;
+ final public 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 (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 WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+ action.openPopup(popup);
+ }
+
+ 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.containsKey(id)) {
+ throw new IllegalArgumentException("Download with this id already exists");
+ } else {
+ 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..390723d868
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
@@ -0,0 +1,131 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+
+import java.nio.ByteBuffer;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * 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() {
+ String[] keys = new String[headers.size()];
+ headers.keySet().toArray(keys);
+ return keys;
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderValues() {
+ String[] values = new String[headers.size()];
+ headers.values().toArray(values);
+ return values;
+ }
+
+ /**
+ * This is a Builder used by subclasses of {@link WebMessage}.
+ */
+ @AnyThread
+ public static abstract 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.
+ *
+ * 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.
+ *
+ * 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..daac8e6486
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
@@ -0,0 +1,124 @@
+package org.mozilla.geckoview;
+
+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 {
+
+ /**
+ * 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).
+ *
+ * TODO: make NonNull once we have Bug 1589693
+ */
+ public final @Nullable String source;
+
+ @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) {
+ 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 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);
+ }
+}
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..f78e30bc8c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
@@ -0,0 +1,27 @@
+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 WebNotification notification) {}
+
+ /**
+ * This is called when an existing notification is closed.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onCloseNotification(@NonNull 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..47f9dbdc9b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
@@ -0,0 +1,141 @@
+/* -*- 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 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;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import android.util.Log;
+
+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;
+ }
+
+ /**
+ * 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..0514272865
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
@@ -0,0 +1,61 @@
+/* -*- 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.
+ *
+ * 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 String scope,
+ @Nullable 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 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 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..46f5aae063
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
@@ -0,0 +1,176 @@
+/* -*- 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 org.mozilla.gecko.util.GeckoBundle;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+
+/**
+ * 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.
+ *
+ * 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>.
+ *
+ * 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.
+ *
+ * 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..46aa2469f6
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
@@ -0,0 +1,239 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+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;
+
+/**
+ * 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;
+
+ /**
+ * 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})
+ /* package */ @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;
+
+ 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;
+
+ /**
+ * 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;
+ }
+ CharBuffer chars = CharBuffer.wrap(bodyString);
+ 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;
+ }
+
+ /**
+ * @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..043bded603
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
@@ -0,0 +1,392 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+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;
+
+/**
+ * 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})
+ /* package */ @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})
+ /* package */ @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;
+
+ // 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;
+
+ // 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 (category << 16) + code;
+ }
+
+ @WrapForJNI
+ /* package */ static WebRequestError fromGeckoError(final long geckoError,
+ final int geckoErrorModule,
+ final int geckoErrorClass,
+ final byte[] certificateBytes) {
+ int code = convertGeckoError(geckoError, geckoErrorModule, geckoErrorClass);
+ int category = getErrorCategory(geckoErrorModule, code);
+ X509Certificate certificate = null;
+ if (certificateBytes != null) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ certificate = (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certificateBytes));
+ } catch (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) {
+ // Match flags with XPCOM ErrorList.h.
+ if (errorModule == 21) {
+ return ERROR_CATEGORY_SECURITY;
+ }
+ return error & 0xF;
+ }
+
+ @WrapForJNI
+ /* package */ static @Error int convertGeckoError(
+ final long geckoError, final int geckoErrorModule, final int geckoErrorClass) {
+ // Match flags with XPCOM ErrorList.h.
+ // safebrowsing
+ if (geckoError == 0x805D001FL) {
+ return ERROR_SAFEBROWSING_PHISHING_URI;
+ }
+ if (geckoError == 0x805D001EL) {
+ return ERROR_SAFEBROWSING_MALWARE_URI;
+ }
+ if (geckoError == 0x805D0023L) {
+ return ERROR_SAFEBROWSING_UNWANTED_URI;
+ }
+ if (geckoError == 0x805D0026L) {
+ return ERROR_SAFEBROWSING_HARMFUL_URI;
+ }
+ // content
+ if (geckoError == 0x805E0010L) {
+ return ERROR_CONTENT_CRASHED;
+ }
+ if (geckoError == 0x804B001BL) {
+ return ERROR_INVALID_CONTENT_ENCODING;
+ }
+ if (geckoError == 0x804B004AL) {
+ return ERROR_UNSAFE_CONTENT_TYPE;
+ }
+ if (geckoError == 0x804B001DL) {
+ return ERROR_CORRUPTED_CONTENT;
+ }
+ // network
+ if (geckoError == 0x804B0014L) {
+ return ERROR_NET_RESET;
+ }
+ if (geckoError == 0x804B0047L) {
+ return ERROR_NET_INTERRUPT;
+ }
+ if (geckoError == 0x804B000EL) {
+ return ERROR_NET_TIMEOUT;
+ }
+ if (geckoError == 0x804B000DL) {
+ return ERROR_CONNECTION_REFUSED;
+ }
+ if (geckoError == 0x804B0033L) {
+ return ERROR_UNKNOWN_SOCKET_TYPE;
+ }
+ if (geckoError == 0x804B001FL) {
+ return ERROR_REDIRECT_LOOP;
+ }
+ if (geckoError == 0x804B0010L) {
+ return ERROR_OFFLINE;
+ }
+ if (geckoError == 0x804B0013L) {
+ return ERROR_PORT_BLOCKED;
+ }
+ // uri
+ if (geckoError == 0x804B0012L) {
+ return ERROR_UNKNOWN_PROTOCOL;
+ }
+ if (geckoError == 0x804B001EL) {
+ return ERROR_UNKNOWN_HOST;
+ }
+ if (geckoError == 0x804B000AL) {
+ return ERROR_MALFORMED_URI;
+ }
+ if (geckoError == 0x80520012L) {
+ return ERROR_FILE_NOT_FOUND;
+ }
+ if (geckoError == 0x80520015L) {
+ return ERROR_FILE_ACCESS_DENIED;
+ }
+ // proxy
+ if (geckoError == 0x804B002AL) {
+ return ERROR_UNKNOWN_PROXY_HOST;
+ }
+ if (geckoError == 0x804B0048L) {
+ return ERROR_PROXY_CONNECTION_REFUSED;
+ }
+
+ if (geckoErrorModule == 21) {
+ 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..dbc981b5fd
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
@@ -0,0 +1,198 @@
+/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI;
+
+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;
+
+/**
+ * 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}.
+ *
+ * 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 (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..1454a18019
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md
@@ -0,0 +1,880 @@
+---
+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
+
+## 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`] abd
+ [`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:A-
+[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:A-
+[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:A-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:A-
+[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]: 437ce82f72ccd40f18d7b7e6f5c0f7e1f6645c02
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..6ecebd65b5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java
@@ -0,0 +1,48 @@
+/* -*- 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>
+ *
+ * <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>
+ *
+ * <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.
+ * </li>
+ * </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>
+ * <li>{@link android.Manifest.permission#ACCESS_FINE_LOCATION}</li>
+ * <li>{@link android.Manifest.permission#READ_EXTERNAL_STORAGE}</li>
+ * <li>{@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE}</li>
+ * <li>{@link android.Manifest.permission#CAMERA}</li>
+ * <li>{@link android.Manifest.permission#RECORD_AUDIO}</li>
+ * </ul>
+ *
+ * For a detailed change log of the API see: <a href="./doc-files/CHANGELOG" target="_blank">CHANGELOG</a>.
+ */
+package org.mozilla.geckoview;
diff --git a/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml b/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml
new file mode 100644
index 0000000000..29b19541b2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml
@@ -0,0 +1,11 @@
+<!-- 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/. -->
+
+<vector android:height="24dp" android:viewportHeight="40"
+ android:viewportWidth="40" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#FFFFFF" android:pathData="M6.5,37.5l0,-35l18.293,0l8.707,8.707l0,26.293z"/>
+ <path android:fillColor="#788B9C" android:pathData="M24.586,3L33,11.414V37H7V3H24.586M25,2H6v36h28V11L25,2L25,2z"/>
+ <path android:fillColor="#FFFFFF" android:pathData="M24.5,11.5l0,-9l0.293,0l8.707,8.707l0,0.293z"/>
+ <path android:fillColor="#788B9C" android:pathData="M25,3.414L32.586,11H25V3.414M25,2h-1v10h10v-1L25,2L25,2z"/>
+</vector>