From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../geckoview/src/androidTest/AndroidManifest.xml | 51 + .../geckoview/src/androidTest/assets/moz.build | 78 + .../androidTest/assets/web_extensions/.eslintrc.js | 14 + .../assets/web_extensions/actions/background.js | 190 + .../actions/button/beasts-32-light.png | Bin 0 -> 1395 bytes .../web_extensions/actions/button/beasts-32.png | Bin 0 -> 1093 bytes .../web_extensions/actions/button/expected.png | Bin 0 -> 1074 bytes .../web_extensions/actions/button/geo-19.png | Bin 0 -> 225 bytes .../web_extensions/actions/button/geo-38.png | Bin 0 -> 225 bytes .../assets/web_extensions/actions/button/icon.svg | 1 + .../assets/web_extensions/actions/content.js | 4 + .../assets/web_extensions/actions/manifest.json | 43 + .../actions/test-open-popup-browser-action.html | 14 + .../actions/test-open-popup-browser-action.js | 7 + .../actions/test-open-popup-page-action.html | 14 + .../actions/test-open-popup-page-action.js | 7 + .../actions/test-popup-messaging.html | 9 + .../web_extensions/actions/test-popup-messaging.js | 24 + .../assets/web_extensions/actions/test-popup.html | 9 + .../assets/web_extensions/actions/test-popup.js | 3 + .../assets/web_extensions/borderify-missing-id.xpi | Bin 0 -> 1827 bytes .../assets/web_extensions/borderify-unsigned.xpi | Bin 0 -> 1882 bytes .../assets/web_extensions/borderify.xpi | Bin 0 -> 9221 bytes .../assets/web_extensions/borderify/borderify.js | 1 + .../web_extensions/borderify/icons/border-48.png | Bin 0 -> 225 bytes .../assets/web_extensions/borderify/icons/icon.svg | 1 + .../assets/web_extensions/borderify/manifest.json | 20 + .../browsing-data-built-in/background.js | 44 + .../browsing-data-built-in/manifest.json | 15 + .../web_extensions/browsing-data/background.js | 8 + .../web_extensions/browsing-data/manifest.json | 15 + .../download-flags-false/download.js | 3 + .../download-flags-false/manifest.json | 15 + .../web_extensions/download-flags-true/download.js | 16 + .../download-flags-true/manifest.json | 15 + .../web_extensions/download-onChanged/download.js | 18 + .../download-onChanged/manifest.json | 15 + .../androidTest/assets/web_extensions/dummy.xpi | Bin 0 -> 544 bytes .../assets/web_extensions/dummy/dummy.js | 1 + .../assets/web_extensions/dummy/manifest.json | 21 + .../extension-page-restore/manifest.json | 11 + .../extension-page-restore/tab-script.js | 5 + .../web_extensions/extension-page-restore/tab.html | 10 + .../extension-page-update/background-script.js | 7 + .../extension-page-update/manifest.json | 21 + .../extension-page-update/tab-script.js | 2 + .../web_extensions/extension-page-update/tab.html | 10 + .../web_extensions/extension-page-update/tabs.js | 1 + .../web_extensions/messaging-content/manifest.json | 22 + .../web_extensions/messaging-content/messaging.js | 29 + .../web_extensions/messaging-iframe/manifest.json | 23 + .../web_extensions/messaging-iframe/messaging.js | 11 + .../assets/web_extensions/messaging/background.js | 28 + .../web_extensions/messaging/icons/border-48.png | Bin 0 -> 225 bytes .../assets/web_extensions/messaging/manifest.json | 18 + .../web_extensions/notification-test/background.js | 6 + .../web_extensions/notification-test/manifest.json | 15 + .../web_extensions/openoptionspage-1/background.js | 1 + .../web_extensions/openoptionspage-1/manifest.json | 20 + .../web_extensions/openoptionspage-2/background.js | 1 + .../web_extensions/openoptionspage-2/manifest.json | 20 + .../web_extensions/page-history/manifest.json | 11 + .../assets/web_extensions/page-history/page.html | 9 + .../clickToRequestPermission.html | 11 + .../permission-request/manifest.json | 13 + .../permission-request/request-permission.js | 11 + .../redirect-to-android-resource/background.js | 39 + .../redirect-to-android-resource/manifest.json | 25 + .../web-accessible-script.js | 3 + .../tabs-activate-remove-2/background.js | 16 + .../tabs-activate-remove-2/manifest.json | 15 + .../tabs-activate-remove/background.js | 16 + .../tabs-activate-remove/manifest.json | 15 + .../web_extensions/tabs-create-2/background.js | 4 + .../web_extensions/tabs-create-2/manifest.json | 15 + .../tabs-create-remove/background.js | 3 + .../tabs-create-remove/manifest.json | 15 + .../web_extensions/tabs-create/background.js | 1 + .../web_extensions/tabs-create/manifest.json | 15 + .../web_extensions/tabs-remove/background.js | 3 + .../web_extensions/tabs-remove/manifest.json | 15 + .../test-support/TestSupportChild.jsm | 83 + .../test-support/TestSupportProcessChild.jsm | 26 + .../web_extensions/test-support/background.js | 118 + .../web_extensions/test-support/manifest.json | 41 + .../assets/web_extensions/test-support/test-api.js | 229 + .../web_extensions/test-support/test-schema.json | 264 + .../web_extensions/test-support/test-support.js | 48 + .../assets/web_extensions/update-1/borderify.js | 1 + .../assets/web_extensions/update-1/manifest.json | 18 + .../assets/web_extensions/update-2/borderify.js | 1 + .../assets/web_extensions/update-2/manifest.json | 17 + .../web_extensions/update-postpone-1/background.js | 3 + .../web_extensions/update-postpone-1/borderify.js | 1 + .../web_extensions/update-postpone-1/manifest.json | 21 + .../web_extensions/update-postpone-2/borderify.js | 1 + .../web_extensions/update-postpone-2/manifest.json | 17 + .../update-with-perms-1/borderify.js | 1 + .../update-with-perms-1/manifest.json | 18 + .../update-with-perms-2/borderify.js | 1 + .../update-with-perms-2/manifest.json | 18 + .../www/accessibility/test-aria-comboboxes.html | 11 + .../assets/www/accessibility/test-checkbox.html | 12 + .../assets/www/accessibility/test-clipboard.html | 9 + .../assets/www/accessibility/test-collection.html | 21 + .../assets/www/accessibility/test-expandable.html | 13 + .../assets/www/accessibility/test-headings.html | 11 + .../assets/www/accessibility/test-links.html | 12 + .../www/accessibility/test-live-region-atomic.html | 12 + .../accessibility/test-live-region-descendant.html | 9 + .../test-live-region-image-labeled-by.html | 15 + .../www/accessibility/test-live-region-image.html | 15 + .../assets/www/accessibility/test-live-region.html | 9 + .../www/accessibility/test-local-iframe.html | 21 + .../test-move-caret-accessibility-focus.html | 9 + .../assets/www/accessibility/test-mutation.html | 9 + .../assets/www/accessibility/test-range.html | 23 + .../www/accessibility/test-remote-iframe.html | 24 + .../assets/www/accessibility/test-scroll.html | 10 + .../assets/www/accessibility/test-selectable.html | 22 + .../www/accessibility/test-text-entry-node.html | 11 + .../assets/www/accessibility/test-tree.html | 10 + .../src/androidTest/assets/www/address_form.html | 21 + .../src/androidTest/assets/www/audio/owl.mp3 | Bin 0 -> 67430 bytes .../src/androidTest/assets/www/autoplay.html | 11 + .../src/androidTest/assets/www/badVideoPath.html | 11 + .../src/androidTest/assets/www/beforeunload.html | 15 + .../src/androidTest/assets/www/cc_form.html | 22 + .../src/androidTest/assets/www/clickToReload.html | 10 + .../src/androidTest/assets/www/clipboard_read.html | 22 + .../src/androidTest/assets/www/color_grid.html | 40 + .../assets/www/color_orange_background.html | 29 + .../src/androidTest/assets/www/colors.html | 23 + .../androidTest/assets/www/context_menu_audio.html | 20 + .../assets/www/context_menu_blob_buffered.html | 44 + .../assets/www/context_menu_blob_full.html | 22 + .../androidTest/assets/www/context_menu_image.html | 10 + .../assets/www/context_menu_image_nested.html | 14 + .../androidTest/assets/www/context_menu_link.html | 15 + .../androidTest/assets/www/context_menu_video.html | 12 + .../src/androidTest/assets/www/data_uri.html | 14 + .../src/androidTest/assets/www/download.html | 18 + .../src/androidTest/assets/www/fixedbottom.html | 36 + .../src/androidTest/assets/www/fixedpercent.html | 25 + .../src/androidTest/assets/www/fixedvh.html | 25 + .../src/androidTest/assets/www/form_blank.html | 20 + .../src/androidTest/assets/www/forms.html | 34 + .../src/androidTest/assets/www/forms2.html | 17 + .../src/androidTest/assets/www/forms2_iframe.html | 16 + .../src/androidTest/assets/www/forms3.html | 14 + .../src/androidTest/assets/www/forms4.html | 14 + .../src/androidTest/assets/www/forms5.html | 24 + .../androidTest/assets/www/forms_autocomplete.html | 16 + .../assets/www/forms_autocomplete_iframe.html | 15 + .../src/androidTest/assets/www/forms_id_value.html | 12 + .../src/androidTest/assets/www/forms_iframe.html | 58 + .../src/androidTest/assets/www/forms_xorigin.html | 77 + .../src/androidTest/assets/www/fullscreen.html | 9 + .../assets/www/getusermedia_xorigin_container.html | 58 + .../assets/www/getusermedia_xorigin_iframe.html | 39 + .../src/androidTest/assets/www/hello.html | 10 + .../src/androidTest/assets/www/hello2.html | 9 + .../src/androidTest/assets/www/helloPDFWorld.pdf | Bin 0 -> 10414 bytes .../src/androidTest/assets/www/hsts_header.sjs | 6 + .../src/androidTest/assets/www/hungScript.html | 16 + .../iframe_100_percent_height_no_scrollable.html | 60 + .../www/iframe_100_percent_height_scrollable.html | 60 + .../assets/www/iframe_98vh_no_scrollable.html | 55 + .../assets/www/iframe_98vh_scrollable.html | 55 + .../src/androidTest/assets/www/iframe_hello.html | 10 + .../androidTest/assets/www/iframe_http_only.html | 14 + .../assets/www/iframe_redirect_automation.html | 12 + .../assets/www/iframe_redirect_local.html | 10 + .../assets/www/iframe_unknown_protocol.html | 10 + .../src/androidTest/assets/www/images/test.gif | Bin 0 -> 23961 bytes .../src/androidTest/assets/www/inputs.html | 66 + .../src/androidTest/assets/www/links.html | 28 + .../src/androidTest/assets/www/loremIpsum.html | 17 + .../androidTest/assets/www/manifest.webmanifest | 17 + .../assets/www/media_session_default1.html | 15 + .../androidTest/assets/www/media_session_dom1.html | 109 + .../src/androidTest/assets/www/metatags.html | 19 + .../src/androidTest/assets/www/mouseToReload.html | 10 + .../geckoview/src/androidTest/assets/www/mp4.html | 11 + .../src/androidTest/assets/www/newSession.html | 22 + .../androidTest/assets/www/newSession_child.html | 9 + .../androidTest/assets/www/no-meta-viewport.html | 5 + .../geckoview/src/androidTest/assets/www/ogg.html | 11 + .../assets/www/overscroll-behavior-auto-none.html | 28 + .../assets/www/overscroll-behavior-auto.html | 28 + .../assets/www/overscroll-behavior-none-auto.html | 28 + .../www/overscroll-behavior-none-on-non-root.html | 37 + .../src/androidTest/assets/www/popup.html | 12 + .../assets/www/print_content_change.html | 37 + .../src/androidTest/assets/www/print_iframe.html | 39 + .../src/androidTest/assets/www/prompts.html | 31 + .../src/androidTest/assets/www/push/push.html | 10 + .../src/androidTest/assets/www/push/push.js | 44 + .../src/androidTest/assets/www/push/sw.js | 30 + ...ground-body-fully-covered-by-green-element.html | 23 + .../www/reflect_local_storage_into_title.html | 17 + .../src/androidTest/assets/www/resubmit.html | 12 + .../assets/www/root_100_percent_height.html | 37 + .../src/androidTest/assets/www/root_100vh.html | 36 + .../src/androidTest/assets/www/root_98vh.html | 36 + .../src/androidTest/assets/www/saveState.html | 18 + .../src/androidTest/assets/www/scroll-handoff.html | 35 + .../src/androidTest/assets/www/scroll.html | 59 + .../src/androidTest/assets/www/select-listbox.html | 7 + .../androidTest/assets/www/select-multiple.html | 7 + .../src/androidTest/assets/www/select.html | 6 + .../assets/www/selectionAction_frame.html | 6 + .../assets/www/selectionAction_frame_xorigin.html | 41 + .../androidTest/assets/www/showDynamicToolbar.html | 96 + .../src/androidTest/assets/www/simple_redirect.sjs | 4 + .../src/androidTest/assets/www/titleChange.html | 16 + .../assets/www/touch-action-wheel-listener.html | 33 + .../src/androidTest/assets/www/touch-action.html | 48 + .../src/androidTest/assets/www/touch.html | 58 + .../src/androidTest/assets/www/touch_xorigin.html | 16 + .../src/androidTest/assets/www/touchstart.html | 37 + .../src/androidTest/assets/www/tracemonkey.pdf | Bin 0 -> 178030 bytes .../src/androidTest/assets/www/trackers.html | 14 + .../src/androidTest/assets/www/transparent.gif | Bin 0 -> 43 bytes .../androidTest/assets/www/update_manifest.json | 40 + .../src/androidTest/assets/www/videos/gizmo.webm | Bin 0 -> 159035 bytes .../src/androidTest/assets/www/videos/short.mp4 | Bin 0 -> 13651 bytes .../src/androidTest/assets/www/videos/video.ogg | Bin 0 -> 285310 bytes .../src/androidTest/assets/www/viewport.html | 19 + .../geckoview/src/androidTest/assets/www/webm.html | 11 + .../androidTest/assets/www/worker/open_window.html | 10 + .../androidTest/assets/www/worker/open_window.js | 15 + .../assets/www/worker/open_window_target.html | 9 + .../assets/www/worker/service-worker.js | 15 + .../android/view/inputmethod/CursorAnchorInfo.java | 14 + .../mozilla/geckoview/GeckoInputStreamTest.java | 167 + .../mozilla/geckoview/test/AccessibilityTest.kt | 2275 +++++++ .../org/mozilla/geckoview/test/AutocompleteTest.kt | 2532 +++++++ .../mozilla/geckoview/test/AutofillDelegateTest.kt | 715 ++ .../org/mozilla/geckoview/test/BaseSessionTest.kt | 297 + .../test/ContentBlockingControllerTest.kt | 302 + .../org/mozilla/geckoview/test/ContentCrashTest.kt | 51 + .../geckoview/test/ContentDelegateChildTest.kt | 278 + .../test/ContentDelegateMultipleSessionsTest.kt | 161 + .../mozilla/geckoview/test/ContentDelegateTest.kt | 660 ++ .../java/org/mozilla/geckoview/test/DisplayTest.kt | 23 + .../mozilla/geckoview/test/DynamicToolbarTest.kt | 727 ++ .../mozilla/geckoview/test/ExtensionActionTest.kt | 878 +++ .../java/org/mozilla/geckoview/test/FinderTest.kt | 456 ++ .../mozilla/geckoview/test/GeckoAppShellTest.kt | 120 + .../mozilla/geckoview/test/GeckoResultTest.java | 673 ++ .../org/mozilla/geckoview/test/GeckoResultTest.kt | 37 + .../geckoview/test/GeckoSessionTestRuleTest.kt | 2114 ++++++ .../org/mozilla/geckoview/test/GeckoViewTest.kt | 462 ++ .../geckoview/test/GeckoViewTestActivity.java | 21 + .../org/mozilla/geckoview/test/GeolocationTest.kt | 294 + .../org/mozilla/geckoview/test/GpuCrashTest.kt | 63 + .../mozilla/geckoview/test/HistoryDelegateTest.kt | 303 + .../mozilla/geckoview/test/ImageResourceTest.kt | 306 + .../geckoview/test/InputResultDetailTest.kt | 417 ++ .../java/org/mozilla/geckoview/test/LocaleTest.kt | 43 + .../mozilla/geckoview/test/MediaDelegateTest.kt | 177 + .../geckoview/test/MediaDelegateXOriginTest.kt | 197 + .../org/mozilla/geckoview/test/MediaSessionTest.kt | 1031 +++ .../org/mozilla/geckoview/test/MultiMapTest.java | 213 + .../geckoview/test/NavigationDelegateTest.kt | 3126 +++++++++ .../java/org/mozilla/geckoview/test/NimbusTest.kt | 35 + .../org/mozilla/geckoview/test/OpenWindowTest.kt | 145 + .../geckoview/test/OrientationDelegateTest.kt | 311 + .../geckoview/test/PanZoomControllerTest.kt | 613 ++ .../org/mozilla/geckoview/test/PdfCreationTest.kt | 159 + .../java/org/mozilla/geckoview/test/PdfSaveTest.kt | 30 + .../geckoview/test/PermissionDelegateTest.kt | 1132 ++++ .../mozilla/geckoview/test/PrintDelegateTest.kt | 255 + .../org/mozilla/geckoview/test/PrivateModeTest.kt | 105 + .../mozilla/geckoview/test/ProfileLockedTest.kt | 52 + .../geckoview/test/ProfilerControllerTest.kt | 45 + .../mozilla/geckoview/test/ProgressDelegateTest.kt | 582 ++ .../mozilla/geckoview/test/PromptDelegateTest.kt | 1084 +++ .../mozilla/geckoview/test/RuntimeSettingsTest.kt | 253 + .../org/mozilla/geckoview/test/ScreenshotTest.kt | 433 ++ .../geckoview/test/SelectionActionDelegateTest.kt | 913 +++ .../mozilla/geckoview/test/SessionLifecycleTest.kt | 240 + .../geckoview/test/StorageControllerTest.kt | 874 +++ .../org/mozilla/geckoview/test/TelemetryTest.kt | 131 + .../geckoview/test/TemporaryProfileRule.java | 35 + .../geckoview/test/TestContentProvider.java | 103 + .../mozilla/geckoview/test/TestCrashHandler.java | 281 + .../mozilla/geckoview/test/TestRuntimeService.java | 404 ++ .../geckoview/test/TextInputDelegateTest.kt | 1407 ++++ .../geckoview/test/TrackingPermissionService.java | 119 + .../mozilla/geckoview/test/VerticalClippingTest.kt | 88 + .../org/mozilla/geckoview/test/WebExecutorTest.kt | 545 ++ .../org/mozilla/geckoview/test/WebExtensionTest.kt | 2989 ++++++++ .../mozilla/geckoview/test/WebNotificationTest.kt | 386 ++ .../java/org/mozilla/geckoview/test/WebPushTest.kt | 257 + .../org/mozilla/geckoview/test/WebPushUtils.java | 165 + .../geckoview/test/crash/ParentCrashTest.kt | 48 + .../test/crash/RuntimeCrashTestService.kt | 19 + .../geckoview/test/rule/GeckoSessionTestRule.java | 2915 ++++++++ .../geckoview/test/rule/TestHarnessException.java | 11 + .../mozilla/geckoview/test/util/Environment.java | 93 + .../geckoview/test/util/RuntimeCreator.java | 175 + .../org/mozilla/geckoview/test/util/TestServer.kt | 167 + .../mozilla/geckoview/test/util/UiThreadUtils.java | 167 + .../src/androidTest/res/drawable-nodpi/colors.png | Bin 0 -> 16210 bytes .../androidTest/res/drawable-nodpi/colors_br.png | Bin 0 -> 4856 bytes .../res/drawable-nodpi/colors_br_scaled.png | Bin 0 -> 2304 bytes .../androidTest/res/drawable-nodpi/colors_tl.png | Bin 0 -> 5593 bytes .../res/drawable-nodpi/colors_tl_scaled.png | Bin 0 -> 1836 bytes .../src/androidTest/res/values/colors.xml | 9 + .../src/androidTest/res/values/strings.xml | 7 + .../src/androidTest/res/values/styles.xml | 11 + .../src/asan/resources/lib/arm64-v8a/wrap.sh | 52 + .../src/asan/resources/lib/armeabi-v7a/wrap.sh | 52 + .../geckoview/src/asan/resources/lib/x86/wrap.sh | 52 + .../src/asan/resources/lib/x86_64/wrap.sh | 52 + .../android/geckoview/src/main/AndroidManifest.xml | 91 + .../src/main/AndroidManifest_overlay.jinja | 19 + .../org/mozilla/gecko/IGeckoEditableChild.aidl | 44 + .../org/mozilla/gecko/IGeckoEditableParent.aidl | 37 + .../aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl | 7 + .../gecko/gfx/ICompositorSurfaceManager.aidl | 11 + .../org/mozilla/gecko/gfx/ISurfaceAllocator.aidl | 15 + .../aidl/org/mozilla/gecko/gfx/SyncConfig.aidl | 7 + .../aidl/org/mozilla/gecko/media/FormatParam.aidl | 7 + .../main/aidl/org/mozilla/gecko/media/ICodec.aidl | 33 + .../org/mozilla/gecko/media/ICodecCallbacks.aidl | 17 + .../org/mozilla/gecko/media/IMediaDrmBridge.aidl | 27 + .../gecko/media/IMediaDrmBridgeCallbacks.aidl | 31 + .../org/mozilla/gecko/media/IMediaManager.aidl | 21 + .../main/aidl/org/mozilla/gecko/media/Sample.aidl | 7 + .../aidl/org/mozilla/gecko/media/SampleBuffer.aidl | 7 + .../org/mozilla/gecko/media/SessionKeyInfo.aidl | 7 + .../org/mozilla/gecko/process/IChildProcess.aidl | 48 + .../org/mozilla/gecko/process/IProcessManager.aidl | 14 + .../aidl/org/mozilla/gecko/util/GeckoBundle.aidl | 7 + .../org/mozilla/gecko/AndroidGamepadManager.java | 415 ++ .../src/main/java/org/mozilla/gecko/Clipboard.java | 181 + .../main/java/org/mozilla/gecko/CrashHandler.java | 537 ++ .../java/org/mozilla/gecko/EnterpriseRoots.java | 96 + .../java/org/mozilla/gecko/EventDispatcher.java | 588 ++ .../main/java/org/mozilla/gecko/GeckoAppShell.java | 1641 +++++ .../org/mozilla/gecko/GeckoBatteryManager.java | 200 + .../java/org/mozilla/gecko/GeckoEditableChild.java | 456 ++ .../java/org/mozilla/gecko/GeckoJavaSampler.java | 807 +++ .../org/mozilla/gecko/GeckoNetworkManager.java | 413 ++ .../mozilla/gecko/GeckoScreenChangeListener.java | 76 + .../org/mozilla/gecko/GeckoScreenOrientation.java | 273 + .../mozilla/gecko/GeckoSystemStateListener.java | 185 + .../main/java/org/mozilla/gecko/GeckoThread.java | 985 +++ .../main/java/org/mozilla/gecko/InputMethods.java | 106 + .../org/mozilla/gecko/MagnifiableSurfaceView.java | 137 + .../src/main/java/org/mozilla/gecko/MultiMap.java | 186 + .../main/java/org/mozilla/gecko/NativeQueue.java | 225 + .../org/mozilla/gecko/ScreenManagerHelper.java | 24 + .../org/mozilla/gecko/SpeechSynthesisService.java | 230 + .../java/org/mozilla/gecko/SurfaceViewWrapper.java | 198 + .../java/org/mozilla/gecko/TelemetryUtils.java | 102 + .../org/mozilla/gecko/annotation/BuildFlag.java | 25 + .../org/mozilla/gecko/annotation/JNITarget.java | 14 + .../mozilla/gecko/annotation/ReflectionTarget.java | 18 + .../mozilla/gecko/annotation/RobocopTarget.java | 14 + .../mozilla/gecko/annotation/WebRTCJNITarget.java | 14 + .../org/mozilla/gecko/annotation/WrapForJNI.java | 56 + .../java/org/mozilla/gecko/gfx/AndroidVsync.java | 72 + .../gecko/gfx/CompositorSurfaceManager.java | 26 + .../java/org/mozilla/gecko/gfx/GeckoSurface.java | 152 + .../org/mozilla/gecko/gfx/GeckoSurfaceTexture.java | 330 + .../java/org/mozilla/gecko/gfx/PanningPerfAPI.java | 71 + .../mozilla/gecko/gfx/RemoteSurfaceAllocator.java | 77 + .../org/mozilla/gecko/gfx/SurfaceAllocator.java | 143 + .../mozilla/gecko/gfx/SurfaceControlManager.java | 105 + .../mozilla/gecko/gfx/SurfaceTextureListener.java | 38 + .../java/org/mozilla/gecko/gfx/SyncConfig.java | 59 + .../java/org/mozilla/gecko/media/AsyncCodec.java | 63 + .../org/mozilla/gecko/media/AsyncCodecFactory.java | 19 + .../org/mozilla/gecko/media/BaseHlsPlayer.java | 104 + .../main/java/org/mozilla/gecko/media/Codec.java | 712 ++ .../java/org/mozilla/gecko/media/CodecProxy.java | 508 ++ .../java/org/mozilla/gecko/media/FormatParam.java | 178 + .../org/mozilla/gecko/media/GeckoAudioInfo.java | 36 + .../gecko/media/GeckoHLSDemuxerWrapper.java | 166 + .../gecko/media/GeckoHLSResourceWrapper.java | 119 + .../org/mozilla/gecko/media/GeckoHLSSample.java | 93 + .../mozilla/gecko/media/GeckoHlsAudioRenderer.java | 170 + .../org/mozilla/gecko/media/GeckoHlsPlayer.java | 1113 +++ .../mozilla/gecko/media/GeckoHlsRendererBase.java | 340 + .../mozilla/gecko/media/GeckoHlsVideoRenderer.java | 518 ++ .../org/mozilla/gecko/media/GeckoMediaDrm.java | 40 + .../gecko/media/GeckoMediaDrmBridgeV21.java | 771 +++ .../gecko/media/GeckoMediaDrmBridgeV23.java | 50 + .../mozilla/gecko/media/GeckoPlayerFactory.java | 43 + .../org/mozilla/gecko/media/GeckoVideoInfo.java | 45 + .../mozilla/gecko/media/JellyBeanAsyncCodec.java | 490 ++ .../mozilla/gecko/media/LollipopAsyncCodec.java | 250 + .../org/mozilla/gecko/media/MediaDrmProxy.java | 298 + .../java/org/mozilla/gecko/media/MediaManager.java | 79 + .../org/mozilla/gecko/media/RemoteManager.java | 254 + .../mozilla/gecko/media/RemoteMediaDrmBridge.java | 163 + .../gecko/media/RemoteMediaDrmBridgeStub.java | 252 + .../main/java/org/mozilla/gecko/media/Sample.java | 291 + .../java/org/mozilla/gecko/media/SampleBuffer.java | 101 + .../java/org/mozilla/gecko/media/SamplePool.java | 154 + .../org/mozilla/gecko/media/SessionKeyInfo.java | 50 + .../main/java/org/mozilla/gecko/media/Utils.java | 39 + .../org/mozilla/gecko/mozglue/GeckoLoader.java | 440 ++ .../java/org/mozilla/gecko/mozglue/JNIObject.java | 20 + .../org/mozilla/gecko/mozglue/NativeReference.java | 12 + .../org/mozilla/gecko/mozglue/SharedMemory.java | 192 + .../gecko/process/GeckoChildProcessServices.jinja | 19 + .../mozilla/gecko/process/GeckoProcessManager.java | 927 +++ .../mozilla/gecko/process/GeckoProcessType.java | 40 + .../gecko/process/GeckoServiceChildProcess.java | 213 + .../gecko/process/GeckoServiceGpuProcess.java | 63 + .../mozilla/gecko/process/MemoryController.java | 74 + .../mozilla/gecko/process/ServiceAllocator.java | 613 ++ .../org/mozilla/gecko/process/ServiceUtils.java | 141 + .../mozilla/gecko/util/BundleEventListener.java | 21 + .../java/org/mozilla/gecko/util/DebugConfig.java | 136 + .../java/org/mozilla/gecko/util/EventCallback.java | 58 + .../mozilla/gecko/util/GeckoBackgroundThread.java | 72 + .../java/org/mozilla/gecko/util/GeckoBundle.java | 1164 ++++ .../gecko/util/HardwareCodecCapabilityUtils.java | 397 ++ .../java/org/mozilla/gecko/util/HardwareUtils.java | 46 + .../org/mozilla/gecko/util/IXPCOMEventTarget.java | 12 + .../java/org/mozilla/gecko/util/ImageDecoder.java | 88 + .../java/org/mozilla/gecko/util/ImageResource.java | 334 + .../org/mozilla/gecko/util/InputDeviceUtils.java | 20 + .../java/org/mozilla/gecko/util/IntentUtils.java | 120 + .../java/org/mozilla/gecko/util/NetworkUtils.java | 168 + .../java/org/mozilla/gecko/util/ProxySelector.java | 149 + .../java/org/mozilla/gecko/util/ThreadUtils.java | 145 + .../java/org/mozilla/gecko/util/XPCOMError.jinja | 38 + .../org/mozilla/gecko/util/XPCOMEventTarget.java | 170 + .../java/org/mozilla/geckoview/AllowOrDeny.java | 16 + .../java/org/mozilla/geckoview/Autocomplete.java | 1445 ++++ .../main/java/org/mozilla/geckoview/Autofill.java | 1234 ++++ .../java/org/mozilla/geckoview/Base64Utils.java | 20 + .../geckoview/BasicSelectionActionDelegate.java | 685 ++ .../java/org/mozilla/geckoview/CallbackResult.java | 15 + .../mozilla/geckoview/CompositorController.java | 133 + .../org/mozilla/geckoview/ContentBlocking.java | 1689 +++++ .../geckoview/ContentBlockingController.java | 203 + .../java/org/mozilla/geckoview/CrashReporter.java | 385 ++ .../org/mozilla/geckoview/DeprecationSchedule.java | 36 + .../java/org/mozilla/geckoview/GeckoDisplay.java | 528 ++ .../java/org/mozilla/geckoview/GeckoEditable.java | 2616 +++++++ .../mozilla/geckoview/GeckoFontScaleListener.java | 172 + .../mozilla/geckoview/GeckoInputConnection.java | 829 +++ .../org/mozilla/geckoview/GeckoInputStream.java | 226 + .../java/org/mozilla/geckoview/GeckoResult.java | 1072 +++ .../java/org/mozilla/geckoview/GeckoRuntime.java | 1054 +++ .../mozilla/geckoview/GeckoRuntimeSettings.java | 1314 ++++ .../java/org/mozilla/geckoview/GeckoSession.java | 7146 ++++++++++++++++++++ .../org/mozilla/geckoview/GeckoSessionHandler.java | 106 + .../mozilla/geckoview/GeckoSessionSettings.java | 732 ++ .../java/org/mozilla/geckoview/GeckoVRManager.java | 42 + .../main/java/org/mozilla/geckoview/GeckoView.java | 1248 ++++ .../geckoview/GeckoViewPrintDocumentAdapter.java | 196 + .../org/mozilla/geckoview/GeckoWebExecutor.java | 189 + .../src/main/java/org/mozilla/geckoview/Image.java | 54 + .../java/org/mozilla/geckoview/MediaSession.java | 647 ++ .../mozilla/geckoview/OrientationController.java | 60 + .../mozilla/geckoview/OverscrollEdgeEffect.java | 246 + .../org/mozilla/geckoview/PanZoomController.java | 949 +++ .../org/mozilla/geckoview/ParcelableUtils.java | 19 + .../org/mozilla/geckoview/ProfilerController.java | 182 + .../org/mozilla/geckoview/PromptController.java | 646 ++ .../org/mozilla/geckoview/RuntimeSettings.java | 266 + .../org/mozilla/geckoview/RuntimeTelemetry.java | 171 + .../java/org/mozilla/geckoview/ScreenLength.java | 164 + .../mozilla/geckoview/SessionAccessibility.java | 936 +++ .../java/org/mozilla/geckoview/SessionFinder.java | 131 + .../org/mozilla/geckoview/SessionPdfFileSaver.java | 98 + .../org/mozilla/geckoview/SessionTextInput.java | 463 ++ .../org/mozilla/geckoview/SlowScriptResponse.java | 20 + .../org/mozilla/geckoview/StorageController.java | 405 ++ .../mozilla/geckoview/WebAuthnTokenManager.java | 586 ++ .../java/org/mozilla/geckoview/WebExtension.java | 2806 ++++++++ .../mozilla/geckoview/WebExtensionController.java | 1577 +++++ .../java/org/mozilla/geckoview/WebMessage.java | 117 + .../org/mozilla/geckoview/WebNotification.java | 233 + .../mozilla/geckoview/WebNotificationDelegate.java | 29 + .../org/mozilla/geckoview/WebPushController.java | 165 + .../org/mozilla/geckoview/WebPushDelegate.java | 62 + .../org/mozilla/geckoview/WebPushSubscription.java | 180 + .../java/org/mozilla/geckoview/WebRequest.java | 248 + .../org/mozilla/geckoview/WebRequestError.java | 380 ++ .../java/org/mozilla/geckoview/WebResponse.java | 227 + .../org/mozilla/geckoview/doc-files/CHANGELOG.md | 1379 ++++ .../java/org/mozilla/geckoview/package-info.java | 40 + .../src/main/res/drawable/ic_generic_file.xml | 11 + .../org/mozilla/gecko/util/GeckoBundleTest.java | 745 ++ .../org/mozilla/gecko/util/IntentUtilsTest.java | 66 + .../org/mozilla/gecko/util/NetworkUtilsTest.java | 215 + 496 files changed, 103181 insertions(+) create mode 100644 mobile/android/geckoview/src/androidTest/AndroidManifest.xml create mode 100644 mobile/android/geckoview/src/androidTest/assets/moz.build create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/.eslintrc.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/download.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/download.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/download.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/dummy.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab-script.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/background-script.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab-script.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tabs.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/messaging.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/messaging.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/page.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/clickToRequestPermission.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/request-permission.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/web-accessible-script.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.jsm create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.jsm create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/background.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-aria-comboboxes.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-checkbox.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-clipboard.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-collection.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-expandable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-headings.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-links.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-atomic.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-descendant.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image-labeled-by.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-local-iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-move-caret-accessibility-focus.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-mutation.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-range.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-remote-iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-scroll.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-selectable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-text-entry-node.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-tree.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/address_form.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3 create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/autoplay.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/badVideoPath.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/beforeunload.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/cc_form.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/clickToReload.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/color_grid.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/color_orange_background.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/colors.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_audio.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_buffered.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_full.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_image.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_image_nested.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_link.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/context_menu_video.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/data_uri.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/download.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/fixedbottom.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/fixedpercent.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/fixedvh.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/form_blank.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms2.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms2_iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms3.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms4.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms5.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete_iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms_id_value.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms_iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/forms_xorigin.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/fullscreen.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_container.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/hello.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/hello2.html create mode 100755 mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/hsts_header.sjs create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/hungScript.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_no_scrollable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_scrollable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_no_scrollable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_scrollable.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_hello.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_http_only.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_automation.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_local.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/iframe_unknown_protocol.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/images/test.gif create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/inputs.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/links.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/loremIpsum.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/manifest.webmanifest create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/media_session_default1.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/media_session_dom1.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/metatags.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/mouseToReload.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/mp4.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/newSession.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/newSession_child.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/no-meta-viewport.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/ogg.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto-none.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-auto.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-on-non-root.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/popup.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/print_content_change.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/print_iframe.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/prompts.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/push/push.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/push/push.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/push/sw.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/red-background-body-fully-covered-by-green-element.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/reflect_local_storage_into_title.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/resubmit.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/root_100_percent_height.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/root_100vh.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/root_98vh.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/saveState.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/scroll.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/select-listbox.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/select-multiple.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/select.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/showDynamicToolbar.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/simple_redirect.sjs create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/titleChange.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/touch-action-wheel-listener.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/touch-action.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/touch.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/touch_xorigin.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/touchstart.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/trackers.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/transparent.gif create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/viewport.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/webm.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.js create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/worker/open_window_target.html create mode 100644 mobile/android/geckoview/src/androidTest/assets/www/worker/service-worker.js create mode 100644 mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt create mode 100644 mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java create mode 100644 mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png create mode 100644 mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png create mode 100644 mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png create mode 100644 mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png create mode 100644 mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png create mode 100644 mobile/android/geckoview/src/androidTest/res/values/colors.xml create mode 100644 mobile/android/geckoview/src/androidTest/res/values/strings.xml create mode 100644 mobile/android/geckoview/src/androidTest/res/values/styles.xml create mode 100644 mobile/android/geckoview/src/asan/resources/lib/arm64-v8a/wrap.sh create mode 100644 mobile/android/geckoview/src/asan/resources/lib/armeabi-v7a/wrap.sh create mode 100644 mobile/android/geckoview/src/asan/resources/lib/x86/wrap.sh create mode 100644 mobile/android/geckoview/src/asan/resources/lib/x86_64/wrap.sh create mode 100644 mobile/android/geckoview/src/main/AndroidManifest.xml create mode 100644 mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ICompositorSurfaceManager.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl create mode 100644 mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md create mode 100644 mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java create mode 100644 mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml create mode 100644 mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/GeckoBundleTest.java create mode 100644 mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java create mode 100644 mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/NetworkUtilsTest.java (limited to 'mobile/android/geckoview/src') diff --git a/mobile/android/geckoview/src/androidTest/AndroidManifest.xml b/mobile/android/geckoview/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000000..4dc4760d0f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/AndroidManifest.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/moz.build b/mobile/android/geckoview/src/androidTest/assets/moz.build new file mode 100644 index 0000000000..12d6550f1c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/moz.build @@ -0,0 +1,78 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +addons = { + "browsing-data": [ + "background.js", + "manifest.json", + ], + "tabs-activate-remove": [ + "background.js", + "manifest.json", + ], + "tabs-activate-remove-2": [ + "background.js", + "manifest.json", + ], + "update-1": [ + "borderify.js", + "manifest.json", + ], + "update-2": [ + "borderify.js", + "manifest.json", + ], + "update-postpone-1": [ + "background.js", + "borderify.js", + "manifest.json", + ], + "update-postpone-2": [ + "borderify.js", + "manifest.json", + ], + "update-with-perms-1": [ + "borderify.js", + "manifest.json", + ], + "update-with-perms-2": [ + "borderify.js", + "manifest.json", + ], + "page-history": [ + "page.html", + "manifest.json", + ], + "download-flags-true": [ + "download.js", + "manifest.json", + ], + "download-flags-false": [ + "download.js", + "manifest.json", + ], + "download-onChanged": [ + "download.js", + "manifest.json", + ], + "permission-request": [ + "clickToRequestPermission.html", + "request-permission.js", + "manifest.json", + ], +} + +for addon, files in addons.items(): + indir = "web_extensions/%s" % addon + xpi = "%s.xpi" % indir + inputs = [indir] + for file in files: + inputs.append("%s/%s" % (indir, file)) + GeneratedFile( + xpi, script="/toolkit/mozapps/extensions/test/create_xpi.py", inputs=inputs + ) + + TEST_HARNESS_FILES.testing.mochitest.tests.junit += ["!%s" % xpi] diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/.eslintrc.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/.eslintrc.js new file mode 100644 index 0000000000..41c5ed8080 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/.eslintrc.js @@ -0,0 +1,14 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, + globals: { + ExtensionAPI: true, + // available to frameScripts + addMessageListener: false, + content: false, + sendAsyncMessage: false, + }, +}; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js new file mode 100644 index 0000000000..dab0f5d897 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js @@ -0,0 +1,190 @@ +const port = browser.runtime.connectNative("browser"); +port.onMessage.addListener(message => { + handleMessage(message, null); +}); + +browser.runtime.onMessage.addListener((message, sender) => { + handleMessage(message, sender.tab.id); +}); + +browser.pageAction.onClicked.addListener(tab => { + port.postMessage({ method: "onClicked", tabId: tab.id, type: "pageAction" }); +}); + +browser.browserAction.onClicked.addListener(tab => { + port.postMessage({ + method: "onClicked", + tabId: tab.id, + type: "browserAction", + }); +}); + +function handlePageActionMessage(message, tabId) { + switch (message.action) { + case "enable": + browser.pageAction.show(tabId); + break; + + case "disable": + browser.pageAction.hide(tabId); + break; + + case "setPopup": + browser.pageAction.setPopup({ + tabId, + popup: message.popup, + }); + break; + + case "setPopupCheckRestrictions": + browser.pageAction + .setPopup({ + tabId, + popup: message.popup, + }) + .then( + () => { + port.postMessage({ + resultFor: "setPopup", + type: "pageAction", + success: true, + }); + }, + err => { + port.postMessage({ + resultFor: "setPopup", + type: "pageAction", + success: false, + error: String(err), + }); + } + ); + break; + + case "setTitle": + browser.pageAction.setTitle({ + tabId, + title: message.title, + }); + break; + + case "setIcon": + browser.pageAction.setIcon({ + tabId, + imageData: message.imageData, + path: message.path, + }); + break; + + default: + throw new Error(`Page Action does not support ${message.action}`); + } +} + +function handleBrowserActionMessage(message, tabId) { + switch (message.action) { + case "enable": + browser.browserAction.enable(tabId); + break; + + case "disable": + browser.browserAction.disable(tabId); + break; + + case "setBadgeText": + browser.browserAction.setBadgeText({ + tabId, + text: message.text, + }); + break; + + case "setBadgeTextColor": + browser.browserAction.setBadgeTextColor({ + tabId, + color: message.color, + }); + break; + + case "setBadgeBackgroundColor": + browser.browserAction.setBadgeBackgroundColor({ + tabId, + color: message.color, + }); + break; + + case "setPopup": + browser.browserAction.setPopup({ + tabId, + popup: message.popup, + }); + break; + + case "setPopupCheckRestrictions": + browser.browserAction + .setPopup({ + tabId, + popup: message.popup, + }) + .then( + () => { + port.postMessage({ + resultFor: "setPopup", + type: "browserAction", + success: true, + }); + }, + err => { + port.postMessage({ + resultFor: "setPopup", + type: "browserAction", + success: false, + error: String(err), + }); + } + ); + break; + + case "setTitle": + browser.browserAction.setTitle({ + tabId, + title: message.title, + }); + break; + + case "setIcon": + browser.browserAction.setIcon({ + tabId, + imageData: message.imageData, + path: message.path, + }); + break; + + default: + throw new Error(`Browser Action does not support ${message.action}`); + } +} + +function handleMessage(message, tabId) { + switch (message.type) { + case "ping": + port.postMessage({ method: "pong" }); + return; + + case "load": + browser.tabs.update(tabId, { + url: message.url, + }); + return; + + case "browserAction": + handleBrowserActionMessage(message, tabId); + return; + + case "pageAction": + handlePageActionMessage(message, tabId); + return; + + default: + throw new Error(`Unsupported message type ${message.type}`); + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png new file mode 100644 index 0000000000..dbed714c56 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png new file mode 100644 index 0000000000..89863ccec7 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png new file mode 100644 index 0000000000..aea2c19784 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png new file mode 100644 index 0000000000..90687de26d Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png new file mode 100644 index 0000000000..90687de26d Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg new file mode 100644 index 0000000000..dd1fae7d15 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js new file mode 100644 index 0000000000..eaa2467df0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js @@ -0,0 +1,4 @@ +const port = browser.runtime.connectNative("browser"); +port.onMessage.addListener(message => { + browser.runtime.sendMessage(message); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json new file mode 100644 index 0000000000..21ca7c7e07 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 2, + "name": "actions", + "version": "1.0", + "description": "Defines Page and Browser actions", + "browser_specific_settings": { + "gecko": { + "id": "actions@tests.mozilla.org" + } + }, + "browser_action": { + "default_title": "Test action default", + "theme_icons": [ + { + "light": "button/beasts-32-light.png", + "dark": "button/beasts-32.png", + "size": 32 + } + ] + }, + "page_action": { + "default_title": "Test action default", + "default_icon": { + "19": "button/geo-19.png", + "38": "button/geo-38.png" + } + }, + "background": { + "scripts": ["background.js"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"] + } + ], + "permissions": [ + "tabs", + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent" + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html new file mode 100644 index 0000000000..dc388b8a7f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html @@ -0,0 +1,14 @@ + + + + + + + +

Hello, world!

+ + + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js new file mode 100644 index 0000000000..cde31235ac --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js @@ -0,0 +1,7 @@ +window.addEventListener("DOMContentLoaded", init); + +function init() { + document.body.addEventListener("click", event => { + browser.browserAction.openPopup(); + }); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html new file mode 100644 index 0000000000..3fe42d0b2e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html @@ -0,0 +1,14 @@ + + + + + + + +

Hello, world!

+ + + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js new file mode 100644 index 0000000000..f16d96333f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js @@ -0,0 +1,7 @@ +window.addEventListener("DOMContentLoaded", init); + +function init() { + document.body.addEventListener("click", event => { + browser.pageAction.openPopup(); + }); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.html new file mode 100644 index 0000000000..f0fff977d8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.html @@ -0,0 +1,9 @@ + + + + + + +

HELLO

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.js new file mode 100644 index 0000000000..479f957564 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.js @@ -0,0 +1,24 @@ +browser.runtime.sendNativeMessage("badNativeApi", "errorerrorerror"); + +async function runTest() { + const response = await browser.runtime.sendNativeMessage( + "browser", + "testPopupMessage" + ); + + browser.runtime.sendNativeMessage("browser", `response: ${response}`); + + const port = browser.runtime.connectNative("browser"); + port.onMessage.addListener(response => { + if (response.action === "disconnect") { + port.disconnect(); + return; + } + + port.postMessage(`response: ${response.message}`); + }); + + port.postMessage("testPopupPortMessage"); +} + +runTest(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html new file mode 100644 index 0000000000..dd98313e59 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html @@ -0,0 +1,9 @@ + + + + + + +

HELLO

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.js new file mode 100644 index 0000000000..47271e744c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.js @@ -0,0 +1,3 @@ +window.addEventListener("DOMContentLoaded", () => { + window.close(); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi new file mode 100644 index 0000000000..19ce0d7f0f Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi new file mode 100644 index 0000000000..fd395d13df Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi new file mode 100644 index 0000000000..1ed97f1047 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js new file mode 100644 index 0000000000..9c3728b381 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png new file mode 100644 index 0000000000..90687de26d Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg new file mode 100644 index 0000000000..dd1fae7d15 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json new file mode 100644 index 0000000000..6cbc5c0601 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "name": "Borderify", + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "browser_specific_settings": { + "gecko": { + "id": "borderify@tests.mozilla.org" + } + }, + "icons": { + "48": "icons/border-48.png" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/background.js new file mode 100644 index 0000000000..d0ae54b3dd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/background.js @@ -0,0 +1,44 @@ +const port = browser.runtime.connectNative("browser"); + +async function apiCall(message) { + const { type, since, removalOptions, dataTypes } = message; + switch (type) { + case "clear-downloads": + await browser.browsingData.removeDownloads({ since }); + break; + case "clear-form-data": + await browser.browsingData.removeFormData({ since }); + break; + case "clear-history": + await browser.browsingData.removeHistory({ since }); + break; + case "clear-passwords": + await browser.browsingData.removePasswords({ since }); + break; + case "clear": + await browser.browsingData.remove(removalOptions, dataTypes); + break; + case "get-settings": + return browser.browsingData.settings(); + } + return null; +} + +port.onMessage.addListener(async message => { + const { uuid } = message; + try { + const result = await apiCall(message); + port.postMessage({ + type: "response", + result, + uuid, + }); + } catch (exception) { + const { message } = exception; + port.postMessage({ + type: "error", + error: message, + uuid, + }); + } +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/manifest.json new file mode 100644 index 0000000000..23df4d8338 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "BrowsingData", + "browser_specific_settings": { + "gecko": { + "id": "browsing-data-settings@tests.mozilla.org" + } + }, + "version": "1.0", + "description": "Tests the browsingData API", + "background": { + "scripts": ["background.js"] + }, + "permissions": ["browsingData", "geckoViewAddons", "nativeMessaging"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/background.js new file mode 100644 index 0000000000..4597e3328b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/background.js @@ -0,0 +1,8 @@ +browser.browsingData.removeDownloads({ since: 10001 }); +browser.browsingData.removeFormData({ since: 10002 }); +browser.browsingData.removeHistory({ since: 10003 }); +browser.browsingData.removePasswords({ since: 10004 }); +browser.browsingData.remove( + { since: 10005 }, + { downloads: true, cookies: true } +); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/manifest.json new file mode 100644 index 0000000000..f7af03c25e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "BrowsingData", + "browser_specific_settings": { + "gecko": { + "id": "browsing-data@tests.mozilla.org" + } + }, + "version": "1.0", + "description": "Tests the browsingData API", + "background": { + "scripts": ["background.js"] + }, + "permissions": ["browsingData"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/download.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/download.js new file mode 100644 index 0000000000..68f51ea5d8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/download.js @@ -0,0 +1,3 @@ +browser.downloads.download({ + url: "http://localhost:4245/assets/www/images/test.gif", +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/manifest.json new file mode 100644 index 0000000000..77b1cb5179 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "Download", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "download-flags-false@tests.mozilla.org" + } + }, + "description": "Downloads a file", + "background": { + "scripts": ["download.js"] + }, + "permissions": ["downloads"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/download.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/download.js new file mode 100644 index 0000000000..4bb06a5cbb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/download.js @@ -0,0 +1,16 @@ +browser.downloads.download({ + url: "http://localhost:4245/assets/www/images/test.gif", + filename: "banana.gif", + method: "POST", + body: "postbody", + headers: [ + { + name: "User-Agent", + value: "Mozilla Firefox", + }, + ], + allowHttpErrors: true, + conflictAction: "overwrite", + saveAs: true, + incognito: true, +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/manifest.json new file mode 100644 index 0000000000..c0170dafd4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "Download", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "download-flags-true@tests.mozilla.org" + } + }, + "description": "Downloads a file", + "background": { + "scripts": ["download.js"] + }, + "permissions": ["downloads"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/download.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/download.js new file mode 100644 index 0000000000..01cd377cef --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/download.js @@ -0,0 +1,18 @@ +async function test() { + browser.downloads.onChanged.addListener(async delta => { + const changes = { current: {}, previous: {} }; + changes.id = delta.id; + delete delta.id; + for (const prop in delta) { + changes.current[prop] = delta[prop].current; + changes.previous[prop] = delta[prop].previous; + } + await browser.runtime.sendNativeMessage("browser", changes); + }); + + await browser.downloads.download({ + url: "http://localhost:4245/assets/www/images/test.gif", + }); +} + +test(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/manifest.json new file mode 100644 index 0000000000..1c1ad4cc5e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "Download", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "download-onChanged@tests.mozilla.org" + } + }, + "description": "Downloads a file", + "background": { + "scripts": ["download.js"] + }, + "permissions": ["downloads", "geckoViewAddons", "nativeMessaging"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi new file mode 100644 index 0000000000..0e0f549ceb Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/dummy.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/dummy.js new file mode 100644 index 0000000000..2a49c0d665 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/dummy.js @@ -0,0 +1 @@ +console.log("Hi, I'm a dummy."); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/manifest.json new file mode 100644 index 0000000000..f1f9b93a91 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "name": "Dummy", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "dummy@tests.mozilla.org" + } + }, + "description": "Doesn't do anything.", + "options_ui": { + "open_in_tab": true, + "page": "options.html" + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["dummy.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/manifest.json new file mode 100644 index 0000000000..0fcb48bc8f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Test messages sent from extensions when restoring", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "extension-page-restoring@tests.mozilla.org" + } + }, + "permissions": ["geckoViewAddons", "nativeMessaging"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab-script.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab-script.js new file mode 100644 index 0000000000..66866bbd37 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab-script.js @@ -0,0 +1,5 @@ +browser.runtime.sendNativeMessage("browser1", "HELLO_FROM_PAGE_1"); +browser.runtime.sendNativeMessage("browser2", "HELLO_FROM_PAGE_2"); + +const port = browser.runtime.connectNative("browser1"); +port.postMessage("HELLO_FROM_PORT"); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab.html new file mode 100644 index 0000000000..d99a610c0c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab.html @@ -0,0 +1,10 @@ + + + + + + +

Hello World!

+ + + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/background-script.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/background-script.js new file mode 100644 index 0000000000..43e2b44f96 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/background-script.js @@ -0,0 +1,7 @@ +browser.runtime.onMessage.addListener(notify); + +function notify(message) { + if (message.action == "showTab") { + browser.tabs.update({ url: "/tab.html" }); + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/manifest.json new file mode 100644 index 0000000000..c64115e07c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "name": "Mozilla Android Components - Tabs Update Test", + "version": "1.0", + "background": { + "scripts": ["background-script.js"] + }, + "browser_specific_settings": { + "gecko": { + "id": "extension-page-update@tests.mozilla.org" + } + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["tabs.js"], + "run_at": "document_idle" + } + ], + "permissions": ["geckoViewAddons", "nativeMessaging", "tabs", ""] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab-script.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab-script.js new file mode 100644 index 0000000000..011f3bb301 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab-script.js @@ -0,0 +1,2 @@ +// Let's test privileged APIs +browser.runtime.sendNativeMessage("browser", "HELLO_FROM_PAGE"); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab.html new file mode 100644 index 0000000000..d99a610c0c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab.html @@ -0,0 +1,10 @@ + + + + + + +

Hello World!

+ + + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tabs.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tabs.js new file mode 100644 index 0000000000..ef5fbf6ce3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tabs.js @@ -0,0 +1 @@ +browser.runtime.sendMessage({ action: "showTab" }); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/manifest.json new file mode 100644 index 0000000000..9a687dafbe --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Test messaging between app and web extension", + "browser_specific_settings": { + "gecko": { + "id": "messaging-content@tests.mozilla.org" + } + }, + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["messaging.js"] + } + ], + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent" + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/messaging.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/messaging.js new file mode 100644 index 0000000000..1c8323df53 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/messaging.js @@ -0,0 +1,29 @@ +// This message should not be handled +browser.runtime.sendNativeMessage("badNativeApi", "errorerrorerror"); + +async function runTest() { + const response = await browser.runtime.sendNativeMessage( + "browser", + "testContentBrowserMessage" + ); + + browser.runtime.sendNativeMessage("browser", `response: ${response}`); + + const port = browser.runtime.connectNative("browser"); + port.onMessage.addListener(response => { + if (response.action === "disconnect") { + port.disconnect(); + return; + } + + port.postMessage(`response: ${response.message}`); + }); + + port.onDisconnect.addListener(() => + browser.runtime.sendNativeMessage("browser", { type: "portDisconnected" }) + ); + + port.postMessage("testContentPortMessage"); +} + +runTest(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/manifest.json new file mode 100644 index 0000000000..f9039fd2e8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Test messaging between app and web extension", + "browser_specific_settings": { + "gecko": { + "id": "messaging-iframe@tests.mozilla.org" + } + }, + "content_scripts": [ + { + "matches": ["*://localhost/*"], + "js": ["messaging.js"], + "all_frames": true + } + ], + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent" + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/messaging.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/messaging.js new file mode 100644 index 0000000000..eb4ad3d8f9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/messaging.js @@ -0,0 +1,11 @@ +browser.runtime.sendNativeMessage("badNativeApi", "errorerrorerror"); + +async function runTest() { + await browser.runtime.sendNativeMessage( + "browser", + "testContentBrowserMessage" + ); + browser.runtime.connectNative("browser"); +} + +runTest(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/background.js new file mode 100644 index 0000000000..20deb53ae7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/background.js @@ -0,0 +1,28 @@ +browser.runtime.sendNativeMessage("badNativeApi", "errorerrorerror"); + +async function runTest() { + const response = await browser.runtime.sendNativeMessage( + "browser", + "testBackgroundBrowserMessage" + ); + + browser.runtime.sendNativeMessage("browser", `response: ${response}`); + + const port = browser.runtime.connectNative("browser"); + port.onMessage.addListener(response => { + if (response.action === "disconnect") { + port.disconnect(); + return; + } + + port.postMessage(`response: ${response.message}`); + }); + + port.onDisconnect.addListener(() => + browser.runtime.sendNativeMessage("browser", { type: "portDisconnected" }) + ); + + port.postMessage("testBackgroundPortMessage"); +} + +runTest(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png new file mode 100644 index 0000000000..90687de26d Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png differ diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/manifest.json new file mode 100644 index 0000000000..d25b692f63 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Test messaging between app and web extension", + "icons": { + "48": "icons/border-48.png" + }, + "browser_specific_settings": { + "gecko": { + "id": "messaging@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["geckoViewAddons", "nativeMessaging"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/background.js new file mode 100644 index 0000000000..cdd3a7a523 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/background.js @@ -0,0 +1,6 @@ +browser.notifications.create("cake-notification", { + type: "basic", + title: "Time for cake!", + iconUrl: "https://example.com/img.svg", + message: "Something something cake", +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/manifest.json new file mode 100644 index 0000000000..963fb51e3f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "Notification test", + "version": "1.0", + "description": "Send a notification.", + "browser_specific_settings": { + "gecko": { + "id": "notification@example.com" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["notifications"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/background.js new file mode 100644 index 0000000000..1872c48d00 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/background.js @@ -0,0 +1 @@ +browser.runtime.openOptionsPage(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/manifest.json new file mode 100644 index 0000000000..487fb0fb3d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "name": "openOptionsPage-1", + "version": "1.0", + "description": "Opens options page in a new tab.", + "browser_specific_settings": { + "gecko": { + "id": "openoptionspage1@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"], + "options_ui": { + "page": "options.html", + "browser_style": true, + "open_in_tab": true + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/background.js new file mode 100644 index 0000000000..1872c48d00 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/background.js @@ -0,0 +1 @@ +browser.runtime.openOptionsPage(); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/manifest.json new file mode 100644 index 0000000000..3378050197 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "name": "openOptionsPage-2", + "version": "1.0", + "description": "Opens options page via delegate.", + "browser_specific_settings": { + "gecko": { + "id": "openoptionspage2@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"], + "options_ui": { + "page": "options.html", + "browser_style": true, + "open_in_tab": false + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/manifest.json new file mode 100644 index 0000000000..9d411c8dd6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Page History", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "page-history@tests.mozilla.org" + } + }, + "description": "Can load a WebExtension Page." +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/page.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/page.html new file mode 100644 index 0000000000..b16a98f74b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/page.html @@ -0,0 +1,9 @@ + + + + + + +

Hello, World!

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/clickToRequestPermission.html b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/clickToRequestPermission.html new file mode 100644 index 0000000000..e6ddcb8c8d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/clickToRequestPermission.html @@ -0,0 +1,11 @@ + + + + Hello, world! + + + + +

Hello, world!

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/manifest.json new file mode 100644 index 0000000000..d2cd405cd1 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest_version": 2, + "name": "permissions", + "browser_specific_settings": { + "gecko": { + "id": "permissions@example.com" + } + }, + "version": "1.0", + "description": "Request optional extension permissions.", + "permissions": ["nativeMessaging", "geckoViewAddons"], + "optional_permissions": ["geolocation", "*://example.com/*"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/request-permission.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/request-permission.js new file mode 100644 index 0000000000..d50bff4126 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/request-permission.js @@ -0,0 +1,11 @@ +window.onload = () => { + document.body.addEventListener("click", requestPermissions); + async function requestPermissions() { + const perms = { + permissions: ["geolocation"], + origins: ["*://example.com/*"], + }; + const response = await browser.permissions.request(perms); + browser.runtime.sendNativeMessage("browser", `${response}`); + } +}; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/background.js new file mode 100644 index 0000000000..fdf088a505 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/background.js @@ -0,0 +1,39 @@ +"use strict"; + +function setupRedirect(fromUrl, redirectUrl) { + browser.webRequest.onBeforeRequest.addListener( + details => { + console.log(`Extension redirects from ${fromUrl} to ${redirectUrl}`); + return { redirectUrl }; + }, + { urls: [fromUrl] }, + ["blocking"] + ); +} + +// Intercepts all script requests from androidTest/assets/www/trackers.html. +// Scripts are executed in order of appearance in the page and block the +// page's "load" event, so the test runner can just wait for the page to +// have loaded and then check the page content to verify that the requests +// were intercepted as expected. +setupRedirect( + "http://trackertest.org/tracker.js", + "data:text/javascript,document.body.textContent='start'" +); +setupRedirect( + "https://tracking.example.com/tracker.js", + browser.runtime.getURL("web-accessible-script.js") +); +setupRedirect( + "https://itisatracker.org/tracker.js", + `data:text/javascript,document.body.textContent+=',end'` +); + +// Work around bug 1300234 to ensure that the webRequest listener has been +// registered before we resume the test. API result doesn't matter, we just +// want to make a roundtrip. +var listenerReady = browser.webRequest.getSecurityInfo("").catch(() => {}); + +listenerReady.then(() => { + browser.runtime.sendNativeMessage("browser", "setupReadyStartTest"); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/manifest.json new file mode 100644 index 0000000000..71d811faa3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "redirect-to-android-resource", + "description": "Redirects script requests from trackers.html to moz-extension:-resource packaged in the APK (resource://android/...)", + "manifest_version": 2, + "version": "1", + "browser_specific_settings": { + "gecko": { + "id": "redirect-to-android-resource@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "webRequest", + "webRequestBlocking", + "http://localhost/", + "http://trackertest.org/", + "https://tracking.example.com/", + "https://itisatracker.org/tracker.js" + ], + "web_accessible_resources": ["web-accessible-script.js"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/web-accessible-script.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/web-accessible-script.js new file mode 100644 index 0000000000..a26c4cc91c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/web-accessible-script.js @@ -0,0 +1,3 @@ +"use strict"; + +document.body.textContent += ",extension-was-here"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/background.js new file mode 100644 index 0000000000..f8ecef0215 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/background.js @@ -0,0 +1,16 @@ +browser.tabs.onActivated.addListener(async tabChange => { + const activeTabs = await browser.tabs.query({ active: true }); + const currentWindow = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + if ( + activeTabs.length === 1 && + activeTabs[0].id == tabChange.tabId && + currentWindow.length === 1 && + currentWindow[0].id === tabChange.tabId + ) { + browser.tabs.remove(tabChange.tabId); + } +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/manifest.json new file mode 100644 index 0000000000..784215634d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/manifest.json @@ -0,0 +1,15 @@ +{ + "browser_specific_settings": { + "gecko": { + "id": "set-tab-active-2@tests.mozilla.org" + } + }, + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Removes the activated Tab.", + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/background.js new file mode 100644 index 0000000000..f8ecef0215 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/background.js @@ -0,0 +1,16 @@ +browser.tabs.onActivated.addListener(async tabChange => { + const activeTabs = await browser.tabs.query({ active: true }); + const currentWindow = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + if ( + activeTabs.length === 1 && + activeTabs[0].id == tabChange.tabId && + currentWindow.length === 1 && + currentWindow[0].id === tabChange.tabId + ) { + browser.tabs.remove(tabChange.tabId); + } +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/manifest.json new file mode 100644 index 0000000000..03c3514bb0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/manifest.json @@ -0,0 +1,15 @@ +{ + "browser_specific_settings": { + "gecko": { + "id": "set-tab-active@tests.mozilla.org" + } + }, + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Removes the activated Tab.", + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/background.js new file mode 100644 index 0000000000..8182b6a4f8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/background.js @@ -0,0 +1,4 @@ +browser.tabs.create({ + url: "https://www.mozilla.org/en-US/", + cookieStoreId: "firefox-container-1", +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/manifest.json new file mode 100644 index 0000000000..2746155adf --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Creates a tab with a contextual identity.", + "browser_specific_settings": { + "gecko": { + "id": "tabs-create-2@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs", "cookies"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/background.js new file mode 100644 index 0000000000..a1f55a3a4f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/background.js @@ -0,0 +1,3 @@ +browser.tabs.create({}).then(tab => { + browser.tabs.remove(tab.id); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/manifest.json new file mode 100644 index 0000000000..10b2f454e7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Creates and removes a tab.", + "browser_specific_settings": { + "gecko": { + "id": "tabs-create-remove@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": [] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/background.js new file mode 100644 index 0000000000..6fbd381e61 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/background.js @@ -0,0 +1 @@ +browser.tabs.create({ url: "https://www.mozilla.org/en-US/" }); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/manifest.json new file mode 100644 index 0000000000..517ddd0189 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Creates a tab.", + "browser_specific_settings": { + "gecko": { + "id": "tabs-create@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/background.js new file mode 100644 index 0000000000..c6ec7aee33 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/background.js @@ -0,0 +1,3 @@ +browser.tabs.query({ url: "*://*/*?tabToClose" }).then(([tab]) => { + browser.tabs.remove(tab.id); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/manifest.json new file mode 100644 index 0000000000..559512eec5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Removes an existing tab.", + "browser_specific_settings": { + "gecko": { + "id": "tabs-remove@tests.mozilla.org" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["tabs"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.jsm b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.jsm new file mode 100644 index 0000000000..58c60a4e6c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.jsm @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { GeckoViewActorChild } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewActorChild.sys.mjs" +); + +const EXPORTED_SYMBOLS = ["TestSupportChild"]; + +class TestSupportChild extends GeckoViewActorChild { + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "FlushApzRepaints": + return new Promise(resolve => { + const repaintDone = () => { + debug`APZ flush done`; + Services.obs.removeObserver(repaintDone, "apz-repaints-flushed"); + resolve(); + }; + Services.obs.addObserver(repaintDone, "apz-repaints-flushed"); + if (this.contentWindow.windowUtils.flushApzRepaints()) { + debug`Flushed APZ repaints, waiting for callback...`; + } else { + debug`Flushing APZ repaints was a no-op, triggering callback directly...`; + repaintDone(); + } + }); + case "PromiseAllPaintsDone": + return new Promise(resolve => { + const window = this.contentWindow; + const utils = window.windowUtils; + + function waitForPaints() { + // Wait until paint suppression has ended + if (utils.paintingSuppressed) { + dump`waiting for paint suppression to end...`; + window.setTimeout(waitForPaints, 0); + return; + } + + if (utils.isMozAfterPaintPending) { + dump`waiting for paint...`; + window.addEventListener("MozAfterPaint", waitForPaints, { + once: true, + }); + return; + } + resolve(); + } + waitForPaints(); + }); + case "GetLinkColor": { + const { selector } = aMsg.data; + const element = this.document.querySelector(selector); + if (!element) { + throw new Error("No element for " + selector); + } + const color = + this.contentWindow.windowUtils.getVisitedDependentComputedStyle( + element, + "", + "color" + ); + return color; + } + case "SetResolutionAndScaleTo": { + return new Promise(resolve => { + const window = this.contentWindow; + const { resolution } = aMsg.data; + window.visualViewport.addEventListener("resize", () => resolve(), { + once: true, + }); + window.windowUtils.setResolutionAndScaleTo(resolution); + }); + } + } + return null; + } +} +const { debug } = TestSupportChild.initLogging("GeckoViewTestSupport"); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.jsm b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.jsm new file mode 100644 index 0000000000..e0622d7743 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.jsm @@ -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/. */ + +var EXPORTED_SYMBOLS = ["TestSupportProcessChild"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const ProcessTools = Cc["@mozilla.org/processtools-service;1"].getService( + Ci.nsIProcessToolsService +); + +class TestSupportProcessChild extends JSProcessActorChild { + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "KillContentProcess": + ProcessTools.kill(Services.appinfo.processID); + } + } +} + +const { debug } = GeckoViewUtils.initLogging("TestSupportProcess[C]"); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js new file mode 100644 index 0000000000..c818719d3b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const port = browser.runtime.connectNative("browser"); + +const APIS = { + AddHistogram({ id, value }) { + browser.test.addHistogram(id, value); + }, + Eval({ code }) { + // eslint-disable-next-line no-eval + return eval(`(async () => { + ${code} + })()`); + }, + SetScalar({ id, value }) { + browser.test.setScalar(id, value); + }, + GetRequestedLocales() { + return browser.test.getRequestedLocales(); + }, + GetLinkColor({ tab, selector }) { + return browser.test.getLinkColor(tab.id, selector); + }, + GetPidForTab({ tab }) { + return browser.test.getPidForTab(tab.id); + }, + GetProfilePath() { + return browser.test.getProfilePath(); + }, + GetAllBrowserPids() { + return browser.test.getAllBrowserPids(); + }, + KillContentProcess({ pid }) { + return browser.test.killContentProcess(pid); + }, + GetPrefs({ prefs }) { + return browser.test.getPrefs(prefs); + }, + GetActive({ tab }) { + return browser.test.getActive(tab.id); + }, + RemoveAllCertOverrides() { + browser.test.removeAllCertOverrides(); + }, + RestorePrefs({ oldPrefs }) { + return browser.test.restorePrefs(oldPrefs); + }, + SetPrefs({ oldPrefs, newPrefs }) { + return browser.test.setPrefs(oldPrefs, newPrefs); + }, + SetResolutionAndScaleTo({ tab, resolution }) { + return browser.test.setResolutionAndScaleTo(tab.id, resolution); + }, + FlushApzRepaints({ tab }) { + return browser.test.flushApzRepaints(tab.id); + }, + PromiseAllPaintsDone({ tab }) { + return browser.test.promiseAllPaintsDone(tab.id); + }, + UsingGpuProcess() { + return browser.test.usingGpuProcess(); + }, + KillGpuProcess() { + return browser.test.killGpuProcess(); + }, + CrashGpuProcess() { + return browser.test.crashGpuProcess(); + }, + ClearHSTSState() { + return browser.test.clearHSTSState(); + }, + TriggerCookieBannerDetected({ tab }) { + return browser.test.triggerCookieBannerDetected(tab.id); + }, + TriggerCookieBannerHandled({ tab }) { + return browser.test.triggerCookieBannerHandled(tab.id); + }, +}; + +port.onMessage.addListener(async message => { + const impl = APIS[message.type]; + apiCall(message, impl); +}); + +browser.runtime.onConnect.addListener(contentPort => { + contentPort.onMessage.addListener(message => { + message.args.tab = contentPort.sender.tab; + + const impl = APIS[message.type]; + apiCall(message, impl); + }); +}); + +function apiCall(message, impl) { + const { id, args } = message; + try { + sendResponse(id, impl(args)); + } catch (error) { + sendResponse(id, Promise.reject(error)); + } +} + +function sendResponse(id, response) { + Promise.resolve(response).then( + value => sendSyncResponse(id, value), + reason => sendSyncResponse(id, null, reason) + ); +} + +function sendSyncResponse(id, response, exception) { + port.postMessage({ + id, + response: JSON.stringify(response), + exception: exception && exception.toString(), + }); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json new file mode 100644 index 0000000000..133eed8985 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json @@ -0,0 +1,41 @@ +{ + "manifest_version": 2, + "name": "Test support", + "version": "1.0", + "description": "Helper script for GeckoView tests", + "browser_specific_settings": { + "gecko": { + "id": "test-support@tests.mozilla.org" + } + }, + "content_scripts": [ + { + "matches": [""], + "js": ["test-support.js"], + "run_at": "document_start" + } + ], + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", + "background": { + "scripts": ["background.js"] + }, + "experiment_apis": { + "test": { + "schema": "test-schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "test-api.js", + "events": ["startup"], + "paths": [["test"]] + } + } + }, + "options_ui": { + "page": "dummy.html" + }, + "permissions": [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent" + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js new file mode 100644 index 0000000000..4bddaedfea --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js @@ -0,0 +1,229 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* globals Services */ + +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +// eslint-disable-next-line mozilla/reject-importGlobalProperties +Cu.importGlobalProperties(["PathUtils"]); + +this.test = class extends ExtensionAPI { + onStartup() { + ChromeUtils.registerWindowActor("TestSupport", { + child: { + moduleURI: + "resource://android/assets/web_extensions/test-support/TestSupportChild.jsm", + }, + allFrames: true, + }); + ChromeUtils.registerProcessActor("TestSupportProcess", { + child: { + moduleURI: + "resource://android/assets/web_extensions/test-support/TestSupportProcessChild.jsm", + }, + }); + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + ChromeUtils.unregisterWindowActor("TestSupport"); + ChromeUtils.unregisterProcessActor("TestSupportProcess"); + } + + getAPI(context) { + /** + * Helper function for getting window or process actors. + * + * @param tabId - id of the tab; required + * @param actorName - a string; the name of the actor + * Default: "TestSupport" which is our test framework actor + * (you can still pass the second parameter when getting the TestSupport actor, for readability) + * + * @returns actor + */ + function getActorForTab(tabId, actorName = "TestSupport") { + const tab = context.extension.tabManager.get(tabId); + const { browsingContext } = tab.browser; + return browsingContext.currentWindowGlobal.getActor(actorName); + } + + return { + test: { + /* Set prefs and returns set of saved prefs */ + async setPrefs(oldPrefs, newPrefs) { + // Save old prefs + Object.assign( + oldPrefs, + ...Object.keys(newPrefs) + .filter(key => !(key in oldPrefs)) + .map(key => ({ [key]: Preferences.get(key, null) })) + ); + + // Set new prefs + Preferences.set(newPrefs); + return oldPrefs; + }, + + /* Restore prefs to old value. */ + async restorePrefs(oldPrefs) { + for (const [name, value] of Object.entries(oldPrefs)) { + if (value === null) { + Preferences.reset(name); + } else { + Preferences.set(name, value); + } + } + }, + + /* Get pref values. */ + async getPrefs(prefs) { + return Preferences.get(prefs); + }, + + /* Gets link color for a given selector. */ + async getLinkColor(tabId, selector) { + return getActorForTab(tabId, "TestSupport").sendQuery( + "GetLinkColor", + { selector } + ); + }, + + async getRequestedLocales() { + return Services.locale.requestedLocales; + }, + + async getPidForTab(tabId) { + const tab = context.extension.tabManager.get(tabId); + const pids = E10SUtils.getBrowserPids(tab.browser); + return pids[0]; + }, + + async getAllBrowserPids() { + const pids = []; + const processes = ChromeUtils.getAllDOMProcesses(); + for (const process of processes) { + if (process.remoteType && process.remoteType.startsWith("web")) { + pids.push(process.osPid); + } + } + return pids; + }, + + async killContentProcess(pid) { + const procs = ChromeUtils.getAllDOMProcesses(); + for (const proc of procs) { + if (pid === proc.osPid) { + proc + .getActor("TestSupportProcess") + .sendAsyncMessage("KillContentProcess"); + } + } + }, + + async addHistogram(id, value) { + return Services.telemetry.getHistogramById(id).add(value); + }, + + removeAllCertOverrides() { + const overrideService = Cc[ + "@mozilla.org/security/certoverride;1" + ].getService(Ci.nsICertOverrideService); + overrideService.clearAllOverrides(); + }, + + async setScalar(id, value) { + return Services.telemetry.scalarSet(id, value); + }, + + async setResolutionAndScaleTo(tabId, resolution) { + return getActorForTab(tabId, "TestSupport").sendQuery( + "SetResolutionAndScaleTo", + { + resolution, + } + ); + }, + + async getActive(tabId) { + const tab = context.extension.tabManager.get(tabId); + return tab.browser.docShellIsActive; + }, + + async getProfilePath() { + return PathUtils.profileDir; + }, + + async flushApzRepaints(tabId) { + // TODO: Note that `waitUntilApzStable` in apz_test_utils.js does + // flush APZ repaints in the parent process (i.e. calling + // nsIDOMWindowUtils.flushApzRepaints for the parent process) before + // flushApzRepaints is called for the target content document, if we + // still meet intermittent failures, we might want to do it here as + // well. + await getActorForTab(tabId, "TestSupport").sendQuery( + "FlushApzRepaints" + ); + }, + + async promiseAllPaintsDone(tabId) { + await getActorForTab(tabId, "TestSupport").sendQuery( + "PromiseAllPaintsDone" + ); + }, + + async usingGpuProcess() { + const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + return gfxInfo.usingGPUProcess; + }, + + async killGpuProcess() { + const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + return gfxInfo.killGPUProcessForTests(); + }, + + async crashGpuProcess() { + const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService( + Ci.nsIGfxInfo + ); + return gfxInfo.crashGPUProcessForTests(); + }, + + async clearHSTSState() { + const sss = Cc["@mozilla.org/ssservice;1"].getService( + Ci.nsISiteSecurityService + ); + return sss.clearAll(); + }, + + async triggerCookieBannerDetected(tabId) { + const actor = getActorForTab(tabId, "CookieBanner"); + return actor.receiveMessage({ + name: "CookieBanner::DetectedBanner", + }); + }, + + async triggerCookieBannerHandled(tabId) { + const actor = getActorForTab(tabId, "CookieBanner"); + return actor.receiveMessage({ + name: "CookieBanner::HandledBanner", + }); + }, + }, + }; + } +}; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json new file mode 100644 index 0000000000..d07e74cc10 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json @@ -0,0 +1,264 @@ +[ + { + "namespace": "test", + "description": "Additional APIs for test support in GeckoView.", + "functions": [ + { + "name": "setPrefs", + "type": "function", + "async": true, + "description": "Set prefs and return a set of saved prefs", + "parameters": [ + { + "name": "oldPrefs", + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" } + }, + { + "name": "newPrefs", + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" } + } + ] + }, + { + "name": "restorePrefs", + "type": "function", + "async": true, + "description": "Restore prefs to old value", + "parameters": [ + { + "type": "any", + "name": "oldPrefs" + } + ] + }, + { + "name": "getPrefs", + "type": "function", + "async": true, + "description": "Get pref values.", + "parameters": [ + { + "name": "prefs", + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + { + "name": "getLinkColor", + "type": "function", + "async": true, + "description": "Get resolved color for the link resolved by a given selector.", + "parameters": [ + { + "type": "number", + "name": "tabId" + }, + { + "type": "string", + "name": "selector" + } + ] + }, + { + "name": "getRequestedLocales", + "type": "function", + "async": true, + "description": "Gets the requested locales.", + "parameters": [] + }, + { + "name": "addHistogram", + "type": "function", + "async": true, + "description": "Add a sample with the given value to the histogram with the given id.", + "parameters": [ + { + "type": "string", + "name": "id" + }, + { + "type": "any", + "name": "value" + } + ] + }, + { + "name": "removeAllCertOverrides", + "type": "function", + "async": true, + "description": "Revokes SSL certificate overrides.", + "parameters": [] + }, + { + "name": "setScalar", + "type": "function", + "async": true, + "description": "Set the given value to the scalar with the given id.", + "parameters": [ + { + "type": "string", + "name": "id" + }, + { + "type": "any", + "name": "value" + } + ] + }, + { + "name": "setResolutionAndScaleTo", + "type": "function", + "async": true, + "description": "Invokes nsIDOMWindowUtils.setResolutionAndScaleTo.", + "parameters": [ + { + "type": "number", + "name": "tabId" + }, + { + "type": "number", + "name": "resolution" + } + ] + }, + { + "name": "getActive", + "type": "function", + "async": true, + "description": "Returns true if the docShell is active for given tab.", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + { + "name": "getPidForTab", + "type": "function", + "async": true, + "description": "Gets the top-level pid belonging to tabId.", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + { + "name": "getAllBrowserPids", + "type": "function", + "async": true, + "description": "Gets the list of pids of the running browser processes", + "parameters": [] + }, + { + "name": "getProfilePath", + "type": "function", + "async": true, + "description": "Gets the path of the current profile", + "parameters": [] + }, + { + "name": "killContentProcess", + "type": "function", + "async": true, + "description": "Crash all content processes", + "parameters": [ + { + "type": "number", + "name": "pid" + } + ] + }, + { + "name": "flushApzRepaints", + "type": "function", + "async": true, + "description": "Invokes nsIDOMWindowUtils.flushApzRepaints for the document of the tabId.", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + { + "name": "promiseAllPaintsDone", + "type": "function", + "async": true, + "description": "A simplified version of promiseAllPaintsDone in paint_listeners.js.", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + { + "name": "usingGpuProcess", + "type": "function", + "async": true, + "description": "Returns true if Gecko is using a GPU process.", + "parameters": [] + }, + + { + "name": "killGpuProcess", + "type": "function", + "async": true, + "description": "Kills the GPU process cleanly without generating a crash report.", + "parameters": [] + }, + + { + "name": "crashGpuProcess", + "type": "function", + "async": true, + "description": "Causes the GPU process to crash.", + "parameters": [] + }, + + { + "name": "clearHSTSState", + "type": "function", + "async": true, + "description": "Clears the sites on the HSTS list.", + "parameters": [] + }, + + { + "name": "triggerCookieBannerDetected", + "type": "function", + "async": true, + "description": "Simulates a cookie banner detection", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + + { + "name": "triggerCookieBannerHandled", + "type": "function", + "async": true, + "description": "Simulates a cookie banner handling", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + } + ] + } +] diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js new file mode 100644 index 0000000000..c0f073e81d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let backgroundPort = null; +let nativePort = null; + +window.addEventListener("pageshow", () => { + backgroundPort = browser.runtime.connect(); + nativePort = browser.runtime.connectNative("browser"); + + nativePort.onMessage.addListener(message => { + if (message.type) { + // This is a session-specific webExtensionApiCall. + // Forward to the background script. + backgroundPort.postMessage(message); + } else if (message.eval) { + try { + // Using eval here is the whole point of this WebExtension so we can + // safely ignore the eslint warning. + const response = window.eval(message.eval); // eslint-disable-line no-eval + sendResponse(message.id, response); + } catch (ex) { + sendSyncResponse(message.id, null, ex); + } + } + }); + + function sendResponse(id, response, exception) { + Promise.resolve(response).then( + value => sendSyncResponse(id, value), + reason => sendSyncResponse(id, null, reason) + ); + } + + function sendSyncResponse(id, response, exception) { + nativePort.postMessage({ + id, + response: JSON.stringify(response), + exception: exception && exception.toString(), + }); + } +}); + +window.addEventListener("pagehide", () => { + backgroundPort.disconnect(); + nativePort.disconnect(); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js new file mode 100644 index 0000000000..9c3728b381 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json new file mode 100644 index 0000000000..8e54cc4586 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update@example.com", + "update_url": "https://example.org/tests/junit/update_manifest.json" + } + }, + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js new file mode 100644 index 0000000000..3529928d82 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid blue"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json new file mode 100644 index 0000000000..19570ea5e5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update@example.com" + } + }, + "version": "2.0", + "description": "Adds a blue border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/background.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/background.js new file mode 100644 index 0000000000..a301506ca7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/background.js @@ -0,0 +1,3 @@ +browser.runtime.onUpdateAvailable.addListener(details => { + // Do nothing, this is just here to prevent auto update. +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/borderify.js new file mode 100644 index 0000000000..9c3728b381 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/manifest.json new file mode 100644 index 0000000000..5011e1ea05 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-postpone@example.com", + "update_url": "https://example.org/tests/junit/update_manifest.json" + } + }, + "background": { + "scripts": ["background.js"] + }, + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/borderify.js new file mode 100644 index 0000000000..3529928d82 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid blue"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/manifest.json new file mode 100644 index 0000000000..720d9ef898 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-postpone@example.com" + } + }, + "version": "2.0", + "description": "Adds a blue border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js new file mode 100644 index 0000000000..9c3728b381 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid red"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json new file mode 100644 index 0000000000..71b6a1eab9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-with-perms@example.com", + "update_url": "https://example.org/tests/junit/update_manifest.json" + } + }, + "version": "1.0", + "description": "Adds a red border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js new file mode 100644 index 0000000000..3529928d82 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js @@ -0,0 +1 @@ +document.body.style.border = "5px solid blue"; diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json new file mode 100644 index 0000000000..9571bdabb2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 2, + "name": "update", + "browser_specific_settings": { + "gecko": { + "id": "update-with-perms@example.com" + } + }, + "version": "2.0", + "description": "Adds a blue border to all webpages matching example.com.", + "content_scripts": [ + { + "matches": ["*://*.example.com/*"], + "js": ["borderify.js"] + } + ], + "permissions": ["tabs"] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-aria-comboboxes.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-aria-comboboxes.html new file mode 100644 index 0000000000..8816879c1a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-aria-comboboxes.html @@ -0,0 +1,11 @@ + + + + + +
+
+ +
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-checkbox.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-checkbox.html new file mode 100644 index 0000000000..a45cfed92b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-checkbox.html @@ -0,0 +1,12 @@ + + + + + + + +
description
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-clipboard.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-clipboard.html new file mode 100644 index 0000000000..c33b48f4e5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-clipboard.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-collection.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-collection.html new file mode 100644 index 0000000000..865594ae5b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-collection.html @@ -0,0 +1,21 @@ + + + + + + +
    +
  • One
  • +
  • Two
  • +
+
    +
  • + 1 +
      +
    • 1.1
    • +
    • 1.2
    • +
    +
  • +
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-expandable.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-expandable.html new file mode 100644 index 0000000000..8b416cf882 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-expandable.html @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-headings.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-headings.html new file mode 100644 index 0000000000..280bbd89d7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-headings.html @@ -0,0 +1,11 @@ + + + + + + preamble +

Fried cheese

with club sauce.

+

Popcorn shrimp

+

Chicken fingers

with spicy club sauce.

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-links.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-links.html new file mode 100644 index 0000000000..a108925dc1 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-links.html @@ -0,0 +1,12 @@ + + + + + + a with href + a with no attributes + a with name + a with onclick + span with role link + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-atomic.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-atomic.html new file mode 100644 index 0000000000..85f9f6ccd2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-atomic.html @@ -0,0 +1,12 @@ + + + + + + +
+ The time is +

3pm

+
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-descendant.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-descendant.html new file mode 100644 index 0000000000..82d88613f0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-descendant.html @@ -0,0 +1,9 @@ + + + + + + +

I will be shown

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image-labeled-by.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image-labeled-by.html new file mode 100644 index 0000000000..5b91f1f6c2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image-labeled-by.html @@ -0,0 +1,15 @@ + + + + + + + + Hello + Goodbye + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image.html new file mode 100644 index 0000000000..da05b33c9a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image.html @@ -0,0 +1,15 @@ + + + + + + +
+ This picture is + happy +
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region.html new file mode 100644 index 0000000000..c73fb91966 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-local-iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-local-iframe.html new file mode 100644 index 0000000000..0aff253395 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-local-iframe.html @@ -0,0 +1,21 @@ + + + + + + + + Some stuff + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-move-caret-accessibility-focus.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-move-caret-accessibility-focus.html new file mode 100644 index 0000000000..d9d1597991 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-move-caret-accessibility-focus.html @@ -0,0 +1,9 @@ + + + + + + +

Hello sweet, sweet world

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-mutation.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-mutation.html new file mode 100644 index 0000000000..5c9c68aca0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-mutation.html @@ -0,0 +1,9 @@ + + + + + + +

I will be shown

+ + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-range.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-range.html new file mode 100644 index 0000000000..70ef76e624 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-range.html @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-remote-iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-remote-iframe.html new file mode 100644 index 0000000000..7e3e5da1ca --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-remote-iframe.html @@ -0,0 +1,24 @@ + + + + + + + + Some stuff + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-scroll.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-scroll.html new file mode 100644 index 0000000000..912aab9143 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-scroll.html @@ -0,0 +1,10 @@ + + + +
+ +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. +

+ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-selectable.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-selectable.html new file mode 100644 index 0000000000..f30951ff83 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-selectable.html @@ -0,0 +1,22 @@ + + + + + + +
    +
  • + 1 +
  • +
  • 2
  • +
+
  • + outside selectable +
  • + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-text-entry-node.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-text-entry-node.html new file mode 100644 index 0000000000..002efc9f14 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-text-entry-node.html @@ -0,0 +1,11 @@ + + + + + + + +
    description
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-tree.html b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-tree.html new file mode 100644 index 0000000000..81ab105c7d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-tree.html @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/address_form.html b/mobile/android/geckoview/src/androidTest/assets/www/address_form.html new file mode 100644 index 0000000000..d247c5ce79 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/address_form.html @@ -0,0 +1,21 @@ + + + + Address form + + +
    + + + + + + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3 b/mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3 new file mode 100644 index 0000000000..9fafa32f93 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3 differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/autoplay.html b/mobile/android/geckoview/src/androidTest/assets/www/autoplay.html new file mode 100644 index 0000000000..24cbf474bd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/autoplay.html @@ -0,0 +1,11 @@ + + + + WEBM Video + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/badVideoPath.html b/mobile/android/geckoview/src/androidTest/assets/www/badVideoPath.html new file mode 100644 index 0000000000..d9b34843fd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/badVideoPath.html @@ -0,0 +1,11 @@ + + + + Bad Video Path + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/beforeunload.html b/mobile/android/geckoview/src/androidTest/assets/www/beforeunload.html new file mode 100644 index 0000000000..d521afe532 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/beforeunload.html @@ -0,0 +1,15 @@ + + + + + + + Click Me + Click Me + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/cc_form.html b/mobile/android/geckoview/src/androidTest/assets/www/cc_form.html new file mode 100644 index 0000000000..7b3ea2a1bb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/cc_form.html @@ -0,0 +1,22 @@ + + + + Form Autofill Test: Credit Card + + +
    + + + + + +
    + +
    + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/clickToReload.html b/mobile/android/geckoview/src/androidTest/assets/www/clickToReload.html new file mode 100644 index 0000000000..47bdceccee --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/clickToReload.html @@ -0,0 +1,10 @@ + + + + Hello, world! + + + +

    Hello, world!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html b/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html new file mode 100644 index 0000000000..19a034a23d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html @@ -0,0 +1,22 @@ + + + + Hello, world! + + + +

    Hello, world!

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/color_grid.html b/mobile/android/geckoview/src/androidTest/assets/www/color_grid.html new file mode 100644 index 0000000000..ebc989acdb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/color_grid.html @@ -0,0 +1,40 @@ + + + + + Color Grid + + + + +
    +
    +
    +
    +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/color_orange_background.html b/mobile/android/geckoview/src/androidTest/assets/www/color_orange_background.html new file mode 100644 index 0000000000..8a682d79a7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/color_orange_background.html @@ -0,0 +1,29 @@ + + + + + Orange Print Background + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/colors.html b/mobile/android/geckoview/src/androidTest/assets/www/colors.html new file mode 100644 index 0000000000..b00da3ed9c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/colors.html @@ -0,0 +1,23 @@ + + + + Colours + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_audio.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_audio.html new file mode 100644 index 0000000000..b26323a13e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_audio.html @@ -0,0 +1,20 @@ + + + + + Context Menu Test Audio + + + +
    + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_buffered.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_buffered.html new file mode 100644 index 0000000000..9849747a41 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_buffered.html @@ -0,0 +1,44 @@ + + + + + Context Menu Test Blob Buffered + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_full.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_full.html new file mode 100644 index 0000000000..5ebc2bddba --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_full.html @@ -0,0 +1,22 @@ + + + + + Context Menu Test Blob + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image.html new file mode 100644 index 0000000000..9564f94628 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image.html @@ -0,0 +1,10 @@ + + + + + Context Menu Test Image + + + Test Image + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image_nested.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image_nested.html new file mode 100644 index 0000000000..99563d66f5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_image_nested.html @@ -0,0 +1,14 @@ + + + + + Context Menu Test Nested Image + + +
    +
    + Test Image +
    +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_link.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_link.html new file mode 100644 index 0000000000..e5b0d0d316 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_link.html @@ -0,0 +1,15 @@ + + + + + Context Menu Test Link + + + + Hello World + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/context_menu_video.html b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_video.html new file mode 100644 index 0000000000..bca8e46afe --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/context_menu_video.html @@ -0,0 +1,12 @@ + + + + + Context Menu Test Video + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/data_uri.html b/mobile/android/geckoview/src/androidTest/assets/www/data_uri.html new file mode 100644 index 0000000000..638e4c754c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/data_uri.html @@ -0,0 +1,14 @@ + + + + Link with a giant data URI + + + Open small link + Open large link + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/download.html b/mobile/android/geckoview/src/androidTest/assets/www/download.html new file mode 100644 index 0000000000..4f06323dc6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/download.html @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fixedbottom.html b/mobile/android/geckoview/src/androidTest/assets/www/fixedbottom.html new file mode 100644 index 0000000000..b802bb335b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fixedbottom.html @@ -0,0 +1,36 @@ + + + + + Fixed bottom element + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fixedpercent.html b/mobile/android/geckoview/src/androidTest/assets/www/fixedpercent.html new file mode 100644 index 0000000000..587df00473 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fixedpercent.html @@ -0,0 +1,25 @@ + + + + +
    diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fixedvh.html b/mobile/android/geckoview/src/androidTest/assets/www/fixedvh.html new file mode 100644 index 0000000000..fd6661c2cd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fixedvh.html @@ -0,0 +1,25 @@ + + + + +
    diff --git a/mobile/android/geckoview/src/androidTest/assets/www/form_blank.html b/mobile/android/geckoview/src/androidTest/assets/www/form_blank.html new file mode 100644 index 0000000000..918cc4cb7a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/form_blank.html @@ -0,0 +1,20 @@ + + + + + Forms + + + +
    + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms.html b/mobile/android/geckoview/src/androidTest/assets/www/forms.html new file mode 100644 index 0000000000..06c2ed64db --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms.html @@ -0,0 +1,34 @@ + + + + Forms + + + +
    + + + + + + +
    + + + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms2.html b/mobile/android/geckoview/src/androidTest/assets/www/forms2.html new file mode 100644 index 0000000000..06ab5ec448 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms2.html @@ -0,0 +1,17 @@ + + + + Forms2 + + +
    +
    + + + + +
    +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms2_iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/forms2_iframe.html new file mode 100644 index 0000000000..849fa43271 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms2_iframe.html @@ -0,0 +1,16 @@ + + + + Forms2 iframe + + +
    +
    + + + + +
    +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms3.html b/mobile/android/geckoview/src/androidTest/assets/www/forms3.html new file mode 100644 index 0000000000..91bceb3943 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms3.html @@ -0,0 +1,14 @@ + + + + Forms + + + +
    + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms4.html b/mobile/android/geckoview/src/androidTest/assets/www/forms4.html new file mode 100644 index 0000000000..3650635396 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms4.html @@ -0,0 +1,14 @@ + + + + Forms + + + +
    + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms5.html b/mobile/android/geckoview/src/androidTest/assets/www/forms5.html new file mode 100644 index 0000000000..b9da67f343 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms5.html @@ -0,0 +1,24 @@ + + + + Forms + + + +
    + + + + + + +
    + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete.html b/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete.html new file mode 100644 index 0000000000..81401a1d27 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete.html @@ -0,0 +1,16 @@ + + + + Forms + + + +
    + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete_iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete_iframe.html new file mode 100644 index 0000000000..11137531ba --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete_iframe.html @@ -0,0 +1,15 @@ + + + + Forms + + + +
    + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms_id_value.html b/mobile/android/geckoview/src/androidTest/assets/www/forms_id_value.html new file mode 100644 index 0000000000..522dbc1600 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms_id_value.html @@ -0,0 +1,12 @@ + + + + Forms ID Value + + + +
    + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms_iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/forms_iframe.html new file mode 100644 index 0000000000..2c0ef7dff5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms_iframe.html @@ -0,0 +1,58 @@ + + + + + Forms iframe + + + +
    + + + + + + +
    + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/forms_xorigin.html b/mobile/android/geckoview/src/androidTest/assets/www/forms_xorigin.html new file mode 100644 index 0000000000..ebd86c59a1 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/forms_xorigin.html @@ -0,0 +1,77 @@ + + + + + Forms + + + +
    + + + + + + +
    + + + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fullscreen.html b/mobile/android/geckoview/src/androidTest/assets/www/fullscreen.html new file mode 100644 index 0000000000..f7d4feb3a4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fullscreen.html @@ -0,0 +1,9 @@ + + + + Fullscreen + + +
    Fullscreen Div
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_container.html b/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_container.html new file mode 100644 index 0000000000..2ba4a89b54 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_container.html @@ -0,0 +1,58 @@ + + + + GetUserMedia from cross-origin iframe: the container document + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_iframe.html new file mode 100644 index 0000000000..3649167c25 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_iframe.html @@ -0,0 +1,39 @@ + + + + GetUserMedia from cross-origin iframe: the iframe document + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/hello.html b/mobile/android/geckoview/src/androidTest/assets/www/hello.html new file mode 100644 index 0000000000..5ebd20f929 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/hello.html @@ -0,0 +1,10 @@ + + + + Hello, world! + + + +

    Hello, world!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/hello2.html b/mobile/android/geckoview/src/androidTest/assets/www/hello2.html new file mode 100644 index 0000000000..d03c2d5521 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/hello2.html @@ -0,0 +1,9 @@ + + + + Hello, world! Again! + + +

    Hello, world! Again!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf b/mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf new file mode 100755 index 0000000000..0f429e1a90 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/hsts_header.sjs b/mobile/android/geckoview/src/androidTest/assets/www/hsts_header.sjs new file mode 100644 index 0000000000..e53ad908fa --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/hsts_header.sjs @@ -0,0 +1,6 @@ +function handleRequest(request, response) { + response.setHeader( + "Strict-Transport-Security", + "max-age=60; includeSubDomains" + ); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/hungScript.html b/mobile/android/geckoview/src/androidTest/assets/www/hungScript.html new file mode 100644 index 0000000000..6b56f4e2e7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/hungScript.html @@ -0,0 +1,16 @@ + + + + Hung Script + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_no_scrollable.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_no_scrollable.html new file mode 100644 index 0000000000..3e7bd5cdd0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_no_scrollable.html @@ -0,0 +1,60 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_scrollable.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_scrollable.html new file mode 100644 index 0000000000..e7517c5f12 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_scrollable.html @@ -0,0 +1,60 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_no_scrollable.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_no_scrollable.html new file mode 100644 index 0000000000..9766f41b7f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_no_scrollable.html @@ -0,0 +1,55 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_scrollable.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_scrollable.html new file mode 100644 index 0000000000..ca356958df --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_scrollable.html @@ -0,0 +1,55 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_hello.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_hello.html new file mode 100644 index 0000000000..ee4962a2b7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_hello.html @@ -0,0 +1,10 @@ + + + + Hello, world! + + +

    Hello, world! From Top Level.

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_http_only.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_http_only.html new file mode 100644 index 0000000000..8f94d6c86d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_http_only.html @@ -0,0 +1,14 @@ + + + + + + + Some stuff + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_automation.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_automation.html new file mode 100644 index 0000000000..61b81cf563 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_automation.html @@ -0,0 +1,12 @@ + + + + + + + Some stuff + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_local.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_local.html new file mode 100644 index 0000000000..eb109536f0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_local.html @@ -0,0 +1,10 @@ + + + + + + + Some stuff + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/iframe_unknown_protocol.html b/mobile/android/geckoview/src/androidTest/assets/www/iframe_unknown_protocol.html new file mode 100644 index 0000000000..81fb616b60 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/iframe_unknown_protocol.html @@ -0,0 +1,10 @@ + + + + Hello, world! + + +

    Hello, world! From Top Level.

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/images/test.gif b/mobile/android/geckoview/src/androidTest/assets/www/images/test.gif new file mode 100644 index 0000000000..ba3b541c31 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/images/test.gif differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/inputs.html b/mobile/android/geckoview/src/androidTest/assets/www/inputs.html new file mode 100644 index 0000000000..554c6c8143 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/inputs.html @@ -0,0 +1,66 @@ + + + + Inputs + + + +
    lorem
    + + +
    sit
    + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/links.html b/mobile/android/geckoview/src/androidTest/assets/www/links.html new file mode 100644 index 0000000000..186426b0e2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/links.html @@ -0,0 +1,28 @@ + + + + Links + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/loremIpsum.html b/mobile/android/geckoview/src/androidTest/assets/www/loremIpsum.html new file mode 100644 index 0000000000..e772f605f0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/loremIpsum.html @@ -0,0 +1,17 @@ + + + + Lorem ipsum + + +

    + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim + veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. Duis aute irure dolor in reprehenderit in voluptate + velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia deserunt mollit + anim id est laborum. +

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/manifest.webmanifest b/mobile/android/geckoview/src/androidTest/assets/www/manifest.webmanifest new file mode 100644 index 0000000000..5528465ba2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "App", + "short_name": "app", + "start_url": "./start/index.html", + "display": "standalone", + "background_color": "#c0ffeeee", + "theme_color": "cadetblue", + "icons": [{ + "src": "images/test.gif", + "sizes": "192x192", + "type": "image/gif" + }], + "related_applications": [{ + "platform": "play", + "url": "https://play.google.com/store/apps/details?id=my.first.webapp" + }] +} \ No newline at end of file diff --git a/mobile/android/geckoview/src/androidTest/assets/www/media_session_default1.html b/mobile/android/geckoview/src/androidTest/assets/www/media_session_default1.html new file mode 100644 index 0000000000..3d6554012b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/media_session_default1.html @@ -0,0 +1,15 @@ + + + MediaSessionDefaultTest1 + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/media_session_dom1.html b/mobile/android/geckoview/src/androidTest/assets/www/media_session_dom1.html new file mode 100644 index 0000000000..8fa9584428 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/media_session_dom1.html @@ -0,0 +1,109 @@ + + + MediaSessionDOMTest1 + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/metatags.html b/mobile/android/geckoview/src/androidTest/assets/www/metatags.html new file mode 100644 index 0000000000..946c9faf27 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/metatags.html @@ -0,0 +1,19 @@ + + + + + MetaTags + + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/mouseToReload.html b/mobile/android/geckoview/src/androidTest/assets/www/mouseToReload.html new file mode 100644 index 0000000000..fef911a926 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/mouseToReload.html @@ -0,0 +1,10 @@ + + + + Hello, world! + + + +

    Hello, world!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/mp4.html b/mobile/android/geckoview/src/androidTest/assets/www/mp4.html new file mode 100644 index 0000000000..09909fac69 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/mp4.html @@ -0,0 +1,11 @@ + + + + MP4 Video + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/newSession.html b/mobile/android/geckoview/src/androidTest/assets/www/newSession.html new file mode 100644 index 0000000000..b92657430c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/newSession.html @@ -0,0 +1,22 @@ + + + + Hello, world! + + + target="_blank" + rel="noopener" + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/newSession_child.html b/mobile/android/geckoview/src/androidTest/assets/www/newSession_child.html new file mode 100644 index 0000000000..28fd019804 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/newSession_child.html @@ -0,0 +1,9 @@ + + + + Hello, world! + + +

    I'm the child

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/no-meta-viewport.html b/mobile/android/geckoview/src/androidTest/assets/www/no-meta-viewport.html new file mode 100644 index 0000000000..8f1cb8fa80 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/no-meta-viewport.html @@ -0,0 +1,5 @@ + + + +

    Nothing here

    + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/ogg.html b/mobile/android/geckoview/src/androidTest/assets/www/ogg.html new file mode 100644 index 0000000000..dd478d3b3f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/ogg.html @@ -0,0 +1,11 @@ + + + + OGG Video + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto-none.html b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto-none.html new file mode 100644 index 0000000000..ff180f961a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto-none.html @@ -0,0 +1,28 @@ + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto.html b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto.html new file mode 100644 index 0000000000..6f2b3ee92a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto.html @@ -0,0 +1,28 @@ + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-auto.html b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-auto.html new file mode 100644 index 0000000000..ff6366ccda --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-auto.html @@ -0,0 +1,28 @@ + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-on-non-root.html b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-on-non-root.html new file mode 100644 index 0000000000..fbe2269c19 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-on-non-root.html @@ -0,0 +1,37 @@ + + + + + + +
    +
    +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/popup.html b/mobile/android/geckoview/src/androidTest/assets/www/popup.html new file mode 100644 index 0000000000..7e52870df5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/popup.html @@ -0,0 +1,12 @@ + + + + Hello, world! + + +

    Launching popup...

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/print_content_change.html b/mobile/android/geckoview/src/androidTest/assets/www/print_content_change.html new file mode 100644 index 0000000000..ae36a6c6b8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/print_content_change.html @@ -0,0 +1,37 @@ + + + + + Orange Print Background Removal + + + + +
    + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/print_iframe.html b/mobile/android/geckoview/src/androidTest/assets/www/print_iframe.html new file mode 100644 index 0000000000..b7dd83f2a5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/print_iframe.html @@ -0,0 +1,39 @@ + + + + + Print iframes + + + + +
    + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/prompts.html b/mobile/android/geckoview/src/androidTest/assets/www/prompts.html new file mode 100644 index 0000000000..53e8f96b04 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/prompts.html @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/push/push.html b/mobile/android/geckoview/src/androidTest/assets/www/push/push.html new file mode 100644 index 0000000000..ccd091eaea --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/push/push.html @@ -0,0 +1,10 @@ + + + + Push API test + + +

    Hello, world!

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/push/push.js b/mobile/android/geckoview/src/androidTest/assets/www/push/push.js new file mode 100644 index 0000000000..d9322d11cc --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/push/push.js @@ -0,0 +1,44 @@ +window.doSubscribe = async function (applicationServerKey) { + const registration = await navigator.serviceWorker.register("./sw.js"); + const sub = await registration.pushManager.subscribe({ + applicationServerKey, + }); + return sub.toJSON(); +}; + +window.doGetSubscription = async function () { + const registration = await navigator.serviceWorker.register("./sw.js"); + const sub = await registration.pushManager.getSubscription(); + if (sub) { + return sub.toJSON(); + } + + return null; +}; + +window.doUnsubscribe = async function () { + const registration = await navigator.serviceWorker.register("./sw.js"); + const sub = await registration.pushManager.getSubscription(); + sub.unsubscribe(); + return {}; +}; + +window.doWaitForPushEvent = function () { + return new Promise(resolve => { + navigator.serviceWorker.addEventListener("message", function (e) { + if (e.data.type === "push") { + resolve(e.data.payload); + } + }); + }); +}; + +window.doWaitForSubscriptionChange = function () { + return new Promise(resolve => { + navigator.serviceWorker.addEventListener("message", function (e) { + if (e.data.type === "pushsubscriptionchange") { + resolve(e.data.type); + } + }); + }); +}; diff --git a/mobile/android/geckoview/src/androidTest/assets/www/push/sw.js b/mobile/android/geckoview/src/androidTest/assets/www/push/sw.js new file mode 100644 index 0000000000..2e51383205 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/push/sw.js @@ -0,0 +1,30 @@ +self.addEventListener("install", function () { + self.skipWaiting(); +}); + +self.addEventListener("activate", function (e) { + e.waitUntil(self.clients.claim()); +}); + +self.addEventListener("push", async function (e) { + const clients = await self.clients.matchAll(); + let text = ""; + if (e.data) { + text = e.data.text(); + } + clients.forEach(function (client) { + client.postMessage({ type: "push", payload: text }); + }); + + try { + const { title, body } = e.data.json(); + self.registration.showNotification(title, { body }); + } catch (e) {} +}); + +self.addEventListener("pushsubscriptionchange", async function (e) { + const clients = await self.clients.matchAll(); + clients.forEach(function (client) { + client.postMessage({ type: "pushsubscriptionchange" }); + }); +}); diff --git a/mobile/android/geckoview/src/androidTest/assets/www/red-background-body-fully-covered-by-green-element.html b/mobile/android/geckoview/src/androidTest/assets/www/red-background-body-fully-covered-by-green-element.html new file mode 100644 index 0000000000..ad6c96599e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/red-background-body-fully-covered-by-green-element.html @@ -0,0 +1,23 @@ + + + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/reflect_local_storage_into_title.html b/mobile/android/geckoview/src/androidTest/assets/www/reflect_local_storage_into_title.html new file mode 100644 index 0000000000..749678c668 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/reflect_local_storage_into_title.html @@ -0,0 +1,17 @@ + + + + no title + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/resubmit.html b/mobile/android/geckoview/src/androidTest/assets/www/resubmit.html new file mode 100644 index 0000000000..6155270f1b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/resubmit.html @@ -0,0 +1,12 @@ + + + + + + +
    + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/root_100_percent_height.html b/mobile/android/geckoview/src/androidTest/assets/www/root_100_percent_height.html new file mode 100644 index 0000000000..e91c997bbb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/root_100_percent_height.html @@ -0,0 +1,37 @@ + + + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/root_100vh.html b/mobile/android/geckoview/src/androidTest/assets/www/root_100vh.html new file mode 100644 index 0000000000..e6c7fef374 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/root_100vh.html @@ -0,0 +1,36 @@ + + + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/root_98vh.html b/mobile/android/geckoview/src/androidTest/assets/www/root_98vh.html new file mode 100644 index 0000000000..a654353d64 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/root_98vh.html @@ -0,0 +1,36 @@ + + + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/saveState.html b/mobile/android/geckoview/src/androidTest/assets/www/saveState.html new file mode 100644 index 0000000000..c85b528f01 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/saveState.html @@ -0,0 +1,18 @@ + + + + Hello, world! + + + + +
    + +
    +

    Hello, world!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html b/mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html new file mode 100644 index 0000000000..98018f1a24 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html @@ -0,0 +1,35 @@ + + + + + + +
    +
    +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/scroll.html b/mobile/android/geckoview/src/androidTest/assets/www/scroll.html new file mode 100644 index 0000000000..e906e45686 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/scroll.html @@ -0,0 +1,59 @@ + + + + + + + +
    +
    +
    +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/select-listbox.html b/mobile/android/geckoview/src/androidTest/assets/www/select-listbox.html new file mode 100644 index 0000000000..5832954d2e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/select-listbox.html @@ -0,0 +1,7 @@ + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/select-multiple.html b/mobile/android/geckoview/src/androidTest/assets/www/select-multiple.html new file mode 100644 index 0000000000..bb9470fffd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/select-multiple.html @@ -0,0 +1,7 @@ + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/select.html b/mobile/android/geckoview/src/androidTest/assets/www/select.html new file mode 100644 index 0000000000..e8d28253d2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/select.html @@ -0,0 +1,6 @@ + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html new file mode 100644 index 0000000000..132155c6a1 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html new file mode 100644 index 0000000000..f1121e63d2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html @@ -0,0 +1,41 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/showDynamicToolbar.html b/mobile/android/geckoview/src/androidTest/assets/www/showDynamicToolbar.html new file mode 100644 index 0000000000..f6b0dd340c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/showDynamicToolbar.html @@ -0,0 +1,96 @@ + + + + + + showDynamicToolbar test content + + + +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    +

    Paragraph

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/simple_redirect.sjs b/mobile/android/geckoview/src/androidTest/assets/www/simple_redirect.sjs new file mode 100644 index 0000000000..43fec90b5a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/simple_redirect.sjs @@ -0,0 +1,4 @@ +function handleRequest(request, response) { + response.setStatusLine(request.httpVersion, 301, "Moved Permanently"); + response.setHeader("Location", request.queryString, false); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/titleChange.html b/mobile/android/geckoview/src/androidTest/assets/www/titleChange.html new file mode 100644 index 0000000000..51f8c936b6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/titleChange.html @@ -0,0 +1,16 @@ + + + + +
    Title1
    + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/touch-action-wheel-listener.html b/mobile/android/geckoview/src/androidTest/assets/www/touch-action-wheel-listener.html new file mode 100644 index 0000000000..cfc9489d17 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/touch-action-wheel-listener.html @@ -0,0 +1,33 @@ + + + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/touch-action.html b/mobile/android/geckoview/src/androidTest/assets/www/touch-action.html new file mode 100644 index 0000000000..62266b6ef7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/touch-action.html @@ -0,0 +1,48 @@ + + + + + + +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/touch.html b/mobile/android/geckoview/src/androidTest/assets/www/touch.html new file mode 100644 index 0000000000..ba3bc098a9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/touch.html @@ -0,0 +1,58 @@ + + + + + + + +
    +
    +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/touch_xorigin.html b/mobile/android/geckoview/src/androidTest/assets/www/touch_xorigin.html new file mode 100644 index 0000000000..89f3762aef --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/touch_xorigin.html @@ -0,0 +1,16 @@ + + + + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/touchstart.html b/mobile/android/geckoview/src/androidTest/assets/www/touchstart.html new file mode 100644 index 0000000000..9ee1f461a7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/touchstart.html @@ -0,0 +1,37 @@ + + + + + + + +
    +
    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf b/mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf new file mode 100644 index 0000000000..4dcf129d65 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/trackers.html b/mobile/android/geckoview/src/androidTest/assets/www/trackers.html new file mode 100644 index 0000000000..56ea43979a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/trackers.html @@ -0,0 +1,14 @@ + + + + Trackers + + +

    Trackers

    + + + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif b/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif new file mode 100644 index 0000000000..e565824aaf Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json b/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json new file mode 100644 index 0000000000..7b2de1f278 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json @@ -0,0 +1,40 @@ +{ + "addons": { + "update@example.com": { + "updates": [ + { + "version": "1.0", + "update_link": "https://example.org/tests/junit/update-1.xpi" + }, + { + "version": "2.0", + "update_link": "https://example.org/tests/junit/update-2.xpi" + } + ] + }, + "update-postpone@example.com": { + "updates": [ + { + "version": "1.0", + "update_link": "https://example.org/tests/junit/update-postpone-1.xpi" + }, + { + "version": "2.0", + "update_link": "https://example.org/tests/junit/update-postpone-2.xpi" + } + ] + }, + "update-with-perms@example.com": { + "updates": [ + { + "version": "1.0", + "update_link": "https://example.org/tests/junit/update-with-perms-1.xpi" + }, + { + "version": "2.0", + "update_link": "https://example.org/tests/junit/update-with-perms-2.xpi" + } + ] + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm b/mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm new file mode 100644 index 0000000000..518531a93f Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 b/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 new file mode 100644 index 0000000000..a674b7eb68 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg b/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg new file mode 100644 index 0000000000..ac7ece3519 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg differ diff --git a/mobile/android/geckoview/src/androidTest/assets/www/viewport.html b/mobile/android/geckoview/src/androidTest/assets/www/viewport.html new file mode 100644 index 0000000000..a5dfa0f64f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/viewport.html @@ -0,0 +1,19 @@ + + + + + + + +
    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/webm.html b/mobile/android/geckoview/src/androidTest/assets/www/webm.html new file mode 100644 index 0000000000..f329582575 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/webm.html @@ -0,0 +1,11 @@ + + + + WebM Video + + + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.html b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.html new file mode 100644 index 0000000000..d71eb0484d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.html @@ -0,0 +1,10 @@ + + + + Open Window test + + +

    Hello, world!

    + + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.js b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.js new file mode 100644 index 0000000000..921cff5b09 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.js @@ -0,0 +1,15 @@ +navigator.serviceWorker.register("./service-worker.js", { + scope: ".", +}); + +function showNotification() { + Notification.requestPermission(function (result) { + if (result === "granted") { + navigator.serviceWorker.ready.then(function (registration) { + registration.showNotification("Open Window Notification", { + body: "Hello", + }); + }); + } + }); +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window_target.html b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window_target.html new file mode 100644 index 0000000000..14775aafac --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/worker/open_window_target.html @@ -0,0 +1,9 @@ + + + + Open Window test target + + +

    Hello, world!

    + + diff --git a/mobile/android/geckoview/src/androidTest/assets/www/worker/service-worker.js b/mobile/android/geckoview/src/androidTest/assets/www/worker/service-worker.js new file mode 100644 index 0000000000..e3fbbb6388 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/worker/service-worker.js @@ -0,0 +1,15 @@ +self.addEventListener("install", function () { + console.log("install"); + self.skipWaiting(); +}); + +self.addEventListener("activate", function (e) { + console.log("activate"); + e.waitUntil(self.clients.claim()); +}); + +self.onnotificationclick = function (event) { + console.log("onnotificationclick"); + self.clients.openWindow("open_window_target.html"); + event.notification.close(); +}; diff --git a/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java b/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java new file mode 100644 index 0000000000..e032950063 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java @@ -0,0 +1,14 @@ +/* -*- 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 android.view.inputmethod; + +/** + * This dummy class is used when running tests on Android versions prior to 21, when the + * CursorAnchorInfo class was first introduced. Without this class, tests will crash with + * ClassNotFoundException when the test rule uses reflection to access the TextInputDelegate + * interface. + */ +public class CursorAnchorInfo {} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java new file mode 100644 index 0000000000..98d43238a7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java @@ -0,0 +1,167 @@ +package org.mozilla.geckoview; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.MediumTest; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mozilla.geckoview.test.BaseSessionTest; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class GeckoInputStreamTest extends BaseSessionTest { + + @Test + public void readAndWriteFile() throws IOException, ExecutionException, InterruptedException { + final byte[] originalBytes = getTestBytes(TEST_GIF_PATH); + final File createdFile = File.createTempFile("temp", ".gif"); + final GeckoInputStream geckoInputStream = new GeckoInputStream(null); + + // Reads from the GeckoInputStream and rewrites to a new file + final Thread readAndRewrite = + new Thread() { + public void run() { + try (OutputStream output = new FileOutputStream(createdFile)) { + byte[] buffer = new byte[4 * 1024]; + int read; + while ((read = geckoInputStream.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + output.flush(); + geckoInputStream.close(); + } catch (IOException e) { + throw new RuntimeException(e.getMessage()); + } + } + }; + + // Writes the bytes from the original file to the GeckoInputStream + final Thread write = + new Thread() { + public void run() { + try { + geckoInputStream.appendBuffer(originalBytes); + } catch (IOException e) { + throw new RuntimeException(e.getMessage()); + } + geckoInputStream.sendEof(); + } + }; + + final CompletableFuture testReadWrite = + CompletableFuture.allOf( + CompletableFuture.runAsync(readAndRewrite), CompletableFuture.runAsync(write)); + testReadWrite.get(); + + final byte[] fileContent = new byte[(int) createdFile.length()]; + final FileInputStream fis = new FileInputStream(createdFile); + fis.read(fileContent); + fis.close(); + + Assert.assertTrue("File was recreated correctly.", Arrays.equals(originalBytes, fileContent)); + } + + class Writer implements Runnable { + final char threadName; + final int timesToRun; + final GeckoInputStream stream; + + public Writer(char threadName, int timesToRun, GeckoInputStream stream) { + this.threadName = threadName; + this.timesToRun = timesToRun; + this.stream = stream; + } + + public void run() { + for (int i = 0; i <= timesToRun; i++) { + final byte[] data = String.format("%s %d %n", threadName, i).getBytes(); + try { + stream.appendBuffer(data); + } catch (IOException e) { + throw new RuntimeException(e.getMessage()); + } + } + } + } + + private boolean isSequenceInOrder( + List lines, List threadNames, int dataLength) { + HashMap lastValue = new HashMap<>(); + for (Character thread : threadNames) { + lastValue.put(thread, -1); + } + for (String line : lines) { + final char thread = line.charAt(0); + final int number = Integer.parseInt(line.replaceAll("[\\D]", "")); + + // Number should always be in sequence for a given thread + if (lastValue.get(thread) + 1 == number) { + lastValue.replace(thread, number); + } else { + return false; + } + } + for (Character thread : threadNames) { + if (lastValue.get(thread) != dataLength) { + return false; + } + } + return true; + } + + @Test + public void multipleWriters() throws ExecutionException, InterruptedException, IOException { + final GeckoInputStream geckoInputStream = new GeckoInputStream(null); + final List threadNames = Arrays.asList('A', 'B'); + final int writeCount = 1000; + final CompletableFuture writers = + CompletableFuture.allOf( + CompletableFuture.runAsync( + new Writer(threadNames.get(0), writeCount, geckoInputStream)), + CompletableFuture.runAsync( + new Writer(threadNames.get(1), writeCount, geckoInputStream))); + writers.get(); + geckoInputStream.sendEof(); + + final List lines = new ArrayList<>(); + final BufferedReader reader = new BufferedReader(new InputStreamReader(geckoInputStream)); + while (reader.ready()) { + lines.add(reader.readLine()); + } + reader.close(); + + Assert.assertTrue( + "Writers wrote as expected.", isSequenceInOrder(lines, threadNames, writeCount)); + } + + @Test + public void writeError() throws IOException { + boolean didThrowIoException = false; + final GeckoInputStream inputStream = new GeckoInputStream(null); + final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + final byte[] data = "Hello, World.".getBytes(); + inputStream.appendBuffer(data); + inputStream.writeError(); + inputStream.sendEof(); + try { + reader.readLine(); + } catch (IOException e) { + didThrowIoException = true; + } + reader.close(); + Assert.assertTrue("Correctly caused an IOException from writer.", didThrowIoException); + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt new file mode 100644 index 0000000000..0f1fa260cb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt @@ -0,0 +1,2275 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.Rect +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.text.InputType +import android.util.SparseLongArray +import android.view.View +import android.view.ViewGroup +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityNodeProvider +import android.view.accessibility.AccessibilityRecord +import android.widget.EditText +import android.widget.FrameLayout +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.After +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ShouldContinue +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +const val DISPLAY_WIDTH = 480 +const val DISPLAY_HEIGHT = 640 + +@RunWith(AndroidJUnit4::class) +@MediumTest +@WithDisplay(width = DISPLAY_WIDTH, height = DISPLAY_HEIGHT) +class AccessibilityTest : BaseSessionTest() { + lateinit var view: View + val screenRect = Rect(0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT) + val provider: AccessibilityNodeProvider get() = view.accessibilityNodeProvider + private val nodeInfos = mutableListOf() + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + // Given a child ID, return the virtual descendent ID. + private fun getVirtualDescendantId(childId: Long): Int { + try { + val getVirtualDescendantIdMethod = + AccessibilityNodeInfo::class.java.getMethod("getVirtualDescendantId", Long::class.java) + val virtualDescendantId = getVirtualDescendantIdMethod.invoke(null, childId) as Int + return if (virtualDescendantId == Int.MAX_VALUE) -1 else virtualDescendantId + } catch (ex: Exception) { + return 0 + } + } + + // Retrieve the virtual descendent ID of the event's source. + private fun getSourceId(event: AccessibilityEvent): Int { + try { + val getSourceIdMethod = + AccessibilityRecord::class.java.getMethod("getSourceNodeId") + return getVirtualDescendantId(getSourceIdMethod.invoke(event) as Long) + } catch (ex: Exception) { + return 0 + } + } + + private fun createNodeInfo(id: Int): AccessibilityNodeInfo { + val node = provider.createAccessibilityNodeInfo(id) + nodeInfos.add(node!!) + return node + } + + // Get a child ID by index. + private fun AccessibilityNodeInfo.getChildId(index: Int): Int { + try { + val field = AccessibilityNodeInfo::class.java.getDeclaredField("mChildNodeIds") + field.setAccessible(true) + val id = Class.forName("android.util.LongArray").getMethod("get", Int::class.java).invoke(field.get(this), index) as Long + return getVirtualDescendantId(id) + } catch (ex: Exception) { + return getVirtualDescendantId( + if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo::class.java.getMethod( + "getChildId", + Int::class.java, + ).invoke(this, index) as Long + } else { + ( + AccessibilityNodeInfo::class.java.getMethod("getChildNodeIds") + .invoke(this) as SparseLongArray + ).get(index) + }, + ) + } + } + + private interface EventDelegate { + fun onAccessibilityFocused(event: AccessibilityEvent) { } + fun onAccessibilityFocusCleared(event: AccessibilityEvent) { } + fun onClicked(event: AccessibilityEvent) { } + fun onFocused(event: AccessibilityEvent) { } + fun onSelected(event: AccessibilityEvent) { } + fun onScrolled(event: AccessibilityEvent) { } + fun onTextSelectionChanged(event: AccessibilityEvent) { } + fun onTextChanged(event: AccessibilityEvent) { } + fun onTextTraversal(event: AccessibilityEvent) { } + fun onWinContentChanged(event: AccessibilityEvent) { } + fun onWinStateChanged(event: AccessibilityEvent) { } + fun onAnnouncement(event: AccessibilityEvent) { } + } + + @Before fun setup() { + // We initialize a view with a parent and grandparent so that the + // accessibility events propagate up at least to the parent. + val context = InstrumentationRegistry.getInstrumentation().targetContext + view = FrameLayout(context) + FrameLayout(context).addView(view) + FrameLayout(context).addView(view.parent as View) + + // Force on accessibility and assign the session's accessibility + // object a view. + sessionRule.runtime.settings.forceEnableAccessibility = true + mainSession.accessibility.view = view + + // Set up an external delegate that will intercept accessibility events. + sessionRule.addExternalDelegateUntilTestEnd( + EventDelegate::class, + { newDelegate -> + (view.parent as View).setAccessibilityDelegate(object : View.AccessibilityDelegate() { + override fun onRequestSendAccessibilityEvent(host: ViewGroup, child: View, event: AccessibilityEvent): Boolean { + when (event.eventType) { + AccessibilityEvent.TYPE_VIEW_FOCUSED -> newDelegate.onFocused(event) + AccessibilityEvent.TYPE_VIEW_CLICKED -> newDelegate.onClicked(event) + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED -> newDelegate.onAccessibilityFocused(event) + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED -> newDelegate.onAccessibilityFocusCleared(event) + AccessibilityEvent.TYPE_VIEW_SELECTED -> newDelegate.onSelected(event) + AccessibilityEvent.TYPE_VIEW_SCROLLED -> newDelegate.onScrolled(event) + AccessibilityEvent.TYPE_VIEW_TEXT_SELECTION_CHANGED -> newDelegate.onTextSelectionChanged(event) + AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED -> newDelegate.onTextChanged(event) + AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY -> newDelegate.onTextTraversal(event) + AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> newDelegate.onWinContentChanged(event) + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> newDelegate.onWinStateChanged(event) + AccessibilityEvent.TYPE_ANNOUNCEMENT -> newDelegate.onAnnouncement(event) + else -> {} + } + return false + } + }) + }, + { (view.parent as View).setAccessibilityDelegate(null) }, + object : EventDelegate { }, + ) + } + + @After fun teardown() { + sessionRule.runtime.settings.forceEnableAccessibility = false + mainSession.accessibility.view = null + if (Build.VERSION.SDK_INT < 33) { + nodeInfos.forEach { node -> + @Suppress("DEPRECATION") + node.recycle() + } + } + } + + private fun waitForInitialFocus(moveToFirstChild: Boolean = false) { + sessionRule.waitUntilCalled(object : GeckoSession.NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: GeckoSession.NavigationDelegate.LoadRequest, + ): GeckoResult? { + return GeckoResult.allow() + } + }) + // XXX: Sometimes we get the window state change of the initial + // about:blank page loading. Need to figure out how to ignore that. + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + if (moveToFirstChild) { + provider.performAction( + View.NO_ID, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + } + } + + @Test fun testRootNode() { + assertThat("provider is not null", provider, notNullValue()) + val node = createNodeInfo(AccessibilityNodeProvider.HOST_VIEW_ID) + assertThat( + "Root node should have WebView class name", + node.className.toString(), + equalTo("android.webkit.WebView"), + ) + } + + @Test fun testPageLoad() { + mainSession.loadTestPath(INPUTS_PATH) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { } + }) + } + + @Test fun testAccessibilityFocusAboutMozilla() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadUri("about:license") + + sessionRule.waitUntilCalled(object : GeckoSession.NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: GeckoSession.NavigationDelegate.LoadRequest, + ): GeckoResult? { + return GeckoResult.allow() + } + }) + + // XXX: Local pages do not dispatch focus events when loaded + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + provider.performAction( + View.NO_ID, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Header is a11y focused", + node.contentDescription.toString(), + equalTo("Licenses"), + ) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Next text leaf is focused", + node.text.toString(), + equalTo("All of the "), + ) + } + }) + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + equalTo("free"), + ) + } + }) + } + + @Test fun testAccessibilityFocus() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(INPUTS_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Label accessibility focused", + node.className.toString(), + equalTo("android.view.View"), + ) + assertThat("Text node should not be focusable", node.isFocusable, equalTo(false)) + assertThat("Text node should be a11y focused", node.isAccessibilityFocused, equalTo(true)) + assertThat("Text node should not be clickable", node.isClickable, equalTo(false)) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Editbox accessibility focused", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + assertThat("Entry node should be focusable", node.isFocusable, equalTo(true)) + assertThat("Entry node should be a11y focused", node.isAccessibilityFocused, equalTo(true)) + assertThat("Entry node should be clickable", node.isClickable, equalTo(true)) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocusCleared(event: AccessibilityEvent) { + assertThat("Accessibility focused node is now cleared", getSourceId(event), equalTo(nodeId)) + val node = createNodeInfo(nodeId) + assertThat("Entry node should node be a11y focused", node.isAccessibilityFocused, equalTo(false)) + } + }) + } + + fun loadTestPage(page: String) { + mainSession.loadTestPath("/assets/www/accessibility/$page.html") + } + + @Test fun testTextEntryNode() { + loadTestPage("test-text-entry-node") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + val nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has field name", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Name description"), + ) + } + } + }) + + mainSession.evaluateJS("document.querySelector('input[aria-label=Last]').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + val nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has field name", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Last, required"), + ) + } + } + }) + } + + @Test fun testMoveCaretAccessibilityFocus() { + loadTestPage("test-move-caret-accessibility-focus") + waitForInitialFocus(false) + + mainSession.evaluateJS( + """ + this.select = function select(node, start, end) { + let r = new Range(); + r.setStart(node, start); + r.setEnd(node, end); + let s = getSelection(); + s.removeAllRanges(); + s.addRange(r); + }; + this.select(document.querySelector('p').childNodes[2], 2, 6); + """.trimIndent(), + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo(", sweet ")) + } + }) + + mainSession.evaluateJS( + """ + this.select(document.querySelector('p').lastElementChild.firstChild, 1, 2); + """.trimIndent(), + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo("world")) + } + }) + + // This focuses the link. + mainSession.finder.find("sweet", 0) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.contentDescription as String, equalTo("sweet")) + } + }) + + // reset caret position + mainSession.evaluateJS( + """ + this.select(document.body, 0, 0); + // Changing DOM selection doesn't focus the document! Force focus + // here so we can use that to determine when this is done. + document.activeElement.blur(); + """.trimIndent(), + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) {} + }) + + mainSession.finder.find("Hell", 0) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + val node = createNodeInfo(getSourceId(event)) + assertThat("Text node should match text", node.text as String, equalTo("Hello ")) + } + }) + } + + private fun waitUntilTextSelectionChanged(fromIndex: Int, toIndex: Int, text: String) { + var eventFromIndex = -1 + var eventToIndex = -1 + var eventText = "" + do { + sessionRule.waitUntilCalled(object : EventDelegate { + override fun onTextSelectionChanged(event: AccessibilityEvent) { + eventFromIndex = event.fromIndex + eventToIndex = event.toIndex + eventText = event.text[0].toString() + } + }) + } while (fromIndex != eventFromIndex || toIndex != eventToIndex) + assertThat("text selection event text matches", eventText, equalTo(text)) + } + + private fun waitUntilTextTraversed( + fromIndex: Int, + toIndex: Int, + expectedNode: Int? = null, + ): Int { + var nodeId: Int = AccessibilityNodeProvider.HOST_VIEW_ID + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextTraversal(event: AccessibilityEvent) { + nodeId = getSourceId(event) + if (expectedNode != null) { + assertThat("Node matches", nodeId, equalTo(expectedNode)) + } + assertThat("fromIndex matches", event.fromIndex, equalTo(fromIndex)) + assertThat("toIndex matches", event.toIndex, equalTo(toIndex)) + } + }) + return nodeId + } + + private fun waitUntilClick(checked: Boolean) { + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + var nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Event's checked state matches", event.isChecked, equalTo(checked)) + assertThat("Checkbox node has correct checked state", node.isChecked, equalTo(checked)) + } + }) + } + + private fun waitUntilSelect(selected: Boolean) { + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onSelected(event: AccessibilityEvent) { + var nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Selectable node has correct selected state", node.isSelected, equalTo(selected)) + } + }) + } + + private fun setSelectionArguments(start: Int, end: Int): Bundle { + val arguments = Bundle(2) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT, start) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT, end) + return arguments + } + + private fun moveByGranularityArguments(granularity: Int, extendSelection: Boolean = false): Bundle { + val arguments = Bundle(2) + arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, granularity) + arguments.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, extendSelection) + return arguments + } + + @Test fun testClipboard() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Writing clipboard requires foreground on Android 10. + activityRule.scenario?.onActivity { activity -> + activity.onWindowFocusChanged(true) + } + } + + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-clipboard") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input').focus()") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Focused EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + } + + @AssertCalled(count = 1) + override fun onTextSelectionChanged(event: AccessibilityEvent) { + assertThat("fromIndex should be at start", event.fromIndex, equalTo(0)) + assertThat("toIndex should be at start", event.toIndex, equalTo(0)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(5, 11)) + waitUntilTextSelectionChanged(5, 11, "hello cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COPY, null) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(11, 11)) + waitUntilTextSelectionChanged(11, 11, "hello cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel world")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(12)) + assertThat("addedCount is correct", event.addedCount, equalTo(6)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(17, 23)) + waitUntilTextSelectionChanged(17, 23, "hello cruel cruel world") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PASTE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be pasted", event.text[0].toString(), equalTo("hello cruel cruel cruel")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(18)) + assertThat("removedCount is correct", event.removedCount, equalTo(5)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SET_SELECTION, setSelectionArguments(0, 0)) + waitUntilTextSelectionChanged(0, 0, "hello cruel cruel cruel") + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD, true), + ) + waitUntilTextSelectionChanged(0, 5, "hello cruel cruel cruel") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CUT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onTextChanged(event: AccessibilityEvent) { + assertThat("text should be cut", event.text[0].toString(), equalTo(" cruel cruel cruel")) + assertThat("fromIndex is correct", event.fromIndex, equalTo(0)) + assertThat("removedCount is correct", event.removedCount, equalTo(5)) + } + }) + } + + @Test fun testMoveByCharacter() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(0, 1, nodeId) // "L" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(1, 2, nodeId) // "o" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + waitUntilTextTraversed(0, 1, nodeId) // "L" + } + + @Test fun testMoveByWord() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(0, 5, nodeId) // "Lorem" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(6, 11, nodeId) // "ipsum" + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + waitUntilTextTraversed(0, 5, nodeId) // "Lorem" + } + + @Test fun testMoveByLine() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor " + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(18, 28, nodeId) // "sit amet, " + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE), + ) + waitUntilTextTraversed(0, 18, nodeId) // "Lorem ipsum dolor " + } + + @Test fun testMoveByCharacterAtEdges() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus() + + // Move to the first link containing "anim id". + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id")) + } + }) + + var success: Boolean + // Navigate forward through "anim id" character by character. + for (start in 0..6) { + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should succeed", success, equalTo(true)) + waitUntilTextTraversed(start, start + 1, nodeId) + } + + // Try to navigate forward past end. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should fail at end", success, equalTo(false)) + + // We're already on "d". Navigate backward through "anim i". + for (start in 5 downTo 0) { + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Prev char should succeed", success, equalTo(true)) + waitUntilTextTraversed(start, start + 1, nodeId) + } + + // Try to navigate backward past start. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Prev char should fail at start", success, equalTo(false)) + } + + @Test fun testMoveByWordAtEdges() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus() + + // Move to the first link containing "anim id". + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on link", node.contentDescription as String, startsWith("anim id")) + } + }) + + var success: Boolean + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should succeed", success, equalTo(true)) + waitUntilTextTraversed(0, 4, nodeId) // "anim" + + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should succeed", success, equalTo(true)) + waitUntilTextTraversed(5, 7, nodeId) // "id" + + // Try to navigate forward past end. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should fail at end", success, equalTo(false)) + + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should succeed", success, equalTo(true)) + waitUntilTextTraversed(0, 4, nodeId) // "anim" + + // Try to navigate backward past start. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should fail at start", success, equalTo(false)) + } + + @Test fun testMoveAtEndOfTextTrailingWhitespace() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first text leaf", node.text as String, startsWith("Lorem ipsum")) + } + }) + + // Initial move backward to move to last word. + var success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Prev word should succeed", success, equalTo(true)) + waitUntilTextTraversed(418, 424, nodeId) // "mollit" + + // Try to move forward past last word. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD), + ) + assertThat("Next word should fail at last word", success, equalTo(false)) + + // Move forward by character (onto trailing space). + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should succeed", success, equalTo(true)) + waitUntilTextTraversed(424, 425, nodeId) // " " + + // Try to move forward past last character. + success = provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, + moveByGranularityArguments(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER), + ) + assertThat("Next char should fail at last char", success, equalTo(false)) + } + + @Test fun testHeadings() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-headings") + waitForInitialFocus() + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "HEADING") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on first heading", node.contentDescription as String, startsWith("Fried cheese")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "First heading is level 1", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 1"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Popcorn shrimp")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Second heading is level 2", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 2"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Accessibility focus on second heading", node.contentDescription as String, startsWith("Chicken fingers")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Third heading is level 3", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("heading level 3"), + ) + } + } + }) + } + + @Test fun testCheckbox() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-checkbox") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Checkbox node is checkable", node.isCheckable, equalTo(true)) + assertThat("Checkbox node is clickable", node.isClickable, equalTo(true)) + assertThat("Checkbox node is focusable", node.isFocusable, equalTo(true)) + assertThat("Checkbox node is not checked", node.isChecked, equalTo(false)) + assertThat("Checkbox node has correct role", node.text.toString(), equalTo("many option")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Hint has description", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("description"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilClick(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilClick(false) + } + + @Test fun testExpandable() { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + loadTestPage("test-expandable") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)) + assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE))) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_EXPAND, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId)) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is collapsable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE)) + assertThat("button is not expandable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND))) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_COLLAPSE, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onClicked(event: AccessibilityEvent) { + assertThat("Clicked event is from same node", getSourceId(event), equalTo(nodeId)) + if (Build.VERSION.SDK_INT >= 21) { + val node = createNodeInfo(nodeId) + assertThat("button is expandable", node.actionList, hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND)) + assertThat("button is not collapsable", node.actionList, not(hasItem(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE))) + } + } + }) + } + + @Test fun testSelectable() { + var nodeId = View.NO_ID + loadTestPage("test-selectable") + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + assertThat("Selectable node is clickable", node.isClickable, equalTo(true)) + assertThat("Selectable node is not selected", node.isSelected, equalTo(false)) + assertThat("Selectable node has correct text", node.text.toString(), equalTo("1")) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilSelect(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_CLICK, null) + waitUntilSelect(false) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null) + waitUntilSelect(true) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SELECT, null) + waitUntilSelect(false) + + // Ensure that querying an option outside of a selectable container + // doesn't crash (bug 1801879). + mainSession.evaluateJS("document.getElementById('outsideSelectable').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Focused outsideSelectable", node.text.toString(), equalTo("outside selectable")) + } + }) + } + + @Test fun testMutation() { + loadTestPage("test-mutation") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 1 child", rootNode.childCount, equalTo(1)) + + assertThat( + "Section has 1 child", + createNodeInfo(rootNode.getChildId(0)).childCount, + equalTo(1), + ) + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 0) + override fun onAnnouncement(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + assertThat( + "Section has no children", + createNodeInfo(rootNode.getChildId(0)).childCount, + equalTo(0), + ) + } + + @Test fun testLiveRegion() { + loadTestPage("test-live-region") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('#to_change').textContent = 'Hello';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("Hello")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionDescendant() { + loadTestPage("test-live-region-descendant") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'none';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 0) + override fun onAnnouncement(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + mainSession.evaluateJS("document.querySelector('#to_show').style.display = 'block';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("I will be shown")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionAtomic() { + loadTestPage("test-live-region-atomic") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('p').textContent = '4pm';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("The time is 4pm")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + mainSession.evaluateJS( + "document.querySelector('#container').removeAttribute('aria-atomic');" + + "document.querySelector('p').textContent = '5pm';", + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("5pm")) + } + + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + } + + @Test fun testLiveRegionImage() { + loadTestPage("test-live-region-image") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('img').alt = 'sad';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("This picture is sad")) + } + }) + } + + @Test fun testLiveRegionImageLabeledBy() { + loadTestPage("test-live-region-image-labeled-by") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('img').setAttribute('aria-labelledby', 'l2');") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAnnouncement(event: AccessibilityEvent) { + assertThat("Announcement is correct", event.text[0].toString(), equalTo("Goodbye")) + } + }) + } + + private fun screenContainsNode(nodeId: Int): Boolean { + var node = createNodeInfo(nodeId) + var nodeBounds = Rect() + node.getBoundsInScreen(nodeBounds) + return screenRect.contains(nodeBounds) + } + + @Ignore // Bug 1506276 - We need to reliably wait for APZC here, and it's not trivial. + @Test + fun testScroll() { + var nodeId = View.NO_ID + loadTestPage("test-scroll.html") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onWinStateChanged(event: AccessibilityEvent) { } + + @AssertCalled(count = 1) + @Suppress("deprecation") + override fun onFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + var node = createNodeInfo(nodeId) + var nodeBounds = Rect() + node.getBoundsInParent(nodeBounds) + assertThat("Default root node bounds are correct", nodeBounds, equalTo(screenRect)) + } + }) + + provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled for focused node to be onscreen", event.scrollY, greaterThan(0)) + assertThat("View is not scrolled to the end", event.scrollY, lessThan(event.maxScrollY)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is still onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the beginning", event.scrollY, equalTo(0)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is offscreen", screenContainsNode(nodeId), equalTo(false)) + } + }) + + SystemClock.sleep(100) + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onScrolled(event: AccessibilityEvent) { + assertThat("View is scrolled to the end", event.scrollY.toDouble(), closeTo(event.maxScrollY.toDouble(), 1.0)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onWinContentChanged(event: AccessibilityEvent) { + assertThat("Focused node is onscreen", screenContainsNode(nodeId), equalTo(true)) + } + }) + } + + @Test + fun autoFill() { + // Wait for the accessibility nodes to populate. + mainSession.loadTestPath(FORMS_HTML_PATH) + waitForInitialFocus() + + val autoFills = mapOf( + "#user1" to "bar", + "#pass1" to "baz", + "#user2" to "bar", + "#pass2" to "baz", + ) + + if (Build.VERSION.SDK_INT >= 19) { + mapOf( + "#email1" to "a@b.c", + "#number1" to "24", + "#tel1" to "42", + ) + } else { + mapOf( + "#email1" to "bar", + "#number1" to "", + "#tel1" to "bar", + ) + } + + // Set up promises to monitor the values changing. + val promises = autoFills.flatMap { entry -> + // Repeat each test with both the top document and the iframe document. + arrayOf("document", "document.querySelector('#iframe').contentDocument").map { doc -> + mainSession.evaluatePromiseJS( + """new Promise(resolve => + $doc.querySelector('${entry.key}').addEventListener( + 'input', event => { + let eventInterface = + event instanceof $doc.defaultView.InputEvent ? "InputEvent" : + event instanceof $doc.defaultView.UIEvent ? "UIEvent" : + event instanceof $doc.defaultView.Event ? "Event" : "Unknown"; + resolve([event.target.value, '${entry.value}', eventInterface]); + }, { once: true }))""", + ) + } + } + + // Perform auto-fill and return number of auto-fills performed. + fun autoFillChild(id: Int, child: AccessibilityNodeInfo) { + // Seal the node info instance so we can perform actions on it. + if (child.childCount > 0) { + for (i in 0 until child.childCount) { + val childId = child.getChildId(i) + autoFillChild(childId, createNodeInfo(childId)) + } + } + + if (EditText::class.java.name == child.className) { + assertThat("Input should be enabled", child.isEnabled, equalTo(true)) + assertThat("Input should be focusable", child.isFocusable, equalTo(true)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Password type should match", + child.isPassword, + equalTo( + child.inputType == InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + ), + ) + } + + val args = Bundle(1) + val value = if (child.isPassword) { + "baz" + } else { + if (Build.VERSION.SDK_INT < 19) { + "bar" + } else { + when (child.inputType) { + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS, + -> "a@b.c" + InputType.TYPE_CLASS_NUMBER -> "24" + InputType.TYPE_CLASS_PHONE -> "42" + else -> "bar" + } + } + } + + val ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE = if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE + } else { + "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE" + } + val ACTION_SET_TEXT = if (Build.VERSION.SDK_INT >= 21) { + AccessibilityNodeInfo.ACTION_SET_TEXT + } else { + 0x200000 + } + + args.putCharSequence(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value) + assertThat( + "Can perform auto-fill", + provider.performAction(id, ACTION_SET_TEXT, args), + equalTo(true), + ) + } + } + + autoFillChild(View.NO_ID, createNodeInfo(View.NO_ID)) + + // Wait on the promises and check for correct values. + for ((actual, expected, eventInterface) in promises.map { it.value.asJSList() }) { + assertThat("Auto-filled value must match", actual, equalTo(expected)) + assertThat("input event should be dispatched with InputEvent interface", eventInterface, equalTo("InputEvent")) + } + } + + @Test + fun autoFill_navigation() { + // Fails with BFCache in the parent. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1715480 + sessionRule.setPrefsUntilTestEnd( + mapOf( + "fission.bfcacheInParent" to false, + ), + ) + fun countAutoFillNodes( + cond: (AccessibilityNodeInfo) -> Boolean = + { it.className == "android.widget.EditText" }, + id: Int = View.NO_ID, + ): Int { + val info = createNodeInfo(id) + return ( + if (cond(info) && info.className != "android.webkit.WebView") { + 1 + } else { + 0 + } + ) + ( + if (info.childCount > 0) { + (0 until info.childCount).sumOf { + countAutoFillNodes(cond, info.getChildId(it)) + } + } else { + 0 + } + ) + } + + // XXX: Reliably waiting for iframes to load could be flaky, so we wait + // for our autofill nodes to be the right number. + fun waitForAutoFillNodes() { + val checkAutoFillNodes = object : EventDelegate, ShouldContinue { + var haveAllAutoFills = countAutoFillNodes() == 18 + + override fun shouldContinue(): Boolean = !haveAllAutoFills + + override fun onWinContentChanged(event: AccessibilityEvent) { + haveAllAutoFills = countAutoFillNodes() == 18 + } + } + if (checkAutoFillNodes.shouldContinue()) { + sessionRule.waitUntilCalled(checkAutoFillNodes) + } + } + + // Wait for the accessibility nodes to populate. + mainSession.loadTestPath(FORMS_HTML_PATH) + waitForInitialFocus() + waitForAutoFillNodes() + + assertThat( + "Initial auto-fill count should match", + countAutoFillNodes(), + equalTo(18), + ) + assertThat( + "Password auto-fill count should match", + countAutoFillNodes({ it.isPassword }), + equalTo(4), + ) + + // Now wait for the nodes to clear. + mainSession.loadTestPath(HELLO_HTML_PATH) + waitForInitialFocus() + assertThat( + "Should not have auto-fill fields", + countAutoFillNodes(), + equalTo(0), + ) + + // Now wait for the nodes to reappear. + mainSession.goBack() + waitForInitialFocus() + waitForAutoFillNodes() + assertThat( + "Should have auto-fill fields again", + countAutoFillNodes(), + equalTo(18), + ) + assertThat( + "Should not have focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(0), + ) + + mainSession.evaluateJS("document.querySelector('#pass1').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onFocused(event: AccessibilityEvent) { + } + }) + assertThat( + "Should have one focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(1), + ) + + mainSession.evaluateJS("document.querySelector('#pass1').blur()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled + override fun onFocused(event: AccessibilityEvent) { + } + }) + assertThat( + "Should not have focused field", + countAutoFillNodes({ it.isFocused }), + equalTo(0), + ) + } + + @Test + fun testTree() { + loadTestPage("test-tree") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 3 children", rootNode.childCount, equalTo(3)) + var rootBounds = Rect() + rootNode.getBoundsInScreen(rootBounds) + assertThat("Root node bounds are not empty", rootBounds.isEmpty, equalTo(false)) + assertThat("Root node is visible to user", rootNode.isVisibleToUser, equalTo(true)) + + var labelBounds = Rect() + val labelNode = createNodeInfo(rootNode.getChildId(0)) + labelNode.getBoundsInScreen(labelBounds) + + assertThat("Label bounds are in parent", rootBounds.contains(labelBounds), equalTo(true)) + assertThat("First node is a label", labelNode.className.toString(), equalTo("android.view.View")) + assertThat("Label has text", labelNode.text.toString(), equalTo("Name:")) + assertThat("Label node is visible to user", labelNode.isVisibleToUser, equalTo(true)) + + val entryNode = createNodeInfo(rootNode.getChildId(1)) + assertThat("Second node is an entry", entryNode.className.toString(), equalTo("android.widget.EditText")) + assertThat("Entry has vieIdwResourceName of 'name'", entryNode.viewIdResourceName, equalTo("name")) + assertThat("Entry value is text", entryNode.text.toString(), equalTo("Julie")) + assertThat("Entry node is visible to user", entryNode.isVisibleToUser, equalTo(true)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Entry hint is label", + entryNode.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("Name:"), + ) + assertThat( + "Entry input type is correct", + entryNode.inputType, + equalTo(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT), + ) + } + + val buttonNode = createNodeInfo(rootNode.getChildId(2)) + assertThat("Last node is a button", buttonNode.className.toString(), equalTo("android.widget.Button")) + // The child text leaf is pruned, so this button is childless. + assertThat("Button has a single text leaf", buttonNode.childCount, equalTo(0)) + assertThat("Button has correct text", buttonNode.text.toString(), equalTo("Submit")) + assertThat("Button is visible to user", buttonNode.isVisibleToUser, equalTo(true)) + } + + @Test fun testLoadUnloadIframeDoc() { + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + + mainSession.loadTestPath(REMOTE_IFRAME) + waitForInitialFocus() + + loadTestPage("test-tree") + waitForInitialFocus() + } + + private fun testAccessibilityFocusIframe(page: String) { + var nodeId = AccessibilityNodeProvider.HOST_VIEW_ID + mainSession.loadTestPath(page) + waitForInitialFocus(true) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Label has text", node.text.toString(), equalTo("Some stuff ")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("heading has correct content", node.text as String, equalTo("Hello, world!")) + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, + null, + ) + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat("Label has text", node.text.toString(), equalTo("Some stuff ")) + } + }) + } + + @Test fun testRemoteAccessibilityFocusIframe() { + testAccessibilityFocusIframe(REMOTE_IFRAME) + } + + @Test fun testLocalAccessibilityFocusIframe() { + testAccessibilityFocusIframe(LOCAL_IFRAME) + } + + private fun testIframeTree(page: String) { + mainSession.loadTestPath(page) + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 2 children", rootNode.childCount, equalTo(2)) + var rootBounds = Rect() + rootNode.getBoundsInScreen(rootBounds) + assertThat("Root bounds are not empty", rootBounds.isEmpty, equalTo(false)) + + val labelNode = createNodeInfo(rootNode.getChildId(0)) + assertThat("First node has text", labelNode.text.toString(), equalTo("Some stuff ")) + + val iframeNode = createNodeInfo(rootNode.getChildId(1)) + assertThat("iframe has vieIdwResourceName of 'iframe'", iframeNode.viewIdResourceName, equalTo("iframe")) + assertThat("iframe has 1 child", iframeNode.childCount, equalTo(1)) + var iframeBounds = Rect() + iframeNode.getBoundsInScreen(iframeBounds) + assertThat("iframe bounds in root bounds", rootBounds.contains(iframeBounds), equalTo(true)) + + val innerDocNode = createNodeInfo(iframeNode.getChildId(0)) + assertThat("Inner doc has one child", innerDocNode.childCount, equalTo(1)) + var innerDocBounds = Rect() + innerDocNode.getBoundsInScreen(innerDocBounds) + assertThat("iframe bounds match inner doc bounds", iframeBounds.contains(innerDocBounds), equalTo(true)) + + val section = createNodeInfo(innerDocNode.getChildId(0)) + assertThat("section has one child", innerDocNode.childCount, equalTo(1)) + + val node = createNodeInfo(section.getChildId(0)) + assertThat("Text node has text", node.text as String, equalTo("Hello, world!")) + var nodeBounds = Rect() + node.getBoundsInScreen(nodeBounds) + assertThat("inner node in inner doc bounds", innerDocBounds.contains(nodeBounds), equalTo(true)) + } + + @Test + fun testRemoteIframeTree() { + testIframeTree(REMOTE_IFRAME) + } + + @Test + fun testLocalIframeTree() { + testIframeTree(LOCAL_IFRAME) + } + + @Test + fun testCollection() { + loadTestPage("test-collection") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 2 children", rootNode.childCount, equalTo(2)) + + val firstList = createNodeInfo(rootNode.getChildId(0)) + assertThat("First list has 2 children", firstList.childCount, equalTo(2)) + assertThat("List is a ListView", firstList.className.toString(), equalTo("android.widget.ListView")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("First list should have collectionInfo", firstList.collectionInfo, notNullValue()) + assertThat("First list has 2 rowCount", firstList.collectionInfo.rowCount, equalTo(2)) + assertThat("First list should not be hierarchical", firstList.collectionInfo.isHierarchical, equalTo(false)) + } + + val firstListFirstItem = createNodeInfo(firstList.getChildId(0)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("Item has collectionItemInfo", firstListFirstItem.collectionItemInfo, notNullValue()) + assertThat("Item has correct rowIndex", firstListFirstItem.collectionItemInfo.rowIndex, equalTo(0)) + } + + val secondList = createNodeInfo(rootNode.getChildId(1)) + assertThat("Second list has 1 child", secondList.childCount, equalTo(1)) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("Second list should have collectionInfo", secondList.collectionInfo, notNullValue()) + assertThat("Second list has 2 rowCount", secondList.collectionInfo.rowCount, equalTo(1)) + assertThat("Second list should be hierarchical", secondList.collectionInfo.isHierarchical, equalTo(true)) + } + } + + @Test fun testNavigateListItems() { + loadTestPage("test-collection") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on text leaf", + node.text as String, + startsWith("One"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "first item is a text leaf", + node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(), + equalTo("text leaf"), + ) + } + } + }) + + provider.performAction( + nodeId, + AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, + null, + ) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on link", + node.contentDescription as String, + startsWith("Two"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "second item is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.geckoRole")!!.toString(), + equalTo("link"), + ) + } + } + }) + } + + @Test + fun testRange() { + loadTestPage("test-range") + waitForInitialFocus() + + val rootNode = createNodeInfo(View.NO_ID) + assertThat("Document has 3 children", rootNode.childCount, equalTo(3)) + + val firstRange = createNodeInfo(rootNode.getChildId(0)) + assertThat("Range has right label", firstRange.text.toString(), equalTo("Rating")) + assertThat("Range is SeekBar", firstRange.className.toString(), equalTo("android.widget.SeekBar")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", firstRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", firstRange.rangeInfo.current, equalTo(4f)) + assertThat("'Rating' has correct max", firstRange.rangeInfo.max, equalTo(10f)) + assertThat("'Rating' has correct min", firstRange.rangeInfo.min, equalTo(1f)) + assertThat("'Rating' has correct range type", firstRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INT)) + } + + val secondRange = createNodeInfo(rootNode.getChildId(1)) + assertThat("Range has right label", secondRange.text.toString(), equalTo("Stars")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", secondRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", secondRange.rangeInfo.current, equalTo(4.5f)) + assertThat("'Rating' has correct max", secondRange.rangeInfo.max, equalTo(5f)) + assertThat("'Rating' has correct min", secondRange.rangeInfo.min, equalTo(1f)) + assertThat("'Rating' has correct range type", secondRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_FLOAT)) + } + + val thirdRange = createNodeInfo(rootNode.getChildId(2)) + assertThat("Range has right label", thirdRange.text.toString(), equalTo("Percent")) + if (Build.VERSION.SDK_INT >= 19) { + assertThat("'Rating' has rangeInfo", thirdRange.rangeInfo, notNullValue()) + assertThat("'Rating' has correct value", thirdRange.rangeInfo.current, equalTo(0.83f)) + assertThat("'Rating' has correct max", thirdRange.rangeInfo.max, equalTo(1f)) + assertThat("'Rating' has correct min", thirdRange.rangeInfo.min, equalTo(0f)) + assertThat("'Rating' has correct range type", thirdRange.rangeInfo.type, equalTo(AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_PERCENT)) + } + } + + @Test fun testLinksMovingByDefault() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with href is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with no attributes", + node.text as String, + startsWith("a with no attributes"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with no attributes is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with name", + node.text as String, + startsWith("a with name"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with name is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with onclick is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "span with role link is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + } + + @Test fun testLinksMovingByLink() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "LINK") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + } + }) + } + + @Test fun testAriaComboBoxesMovingByDefault() { + loadTestPage("test-aria-comboboxes") + waitForInitialFocus() + var nodeId = View.NO_ID + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.0 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.0 combobox"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.1 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.1 combobox"), + ) + } + } + }) + } + + @Test fun testAriaComboBoxesMovingByControl() { + loadTestPage("test-aria-comboboxes") + waitForInitialFocus() + var nodeId = View.NO_ID + + val bundle = Bundle() + bundle.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING, "CONTROL") + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.0 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.0 combobox"), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, bundle) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus is EditBox", + node.className.toString(), + equalTo("android.widget.EditText"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "Accessibility focus on ARIA 1.1 combobox", + node.extras.getString("AccessibilityNodeInfo.hint"), + equalTo("ARIA 1.1 combobox"), + ) + } + } + }) + } + + @Test fun testAccessibilityFocusBoundaries() { + loadTestPage("test-links") + waitForInitialFocus() + var nodeId = View.NO_ID + var performedAction: Boolean + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus to first node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with href", + node.contentDescription as String, + startsWith("a with href"), + ) + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus past first node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID)) + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus to second node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with no attributes", + node.text as String, + startsWith("a with no attributes"), + ) + } + }) + + // hide first and last link + mainSession.evaluateJS("document.querySelectorAll('body > :first-child, body > :last-child').forEach(e => e.style.display = 'none');") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Successfully moved a11y focus past first visible node", performedAction, equalTo(true)) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + assertThat("Accessibility focus on web view", getSourceId(event), equalTo(View.NO_ID)) + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with name", + node.text as String, + startsWith("a with name"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with name is not a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo(""), + ) + } + } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on a with onclick", + node.contentDescription as String, + startsWith("a with onclick"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "a with onclick is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus to last hidden node", performedAction, equalTo(false)) + + // show last link + mainSession.evaluateJS("document.querySelector('body > :last-child').style.display = 'initial';") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onWinContentChanged(event: AccessibilityEvent) { } + }) + + provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onAccessibilityFocused(event: AccessibilityEvent) { + nodeId = getSourceId(event) + val node = createNodeInfo(nodeId) + assertThat( + "Accessibility focus on span with role link", + node.contentDescription as String, + startsWith("span with role link"), + ) + if (Build.VERSION.SDK_INT >= 19) { + assertThat( + "span with role link is a link", + node.extras.getCharSequence("AccessibilityNodeInfo.roleDescription")!!.toString(), + equalTo("link"), + ) + } + } + }) + + performedAction = provider.performAction(nodeId, AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus beyond last node", performedAction, equalTo(false)) + + performedAction = provider.performAction(View.NO_ID, AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT, null) + assertThat("Should fail to move a11y focus before web content", performedAction, equalTo(false)) + } + + @Test fun testTextEntry() { + loadTestPage("test-text-entry-node") + waitForInitialFocus() + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').focus()") + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onFocused(event: AccessibilityEvent) {} + }) + + mainSession.evaluateJS("document.querySelector('input[aria-label=Name]').value = 'Tobiasas'") + + sessionRule.waitUntilCalled(object : EventDelegate { + @AssertCalled(count = 1) + override fun onTextChanged(event: AccessibilityEvent) {} + + @AssertCalled(count = 1) + override fun onTextSelectionChanged(event: AccessibilityEvent) {} + + // Don't fire a11y focus for collapsed caret changes. + // This will interfere with on screen keyboards and throw a11y focus + // back and fourth. + @AssertCalled(count = 0) + override fun onAccessibilityFocused(event: AccessibilityEvent) {} + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt new file mode 100644 index 0000000000..fbfe2fe46d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt @@ -0,0 +1,2532 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Handler +import android.os.Looper +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autocomplete.Address +import org.mozilla.geckoview.Autocomplete.AddressSelectOption +import org.mozilla.geckoview.Autocomplete.CreditCard +import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption +import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption +import org.mozilla.geckoview.Autocomplete.LoginEntry +import org.mozilla.geckoview.Autocomplete.LoginSaveOption +import org.mozilla.geckoview.Autocomplete.LoginSelectOption +import org.mozilla.geckoview.Autocomplete.SelectOption +import org.mozilla.geckoview.Autocomplete.StorageDelegate +import org.mozilla.geckoview.Autocomplete.UsedField +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PromptDelegate +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@RunWith(AndroidJUnit4::class) +@MediumTest +class AutocompleteTest : BaseSessionTest() { + val acceptDelay: Long = 100 + + // This is a utility to delete previous credit card and address information. + // Some credit card tests may not use fetched data since pop up is opened + // before fetching it. + private fun clearData() { + mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH) + mainSession.waitForPageStop() + + val fetchHandled = GeckoResult() + sessionRule.delegateDuringNextWait(object : StorageDelegate { + override fun onAddressFetch(): GeckoResult>? { + return null + } + override fun onCreditCardFetch(): GeckoResult>? { + Handler(Looper.getMainLooper()).postDelayed({ + fetchHandled.complete(null) + }, acceptDelay) + + return null + } + }) + + mainSession.evaluateJS("document.querySelector('#name').focus()") + sessionRule.waitForResult(fetchHandled) + } + + @Test + fun loginBuilderDefaultValue() { + val login = LoginEntry.Builder() + .build() + + assertThat( + "Guid should match", + login.guid, + equalTo(null), + ) + assertThat( + "Origin should match", + login.origin, + equalTo(""), + ) + assertThat( + "Form action origin should match", + login.formActionOrigin, + equalTo(null), + ) + assertThat( + "HTTP realm should match", + login.httpRealm, + equalTo(null), + ) + assertThat( + "Username should match", + login.username, + equalTo(""), + ) + assertThat( + "Password should match", + login.password, + equalTo(""), + ) + } + + @Test + fun fetchLogins() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + ), + ) + + val fetchHandled = GeckoResult() + + sessionRule.delegateDuringNextWait(object : StorageDelegate { + @AssertCalled(count = 1) + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + Handler(Looper.getMainLooper()).postDelayed({ + fetchHandled.complete(null) + }, acceptDelay) + + return null + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + sessionRule.waitForResult(fetchHandled) + } + + @Test + fun fetchCreditCards() { + val fetchHandled = GeckoResult() + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : StorageDelegate { + @AssertCalled(count = 1) + override fun onCreditCardFetch(): GeckoResult>? { + Handler(Looper.getMainLooper()).postDelayed({ + fetchHandled.complete(null) + }, acceptDelay) + + return null + } + }) + + mainSession.evaluateJS("document.querySelector('#name').focus()") + sessionRule.waitForResult(fetchHandled) + } + + @Test + fun creditCardBuilderDefaultValue() { + val creditCard = CreditCard.Builder() + .build() + + assertThat( + "Guid should match", + creditCard.guid, + equalTo(null), + ) + assertThat( + "Name should match", + creditCard.name, + equalTo(""), + ) + assertThat( + "Number should match", + creditCard.number, + equalTo(""), + ) + assertThat( + "Expiration month should match", + creditCard.expirationMonth, + equalTo(""), + ) + assertThat( + "Expiration year should match", + creditCard.expirationYear, + equalTo(""), + ) + } + + @Test + fun creditCardSelectAndFill() { + // Workaround to fetch and open prompt + clearData() + + // Test: + // 1. Load a credit card form page. + // 2. Focus on the name input field. + // a. Ensure onCreditCardFetch is called. + // b. Return the saved entries. + // c. Ensure onCreditCardSelect is called. + // d. Select and return one of the options. + // e. Ensure the form is filled accordingly. + + val name = arrayOf("Peter Parker", "John Doe") + val number = arrayOf("1234-1234-1234-1234", "2345-2345-2345-2345") + val guid = arrayOf("test-guid1", "test-guid2") + val expMonth = arrayOf("04", "08") + val expYear = arrayOf("22", "23") + val savedCC = arrayOf( + CreditCard.Builder() + .guid(guid[0]) + .name(name[0]) + .number(number[0]) + .expirationMonth(expMonth[0]) + .expirationYear(expYear[0]) + .build(), + CreditCard.Builder() + .guid(guid[1]) + .name(name[1]) + .number(number[1]) + .expirationMonth(expMonth[1]) + .expirationYear(expYear[1]) + .build(), + ) + + val selectHandled = GeckoResult() + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : StorageDelegate { + @AssertCalled + override fun onCreditCardFetch(): GeckoResult>? { + return GeckoResult.fromValue(savedCC) + } + + @AssertCalled(false) + override fun onCreditCardSave(creditCard: CreditCard) {} + }) + + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onCreditCardSelect( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + assertThat( + "There should be two options", + prompt.options.size, + equalTo(2), + ) + + for (i in 0..1) { + val creditCard = prompt.options[i].value + + assertThat("Credit card should not be null", creditCard, notNullValue()) + assertThat( + "Name should match", + creditCard.name, + equalTo(name[i]), + ) + assertThat( + "Number should match", + creditCard.number, + equalTo(number[i]), + ) + assertThat( + "Expiration month should match", + creditCard.expirationMonth, + equalTo(expMonth[i]), + ) + assertThat( + "Expiration year should match", + creditCard.expirationYear, + equalTo(expYear[i]), + ) + } + Handler(Looper.getMainLooper()).postDelayed({ + selectHandled.complete(null) + }, acceptDelay) + + return GeckoResult.fromValue(prompt.confirm(prompt.options[0])) + } + }) + + // Focus on the name input field. + mainSession.evaluateJS("document.querySelector('#name').focus()") + sessionRule.waitForResult(selectHandled) + + assertThat( + "Filled name should match", + mainSession.evaluateJS("document.querySelector('#name').value") as String, + equalTo(name[0]), + ) + assertThat( + "Filled number should match", + mainSession.evaluateJS("document.querySelector('#number').value") as String, + equalTo(number[0]), + ) + assertThat( + "Filled expiration month should match", + mainSession.evaluateJS("document.querySelector('#expMonth').value") as String, + equalTo(expMonth[0]), + ) + assertThat( + "Filled expiration year should match", + mainSession.evaluateJS("document.querySelector('#expYear').value") as String, + equalTo(expYear[0]), + ) + } + + @Test + fun addressBuilderDefaultValue() { + val address = Address.Builder() + .build() + + assertThat( + "Guid should match", + address.guid, + equalTo(null), + ) + assertThat( + "Name should match", + address.name, + equalTo(""), + ) + assertThat( + "Given name should match", + address.givenName, + equalTo(""), + ) + assertThat( + "Family name should match", + address.familyName, + equalTo(""), + ) + assertThat( + "Street address should match", + address.streetAddress, + equalTo(""), + ) + assertThat( + "Address level 1 should match", + address.addressLevel1, + equalTo(""), + ) + assertThat( + "Address level 2 should match", + address.addressLevel2, + equalTo(""), + ) + assertThat( + "Address level 3 should match", + address.addressLevel3, + equalTo(""), + ) + assertThat( + "Postal code should match", + address.postalCode, + equalTo(""), + ) + assertThat( + "Country should match", + address.country, + equalTo(""), + ) + assertThat( + "Tel should match", + address.tel, + equalTo(""), + ) + assertThat( + "Email should match", + address.email, + equalTo(""), + ) + } + + @Test + fun creditCardSelectDismiss() { + // Workaround to fetch and open prompt + clearData() + + val name = arrayOf("Peter Parker", "John Doe", "Taro Yamada") + val number = arrayOf("1234-1234-1234-1234", "2345-2345-2345-2345", "5555-5555-5555-5555") + val guid = arrayOf("test-guid1", "test-guid2", "test-guid3") + val expMonth = arrayOf("04", "08", "12") + val expYear = arrayOf("22", "23", "24") + val savedCC = arrayOf( + CreditCard.Builder() + .guid(guid[0]) + .name(name[0]) + .number(number[0]) + .expirationMonth(expMonth[0]) + .expirationYear(expYear[0]) + .build(), + CreditCard.Builder() + .guid(guid[1]) + .name(name[1]) + .number(number[1]) + .expirationMonth(expMonth[1]) + .expirationYear(expYear[1]) + .build(), + CreditCard.Builder() + .guid(guid[2]) + .name(name[2]) + .number(number[2]) + .expirationMonth(expMonth[2]) + .expirationYear(expYear[2]) + .build(), + ) + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onCreditCardFetch(): GeckoResult>? { + return GeckoResult.fromValue(savedCC) + } + }) + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + val promptHandled = GeckoResult() + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled + override fun onCreditCardSelect(session: GeckoSession, prompt: AutocompleteRequest): GeckoResult? { + assertThat( + "There should be three options", + prompt.options.size, + equalTo(3), + ) + prompt.setDelegate(promptInstanceDelegate) + Handler(Looper.getMainLooper()).postDelayed({ + promptHandled.complete(null) + }, acceptDelay) + + return GeckoResult() + } + }) + + mainSession.evaluateJS("document.querySelector('#name').focus()") + sessionRule.waitForResult(promptHandled) + mainSession.evaluateJS("document.querySelector('#name').blur()") + sessionRule.waitForResult(result) + } + + @Test + fun fetchAddresses() { + val fetchHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled(count = 1) + override fun onAddressFetch(): GeckoResult>? { + Handler(Looper.getMainLooper()).postDelayed({ + fetchHandled.complete(null) + }, acceptDelay) + + return null + } + }) + + mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("document.querySelector('#name').focus()") + sessionRule.waitForResult(fetchHandled) + } + + fun checkAddressesForCorrectness(savedAddresses: Array
    , selectedAddress: Address) { + // Test: + // 1. Load an address form page. + // 2. Focus on the given name input field. + // a. Ensure onAddressFetch is called. + // b. Return the saved entries. + // c. Ensure onAddressSelect is called. + // d. Select and return one of the options. + // e. Ensure the form is filled accordingly. + + val selectHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onAddressFetch(): GeckoResult>? { + return GeckoResult.fromValue(savedAddresses) + } + + @AssertCalled(false) + override fun onAddressSave(address: Address) {} + }) + + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onAddressSelect( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + assertThat( + "There should be one option", + prompt.options.size, + equalTo(savedAddresses.size), + ) + + val addressOption = prompt.options.find { it.value.familyName == selectedAddress.familyName } + val address = addressOption?.value + + assertThat("Address should not be null", address, notNullValue()) + assertThat( + "Guid should match", + address?.guid, + equalTo(selectedAddress.guid), + ) + assertThat( + "Name should match", + address?.name, + equalTo(selectedAddress.name), + ) + assertThat( + "Given name should match", + address?.givenName, + equalTo(selectedAddress.givenName), + ) + assertThat( + "Family name should match", + address?.familyName, + equalTo(selectedAddress.familyName), + ) + assertThat( + "Street address should match", + address?.streetAddress, + equalTo(selectedAddress.streetAddress), + ) + assertThat( + "Address level 1 should match", + address?.addressLevel1, + equalTo(selectedAddress.addressLevel1), + ) + assertThat( + "Address level 2 should match", + address?.addressLevel2, + equalTo(selectedAddress.addressLevel2), + ) + assertThat( + "Address level 3 should match", + address?.addressLevel3, + equalTo(selectedAddress.addressLevel3), + ) + assertThat( + "Postal code should match", + address?.postalCode, + equalTo(selectedAddress.postalCode), + ) + assertThat( + "Country should match", + address?.country, + equalTo(selectedAddress.country), + ) + assertThat( + "Tel should match", + address?.tel, + equalTo(selectedAddress.tel), + ) + assertThat( + "Email should match", + address?.email, + equalTo(selectedAddress.email), + ) + + Handler(Looper.getMainLooper()).postDelayed({ + selectHandled.complete(null) + }, acceptDelay) + + return GeckoResult.fromValue(prompt.confirm(addressOption!!)) + } + }) + + mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH) + mainSession.waitForPageStop() + + // Focus on the given name input field. + mainSession.evaluateJS("document.querySelector('#givenName').focus()") + sessionRule.waitForResult(selectHandled) + + assertThat( + "Filled given name should match", + mainSession.evaluateJS("document.querySelector('#givenName').value") as String, + equalTo(selectedAddress.givenName), + ) + assertThat( + "Filled family name should match", + mainSession.evaluateJS("document.querySelector('#familyName').value") as String, + equalTo(selectedAddress.familyName), + ) + assertThat( + "Filled street address should match", + mainSession.evaluateJS("document.querySelector('#streetAddress').value") as String, + equalTo(selectedAddress.streetAddress), + ) + assertThat( + "Filled country should match", + mainSession.evaluateJS("document.querySelector('#country').value") as String, + equalTo(selectedAddress.country), + ) + assertThat( + "Filled postal code should match", + mainSession.evaluateJS("document.querySelector('#postalCode').value") as String, + equalTo(selectedAddress.postalCode), + ) + assertThat( + "Filled email should match", + mainSession.evaluateJS("document.querySelector('#email').value") as String, + equalTo(selectedAddress.email), + ) + assertThat( + "Filled telephone number should match", + mainSession.evaluateJS("document.querySelector('#tel').value") as String, + equalTo(selectedAddress.tel), + ) + assertThat( + "Filled organization should match", + mainSession.evaluateJS("document.querySelector('#organization').value") as String, + equalTo(selectedAddress.organization), + ) + } + + @Test + fun addressSelectAndFill() { + val name = "Peter Parker" + val givenName = "Peter" + val familyName = "Parker" + val streetAddress = "20 Ingram Street, Forest Hills Gardens, Queens" + val postalCode = "11375" + val country = "US" + val email = "spiderman@newyork.com" + val tel = "+1 180090021" + val organization = "" + val guid = "test-guid" + val savedAddress = Address.Builder() + .guid(guid) + .name(name) + .givenName(givenName) + .familyName(familyName) + .streetAddress(streetAddress) + .postalCode(postalCode) + .country(country) + .email(email) + .tel(tel) + .organization(organization) + .build() + val savedAddresses = mutableListOf
    (savedAddress) + + checkAddressesForCorrectness(savedAddresses.toTypedArray(), savedAddress) + } + + @Test + fun addressSelectAndFillMultipleAddresses() { + val names = arrayOf("Peter Parker", "Wade Wilson") + val givenNames = arrayOf("Peter", "Wade") + val familyNames = arrayOf("Parker", "Wilson") + val streetAddresses = arrayOf("20 Ingram Street, Forest Hills Gardens, Queens", "890 Fifth Avenue, Manhattan") + val postalCodes = arrayOf("11375", "10110") + val countries = arrayOf("US", "US") + val emails = arrayOf("spiderman@newyork.com", "deadpool@newyork.com") + val tels = arrayOf("+1 180090021", "+1 180055555") + val organizations = arrayOf("", "") + val guids = arrayOf("test-guid-1", "test-guid-2") + val selectedAddress = Address.Builder() + .guid(guids[1]) + .name(names[1]) + .givenName(givenNames[1]) + .familyName(familyNames[1]) + .streetAddress(streetAddresses[1]) + .postalCode(postalCodes[1]) + .country(countries[1]) + .email(emails[1]) + .tel(tels[1]) + .organization(organizations[1]) + .build() + val savedAddresses = mutableListOf
    ( + Address.Builder() + .guid(guids[0]) + .name(names[0]) + .givenName(givenNames[0]) + .familyName(familyNames[0]) + .streetAddress(streetAddresses[0]) + .postalCode(postalCodes[0]) + .country(countries[0]) + .email(emails[0]) + .tel(tels[0]) + .organization(organizations[0]) + .build(), + selectedAddress, + ) + + checkAddressesForCorrectness(savedAddresses.toTypedArray(), selectedAddress) + } + + @Test + fun addressSelectDismiss() { + val name = "Peter Parker" + val givenName = "Peter" + val familyName = "Parker" + val streetAddress = "20 Ingram Street, Forest Hills Gardens, Queens" + val postalCode = "11375" + val country = "US" + val email = "spiderman@newyork.com" + val tel = "+1 180090021" + val organization = "" + val guid = "test-guid" + val savedAddress = Address.Builder() + .guid(guid) + .name(name) + .givenName(givenName) + .familyName(familyName) + .streetAddress(streetAddress) + .postalCode(postalCode) + .country(country) + .email(email) + .tel(tel) + .organization(organization) + .build() + val savedAddresses = mutableListOf
    (savedAddress) + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onAddressFetch(): GeckoResult>? { + return GeckoResult.fromValue(savedAddresses.toTypedArray()) + } + }) + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + val promptHandled = GeckoResult() + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled + override fun onAddressSelect(session: GeckoSession, prompt: AutocompleteRequest): GeckoResult? { + assertThat( + "There should be one option", + prompt.options.size, + equalTo(1), + ) + prompt.setDelegate(promptInstanceDelegate) + Handler(Looper.getMainLooper()).postDelayed({ + promptHandled.complete(null) + }, acceptDelay) + + return GeckoResult() + } + }) + + mainSession.loadTestPath(ADDRESS_FORM_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('#givenName').focus()") + sessionRule.waitForResult(promptHandled) + mainSession.evaluateJS("document.querySelector('#givenName').blur()") + sessionRule.waitForResult(result) + } + + @Test + fun loginSaveDismiss() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : StorageDelegate { + @AssertCalled(count = 1) + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return null + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled(count = 0) + override fun onLoginSave(login: LoginEntry) {} + }) + + // Assign login credentials. + mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'") + mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'") + + // Submit the form. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + val option = prompt.options[0] + val login = option.value + + assertThat("Session should not be null", session, notNullValue()) + assertThat("Login should not be null", login, notNullValue()) + assertThat( + "Username should match", + login.username, + equalTo("user1x"), + ) + + assertThat( + "Password should match", + login.password, + equalTo("pass1x"), + ) + + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @Test + fun loginSaveAccept() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginSave(login: LoginEntry) { + assertThat( + "Username should match", + login.username, + equalTo("user1x"), + ) + + assertThat( + "Password should match", + login.password, + equalTo("pass1x"), + ) + + saveHandled.complete(null) + } + }) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo("user1x"), + ) + + assertThat( + "Password should match", + login.password, + equalTo("pass1x"), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign login credentials. + mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'") + mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'") + + // Submit the form. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitForResult(saveHandled) + } + + @Test + fun loginSaveModifyAccept() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginSave(login: LoginEntry) { + assertThat( + "Username should match", + login.username, + equalTo("user1x"), + ) + + assertThat( + "Password should match", + login.password, + equalTo("pass1xmod"), + ) + + saveHandled.complete(null) + } + }) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo("user1x"), + ) + + assertThat( + "Password should match", + login.password, + equalTo("pass1x"), + ) + + val modLogin = LoginEntry.Builder() + .origin(login.origin) + .formActionOrigin(login.origin) + .httpRealm(login.httpRealm) + .username(login.username) + .password("pass1xmod") + .build() + + return GeckoResult.fromValue(prompt.confirm(LoginSaveOption(modLogin))) + } + }) + + // Assign login credentials. + mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'") + mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'") + + // Submit the form. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitForResult(saveHandled) + } + + @Test + fun loginUpdateAccept() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + val saveHandled = GeckoResult() + val saveHandled2 = GeckoResult() + + val user1 = "user1x" + val pass1 = "pass1x" + val pass2 = "pass1up" + val guid = "test-guid" + val savedLogins = mutableListOf() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return GeckoResult.fromValue(savedLogins.toTypedArray()) + } + + @AssertCalled(count = 2) + override fun onLoginSave(login: LoginEntry) { + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(forEachCall(pass1, pass2)), + ) + + assertThat( + "GUID should match", + login.guid, + equalTo(forEachCall(null, guid)), + ) + + val savedLogin = LoginEntry.Builder() + .guid(guid) + .origin(login.origin) + .formActionOrigin(login.formActionOrigin) + .username(login.username) + .password(login.password) + .build() + + savedLogins.add(savedLogin) + + if (sessionRule.currentCall.counter == 1) { + saveHandled.complete(null) + } else if (sessionRule.currentCall.counter == 2) { + saveHandled2.complete(null) + } + } + }) + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 2) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(forEachCall(pass1, pass2)), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign login credentials. + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'") + mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'") + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitForResult(saveHandled) + + // Update login credentials. + val session2 = sessionRule.createOpenSession() + session2.loadTestPath(FORMS3_HTML_PATH) + session2.waitForPageStop() + session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'") + session2.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitForResult(saveHandled2) + } + + @Test + fun creditCardSaveAccept() { + val ccName = "MyCard" + val ccNumber = "5105105105105100" + val ccExpMonth = "6" + val ccExpYear = "2024" + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onCreditCardSave(creditCard: CreditCard) { + assertThat("Credit card name should match", creditCard.name, equalTo(ccName)) + assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber)) + assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth)) + assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYear)) + saveHandled.complete(null) + } + }) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest, + ): GeckoResult { + assertThat("Session should not be null", session, notNullValue()) + + val option = request.options[0] + val cc = option.value + + assertThat("Credit card should not be null", cc, notNullValue()) + + assertThat( + "Credit card name should match", + cc.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + cc.number, + equalTo(ccNumber), + ) + assertThat( + "Credit card expiration month should match", + cc.expirationMonth, + equalTo(ccExpMonth), + ) + assertThat( + "Credit card expiration year should match", + cc.expirationYear, + equalTo(ccExpYear), + ) + + return GeckoResult.fromValue(request.confirm(option)) + } + }) + + // Enter the card values + mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'") + mainSession.evaluateJS("document.querySelector('#name').focus()") + mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'") + mainSession.evaluateJS("document.querySelector('#number').focus()") + mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'") + mainSession.evaluateJS("document.querySelector('#expMonth').focus()") + mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'") + mainSession.evaluateJS("document.querySelector('#expYear').focus()") + + // Submit the form + mainSession.evaluateJS("document.querySelector('form').requestSubmit()") + + sessionRule.waitForResult(saveHandled) + } + + @Test + fun creditCardSaveAcceptForm2() { + // TODO Bug 1764709: Right now we fill normalized credit card data to match + // the expected result. + val ccName = "MyCard" + val ccNumber = "5105105105105100" + val ccExpMonth = "6" + val ccExpYear = "2024" + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onCreditCardSave(creditCard: CreditCard) { + assertThat("Credit card name should match", creditCard.name, equalTo(ccName)) + assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber)) + assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth)) + assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYear)) + saveHandled.complete(null) + } + }) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest, + ): GeckoResult { + assertThat("Session should not be null", session, notNullValue()) + + val option = request.options[0] + val cc = option.value + + assertThat("Credit card should not be null", cc, notNullValue()) + + assertThat( + "Credit card name should match", + cc.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + cc.number, + equalTo(ccNumber), + ) + assertThat( + "Credit card expiration month should match", + cc.expirationMonth, + equalTo(ccExpMonth), + ) + assertThat( + "Credit card expiration year should match", + cc.expirationYear, + equalTo(ccExpYear), + ) + + return GeckoResult.fromValue(request.confirm(option)) + } + }) + + // Enter the card values + mainSession.evaluateJS("document.querySelector('#form2 #name').value = '$ccName'") + mainSession.evaluateJS("document.querySelector('#form2 #name').focus()") + mainSession.evaluateJS("document.querySelector('#form2 #number').value = '$ccNumber'") + mainSession.evaluateJS("document.querySelector('#form2 #number').focus()") + mainSession.evaluateJS("document.querySelector('#form2 #exp').value = '$ccExpMonth/$ccExpYear'") + mainSession.evaluateJS("document.querySelector('#form2 #exp').focus()") + + // Submit the form + mainSession.evaluateJS("document.querySelector('#form2').requestSubmit()") + + sessionRule.waitForResult(saveHandled) + } + + @Test + fun creditCardSaveDismiss() { + val ccName = "MyCard" + val ccNumber = "5105105105105100" + val ccExpMonth = "6" + val ccExpYear = "2024" + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : StorageDelegate { + @AssertCalled + override fun onCreditCardFetch(): GeckoResult>? { + return null + } + }) + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled(count = 0) + override fun onCreditCardSave(creditCard: CreditCard) {} + }) + + // Enter the card values + mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'") + mainSession.evaluateJS("document.querySelector('#name').focus()") + mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'") + mainSession.evaluateJS("document.querySelector('#number').focus()") + mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'") + mainSession.evaluateJS("document.querySelector('#expMonth').focus()") + mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'") + mainSession.evaluateJS("document.querySelector('#expYear').focus()") + + // Submit the form + mainSession.evaluateJS("document.querySelector('form').requestSubmit()") + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest, + ): GeckoResult { + assertThat("Session should not be null", session, notNullValue()) + + val option = request.options[0] + val cc = option.value + + assertThat("Credit card should not be null", cc, notNullValue()) + + assertThat( + "Credit card name should match", + cc.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + cc.number, + equalTo(ccNumber), + ) + assertThat( + "Credit card expiration month should match", + cc.expirationMonth, + equalTo(ccExpMonth), + ) + assertThat( + "Credit card expiration year should match", + cc.expirationYear, + equalTo(ccExpYear), + ) + + return GeckoResult.fromValue(request.dismiss()) + } + }) + } + + @Test + fun creditCardSaveModifyAccept() { + val ccName = "MyCard" + val ccNumber = "5105105105105100" + val ccExpMonth = "6" + val ccExpYearNew = "2026" + val ccExpYear = "2024" + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onCreditCardSave(creditCard: CreditCard) { + assertThat("Credit card name should match", creditCard.name, equalTo(ccName)) + assertThat("Credit card number should match", creditCard.number, equalTo(ccNumber)) + assertThat("Credit card expiration month should match", creditCard.expirationMonth, equalTo(ccExpMonth)) + assertThat("Credit card expiration year should match", creditCard.expirationYear, equalTo(ccExpYearNew)) + saveHandled.complete(null) + } + }) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest, + ): GeckoResult { + assertThat("Session should not be null", session, notNullValue()) + + val option = request.options[0] + val cc = option.value + + assertThat("Credit card should not be null", cc, notNullValue()) + + assertThat( + "Credit card name should match", + cc.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + cc.number, + equalTo(ccNumber), + ) + assertThat( + "Credit card expiration month should match", + cc.expirationMonth, + equalTo(ccExpMonth), + ) + assertThat( + "Credit card expiration year should match", + cc.expirationYear, + equalTo(ccExpYear), + ) + + val modifiedCreditCard = CreditCard.Builder() + .name(cc.name) + .number(cc.number) + .expirationMonth(cc.expirationMonth) + .expirationYear(ccExpYearNew) + .build() + + return GeckoResult.fromValue(request.confirm(CreditCardSaveOption(modifiedCreditCard))) + } + }) + + // Enter the card values + mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'") + mainSession.evaluateJS("document.querySelector('#name').focus()") + mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber'") + mainSession.evaluateJS("document.querySelector('#number').focus()") + mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth'") + mainSession.evaluateJS("document.querySelector('#expMonth').focus()") + mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear'") + mainSession.evaluateJS("document.querySelector('#expYear').focus()") + + // Submit the form + mainSession.evaluateJS("document.querySelector('form').requestSubmit()") + + sessionRule.waitForResult(saveHandled) + } + + @Test + fun creditCardUpdateAccept() { + val ccName = "MyCard" + val ccNumber1 = "5105105105105100" + val ccExpMonth1 = "6" + val ccExpYear1 = "2024" + val ccNumber2 = "4111111111111111" + val ccExpMonth2 = "11" + val ccExpYear2 = "2021" + val savedCreditCards = mutableListOf() + + mainSession.loadTestPath(CC_FORM_HTML_PATH) + mainSession.waitForPageStop() + + val saveHandled1 = GeckoResult() + val saveHandled2 = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onCreditCardFetch(): GeckoResult> { + return GeckoResult.fromValue(savedCreditCards.toTypedArray()) + } + + @AssertCalled(count = 2) + override fun onCreditCardSave(creditCard: CreditCard) { + assertThat( + "Credit card name should match", + creditCard.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + creditCard.number, + equalTo(forEachCall(ccNumber1, ccNumber2)), + ) + assertThat( + "Credit card expiration month should match", + creditCard.expirationMonth, + equalTo(forEachCall(ccExpMonth1, ccExpMonth2)), + ) + assertThat( + "Credit card expiration year should match", + creditCard.expirationYear, + equalTo(forEachCall(ccExpYear1, ccExpYear2)), + ) + + val savedCC = CreditCard.Builder() + .guid("test1") + .name(creditCard.name) + .number(creditCard.number) + .expirationMonth(creditCard.expirationMonth) + .expirationYear(creditCard.expirationYear) + .build() + savedCreditCards.add(savedCC) + + if (sessionRule.currentCall.counter == 1) { + saveHandled1.complete(null) + } else if (sessionRule.currentCall.counter == 2) { + saveHandled2.complete(null) + } + } + }) + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 2) + override fun onCreditCardSave( + session: GeckoSession, + request: AutocompleteRequest, + ): GeckoResult { + assertThat("Session should not be null", session, notNullValue()) + + val option = request.options[0] + val cc = option.value + + assertThat("Credit card should not be null", cc, notNullValue()) + + assertThat( + "Credit card name should match", + cc.name, + equalTo(ccName), + ) + assertThat( + "Credit card number should match", + cc.number, + equalTo(forEachCall(ccNumber1, ccNumber2)), + ) + assertThat( + "Credit card expiration month should match", + cc.expirationMonth, + equalTo(forEachCall(ccExpMonth1, ccExpMonth2)), + ) + assertThat( + "Credit card expiration year should match", + cc.expirationYear, + equalTo(forEachCall(ccExpYear1, ccExpYear2)), + ) + + return GeckoResult.fromValue(request.confirm(option)) + } + }) + + // Enter the card values + mainSession.evaluateJS("document.querySelector('#name').value = '$ccName'") + mainSession.evaluateJS("document.querySelector('#name').focus()") + mainSession.evaluateJS("document.querySelector('#number').value = '$ccNumber1'") + mainSession.evaluateJS("document.querySelector('#number').focus()") + mainSession.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth1'") + mainSession.evaluateJS("document.querySelector('#expMonth').focus()") + mainSession.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear1'") + mainSession.evaluateJS("document.querySelector('#expYear').focus()") + + // Submit the form + mainSession.evaluateJS("document.querySelector('form').requestSubmit()") + + sessionRule.waitForResult(saveHandled1) + + // Update credit card + val session2 = sessionRule.createOpenSession() + session2.loadTestPath(CC_FORM_HTML_PATH) + session2.waitForPageStop() + session2.evaluateJS("document.querySelector('#name').value = '$ccName'") + session2.evaluateJS("document.querySelector('#name').focus()") + session2.evaluateJS("document.querySelector('#number').value = '$ccNumber2'") + session2.evaluateJS("document.querySelector('#number').focus()") + session2.evaluateJS("document.querySelector('#expMonth').value = '$ccExpMonth2'") + session2.evaluateJS("document.querySelector('#expMonth').focus()") + session2.evaluateJS("document.querySelector('#expYear').value = '$ccExpYear2'") + session2.evaluateJS("document.querySelector('#expYear').focus()") + + session2.evaluateJS("document.querySelector('form').requestSubmit()") + + sessionRule.waitForResult(saveHandled2) + } + + fun testLoginUsed(autofillEnabled: Boolean) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + val usedHandled = GeckoResult() + + val user1 = "user1x" + val pass1 = "pass1x" + val guid = "test-guid" + val origin = GeckoSessionTestRule.TEST_ENDPOINT + val savedLogin = LoginEntry.Builder() + .guid(guid) + .origin(origin) + .formActionOrigin(origin) + .username(user1) + .password(pass1) + .build() + val savedLogins = mutableListOf(savedLogin) + + if (autofillEnabled) { + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return GeckoResult.fromValue(savedLogins.toTypedArray()) + } + + @AssertCalled(count = 1) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) { + assertThat( + "Used fields should match", + usedFields, + equalTo(UsedField.PASSWORD), + ) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass1), + ) + + assertThat( + "GUID should match", + login.guid, + equalTo(guid), + ) + + usedHandled.complete(null) + } + }) + } else { + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return GeckoResult.fromValue(savedLogins.toTypedArray()) + } + + @AssertCalled(false) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) {} + }) + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(false) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + return null + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + if (autofillEnabled) { + sessionRule.waitForResult(usedHandled) + } else { + mainSession.waitForPageStop() + } + } + + @Test + fun loginUsed() { + testLoginUsed(true) + } + + @Test + fun loginAutofillDisabled() { + sessionRule.runtime.settings.loginAutofillEnabled = false + testLoginUsed(false) + sessionRule.runtime.settings.loginAutofillEnabled = true + } + + fun testPasswordAutofill(autofillEnabled: Boolean) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + val user1 = "user1x" + val pass1 = "pass1x" + val guid = "test-guid" + val origin = GeckoSessionTestRule.TEST_ENDPOINT + val savedLogin = LoginEntry.Builder() + .guid(guid) + .origin(origin) + .formActionOrigin(origin) + .username(user1) + .password(pass1) + .build() + val savedLogins = mutableListOf(savedLogin) + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return GeckoResult.fromValue(savedLogins.toTypedArray()) + } + + @AssertCalled(false) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) {} + }) + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(false) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + return null + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("document.querySelector('#user1').focus()") + mainSession.evaluateJS( + "document.querySelector('#user1').value = '$user1'", + ) + mainSession.pressKey(KeyEvent.KEYCODE_TAB) + + val pass = mainSession.evaluateJS( + "document.querySelector('#pass1').value", + ) as String + + if (autofillEnabled) { + assertThat( + "Password should match", + pass, + equalTo(pass1), + ) + } else { + assertThat( + "Password should not be filled", + pass, + equalTo(""), + ) + } + } + + @Test + fun loginAutofillDisabledPasswordAutofill() { + sessionRule.runtime.settings.loginAutofillEnabled = false + testPasswordAutofill(false) + sessionRule.runtime.settings.loginAutofillEnabled = true + } + + @Test + fun loginAutofillEnabledPasswordAutofill() { + testPasswordAutofill(true) + } + + @Test + fun loginSelectAccept() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "dom.disable_open_during_load" to false, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + // Test: + // 1. Load a login form page. + // 2. Input un/pw and submit. + // a. Ensure onLoginSave is called accordingly. + // b. Save the submitted login entry. + // 3. Reload the login form page. + // a. Ensure onLoginFetch is called. + // b. Return empty login entry list to avoid autofilling. + // 4. Input a new set of un/pw and submit. + // a. Ensure onLoginSave is called again. + // b. Save the submitted login entry. + // 5. Reload the login form page. + // 6. Focus on the username input field. + // a. Ensure onLoginFetch is called. + // b. Return the saved login entries. + // c. Ensure onLoginSelect is called. + // d. Select and return one of the options. + // e. Submit the form. + // f. Ensure that onLoginUsed is called. + + val user1 = "user1x" + val user2 = "user2x" + val pass1 = "pass1x" + val pass2 = "pass2x" + val savedLogins = mutableListOf() + + val saveHandled1 = GeckoResult() + val saveHandled2 = GeckoResult() + val selectHandled = GeckoResult() + val usedHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + var logins = mutableListOf() + + if (savedLogins.size == 2) { + logins = savedLogins + } + + return GeckoResult.fromValue(logins.toTypedArray()) + } + + @AssertCalled(count = 2) + override fun onLoginSave(login: LoginEntry) { + var username = "" + var password = "" + var handle = GeckoResult() + + if (sessionRule.currentCall.counter == 1) { + username = user1 + password = pass1 + handle = saveHandled1 + } else if (sessionRule.currentCall.counter == 2) { + username = user2 + password = pass2 + handle = saveHandled2 + } + + val savedLogin = LoginEntry.Builder() + .guid(login.username) + .origin(login.origin) + .formActionOrigin(login.formActionOrigin) + .username(login.username) + .password(login.password) + .build() + + savedLogins.add(savedLogin) + + assertThat( + "Username should match", + login.username, + equalTo(username), + ) + + assertThat( + "Password should match", + login.password, + equalTo(password), + ) + + handle.complete(null) + } + + @AssertCalled(count = 1) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) { + assertThat( + "Used fields should match", + usedFields, + equalTo(UsedField.PASSWORD), + ) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass1), + ) + + assertThat( + "GUID should match", + login.guid, + equalTo(user1), + ) + + usedHandled.complete(null) + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass1), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign login credentials. + mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'") + mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'") + + // Submit the form. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + sessionRule.waitForResult(saveHandled1) + + // Reload. + val session2 = sessionRule.createOpenSession() + session2.loadTestPath(FORMS3_HTML_PATH) + session2.waitForPageStop() + + session2.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user2), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass2), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign alternative login credentials. + session2.evaluateJS("document.querySelector('#user1').value = '$user2'") + session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'") + + // Submit the form. + session2.evaluateJS("document.querySelector('#form1').submit()") + sessionRule.waitForResult(saveHandled2) + + // Reload for the last time. + val session3 = sessionRule.createOpenSession() + + session3.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSelect( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + assertThat( + "There should be two options", + prompt.options.size, + equalTo(2), + ) + + var usernames = arrayOf(user1, user2) + var passwords = arrayOf(pass1, pass2) + + for (i in 0..1) { + val login = prompt.options[i].value + + assertThat("Login should not be null", login, notNullValue()) + assertThat( + "Username should match", + login.username, + equalTo(usernames[i]), + ) + assertThat( + "Password should match", + login.password, + equalTo(passwords[i]), + ) + } + + Handler(Looper.getMainLooper()).postDelayed({ + selectHandled.complete(null) + }, acceptDelay) + + return GeckoResult.fromValue(prompt.confirm(prompt.options[0])) + } + }) + + session3.loadTestPath(FORMS3_HTML_PATH) + session3.waitForPageStop() + + // Focus on the username input field. + session3.evaluateJS("document.querySelector('#user1').focus()") + sessionRule.waitForResult(selectHandled) + + assertThat( + "Filled username should match", + session3.evaluateJS("document.querySelector('#user1').value") as String, + equalTo(user1), + ) + + assertThat( + "Filled password should match", + session3.evaluateJS("document.querySelector('#pass1').value") as String, + equalTo(pass1), + ) + + // Submit the selection. + session3.evaluateJS("document.querySelector('#form1').submit()") + sessionRule.waitForResult(usedHandled) + } + + @Test + fun loginSelectModifyAccept() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "dom.disable_open_during_load" to false, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + // Test: + // 1. Load a login form page. + // 2. Input un/pw and submit. + // a. Ensure onLoginSave is called accordingly. + // b. Save the submitted login entry. + // 3. Reload the login form page. + // a. Ensure onLoginFetch is called. + // b. Return empty login entry list to avoid autofilling. + // 4. Input a new set of un/pw and submit. + // a. Ensure onLoginSave is called again. + // b. Save the submitted login entry. + // 5. Reload the login form page. + // 6. Focus on the username input field. + // a. Ensure onLoginFetch is called. + // b. Return the saved login entries. + // c. Ensure onLoginSelect is called. + // d. Select and return a new login entry. + // e. Submit the form. + // f. Ensure that onLoginUsed is not called. + + val user1 = "user1x" + val user2 = "user2x" + val pass1 = "pass1x" + val pass2 = "pass2x" + val userMod = "user1xmod" + val passMod = "pass1xmod" + val savedLogins = mutableListOf() + + val saveHandled1 = GeckoResult() + val saveHandled2 = GeckoResult() + val selectHandled = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + var logins = mutableListOf() + + if (savedLogins.size == 2) { + logins = savedLogins + } + + return GeckoResult.fromValue(logins.toTypedArray()) + } + + @AssertCalled(count = 2) + override fun onLoginSave(login: LoginEntry) { + var username = "" + var password = "" + var handle = GeckoResult() + + if (sessionRule.currentCall.counter == 1) { + username = user1 + password = pass1 + handle = saveHandled1 + } else if (sessionRule.currentCall.counter == 2) { + username = user2 + password = pass2 + handle = saveHandled2 + } + + val savedLogin = LoginEntry.Builder() + .guid(login.username) + .origin(login.origin) + .formActionOrigin(login.formActionOrigin) + .username(login.username) + .password(login.password) + .build() + + savedLogins.add(savedLogin) + + assertThat( + "Username should match", + login.username, + equalTo(username), + ) + + assertThat( + "Password should match", + login.password, + equalTo(password), + ) + + handle.complete(null) + } + + @AssertCalled(false) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) {} + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass1), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign login credentials. + mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'") + mainSession.evaluateJS("document.querySelector('#pass1').value = '$pass1'") + + // Submit the form. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + sessionRule.waitForResult(saveHandled1) + + // Reload. + val session2 = sessionRule.createOpenSession() + session2.loadTestPath(FORMS3_HTML_PATH) + session2.waitForPageStop() + + session2.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user2), + ) + + assertThat( + "Password should match", + login.password, + equalTo(pass2), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign alternative login credentials. + session2.evaluateJS("document.querySelector('#user1').value = '$user2'") + session2.evaluateJS("document.querySelector('#pass1').value = '$pass2'") + + // Submit the form. + session2.evaluateJS("document.querySelector('#form1').submit()") + sessionRule.waitForResult(saveHandled2) + + // Reload for the last time. + val session3 = sessionRule.createOpenSession() + + session3.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onLoginSelect( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + assertThat( + "There should be two options", + prompt.options.size, + equalTo(2), + ) + + var usernames = arrayOf(user1, user2) + var passwords = arrayOf(pass1, pass2) + + for (i in 0..1) { + val login = prompt.options[i].value + + assertThat("Login should not be null", login, notNullValue()) + assertThat( + "Username should match", + login.username, + equalTo(usernames[i]), + ) + assertThat( + "Password should match", + login.password, + equalTo(passwords[i]), + ) + } + + val login = prompt.options[0].value + val modOption = LoginSelectOption( + LoginEntry.Builder() + .origin(login.origin) + .formActionOrigin(login.formActionOrigin) + .username(userMod) + .password(passMod) + .build(), + ) + + Handler(Looper.getMainLooper()).postDelayed({ + selectHandled.complete(null) + }, acceptDelay) + + return GeckoResult.fromValue(prompt.confirm(modOption)) + } + }) + + session3.loadTestPath(FORMS3_HTML_PATH) + session3.waitForPageStop() + + // Focus on the username input field. + session3.evaluateJS("document.querySelector('#user1').focus()") + sessionRule.waitForResult(selectHandled) + + assertThat( + "Filled username should match", + session3.evaluateJS("document.querySelector('#user1').value") as String, + equalTo(userMod), + ) + + assertThat( + "Filled password should match", + session3.evaluateJS("document.querySelector('#pass1').value") as String, + equalTo(passMod), + ) + + // Submit the selection. + session3.evaluateJS("document.querySelector('#form1').submit()") + session3.waitForPageStop() + } + + @Test + fun loginSelectGeneratedPassword() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.generation.enabled" to true, + "signon.generation.available" to true, + "dom.disable_open_during_load" to false, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + // Test: + // 1. Load a login form page. + // 2. Input username. + // 3. Focus on the password input field. + // a. Ensure onLoginSelect is called with a generated password. + // b. Return the login entry with the generated password. + // 4. Submit the login form. + // a. Ensure onLoginSave is called with accordingly. + + val user1 = "user1x" + var genPass = "" + + val saveHandled1 = GeckoResult() + val selectHandled = GeckoResult() + var numSelects = 0 + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + assertThat("Domain should match", domain, equalTo("localhost")) + + return GeckoResult.fromValue(null) + } + + @AssertCalled(count = 1) + override fun onLoginSave(login: LoginEntry) { + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + assertThat( + "Password should match", + login.password, + equalTo(genPass), + ) + + saveHandled1.complete(null) + } + + @AssertCalled(false) + override fun onLoginUsed(login: LoginEntry, usedFields: Int) {} + }) + + mainSession.loadTestPath(FORMS4_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled + override fun onLoginSelect( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + assertThat( + "There should be one option", + prompt.options.size, + equalTo(1), + ) + + val option = prompt.options[0] + val login = option.value + + assertThat( + "Hint should match", + option.hint, + equalTo(SelectOption.Hint.GENERATED), + ) + + assertThat("Login should not be null", login, notNullValue()) + assertThat( + "Password should not be empty", + login.password, + not(isEmptyOrNullString()), + ) + + genPass = login.password + + if (numSelects == 0) { + Handler(Looper.getMainLooper()).postDelayed({ + selectHandled.complete(null) + }, acceptDelay) + } + ++numSelects + + return GeckoResult.fromValue(prompt.confirm(option)) + } + + @AssertCalled(count = 1) + override fun onLoginSave( + session: GeckoSession, + prompt: AutocompleteRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + + val option = prompt.options[0] + val login = option.value + + assertThat("Login should not be null", login, notNullValue()) + + assertThat( + "Username should match", + login.username, + equalTo(user1), + ) + + // TODO: The flag is only set for login entry updates yet. + /* + assertThat( + "Hint should match", + option.hint, + equalTo(LoginSaveOption.Hint.GENERATED)) + */ + + assertThat( + "Password should not be empty", + login.password, + not(isEmptyOrNullString()), + ) + + assertThat( + "Password should match", + login.password, + equalTo(genPass), + ) + + return GeckoResult.fromValue(prompt.confirm(option)) + } + }) + + // Assign username and focus on password. + mainSession.evaluateJS("document.querySelector('#user1').value = '$user1'") + mainSession.evaluateJS("document.querySelector('#pass1').focus()") + sessionRule.waitForResult(selectHandled) + + assertThat( + "Filled username should match", + mainSession.evaluateJS("document.querySelector('#user1').value") as String, + equalTo(user1), + ) + + val filledPass = mainSession.evaluateJS( + "document.querySelector('#pass1').value", + ) as String + + assertThat( + "Password should not be empty", + filledPass, + not(isEmptyOrNullString()), + ) + + assertThat( + "Filled password should match", + filledPass, + equalTo(genPass), + ) + + // Submit the selection. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + mainSession.waitForPageStop() + } + + @Test + fun loginSelectDismiss() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + // Enable login management since it's disabled in automation. + "signon.rememberSignons" to true, + "signon.autofillForms.http" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + val user = arrayOf("user1x", "user2x") + val pass = arrayOf("pass1x", "pass2x") + val guid = arrayOf("test-guid1", "test-guid2") + val origin = GeckoSessionTestRule.TEST_ENDPOINT + val savedLogins = arrayOf( + LoginEntry.Builder() + .guid(guid[0]) + .origin(origin) + .formActionOrigin(origin) + .username(user[0]) + .password(pass[0]) + .build(), + LoginEntry.Builder() + .guid(guid[1]) + .origin(origin) + .formActionOrigin(origin) + .username(user[1]) + .password(pass[1]) + .build(), + ) + + sessionRule.delegateUntilTestEnd(object : StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + return GeckoResult.fromValue(savedLogins) + } + }) + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + val promptHandled = GeckoResult() + mainSession.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled + override fun onLoginSelect(session: GeckoSession, prompt: AutocompleteRequest): GeckoResult? { + assertThat( + "There should be two options", + prompt.options.size, + equalTo(2), + ) + prompt.setDelegate(promptInstanceDelegate) + Handler(Looper.getMainLooper()).postDelayed({ + promptHandled.complete(null) + }, acceptDelay) + + return GeckoResult() + } + }) + + mainSession.loadTestPath(FORMS3_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('#user1').focus()") + sessionRule.waitForResult(promptHandled) + mainSession.evaluateJS("document.querySelector('#user1').blur()") + sessionRule.waitForResult(result) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt new file mode 100644 index 0000000000..f1adc7bf1e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt @@ -0,0 +1,715 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.Rect +import android.util.SparseArray +import android.view.KeyEvent +import android.view.View +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.TextInputDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports + +@RunWith(Parameterized::class) +@MediumTest +class AutofillDelegateTest : BaseSessionTest() { + + companion object { + @get:Parameterized.Parameters(name = "{0}") + @JvmStatic + val parameters: List> = listOf( + arrayOf("#inProcess"), + arrayOf("#oop"), + ) + } + + @field:Parameterized.Parameter(0) + @JvmField + var iframe: String = "" + + // Whether the iframe is loaded in-process (i.e. with the same origin as the + // outer html page) or out-of-process. + private val pageUrl by lazy { + when (iframe) { + "#inProcess" -> "http://example.org/tests/junit/forms_xorigin.html" + "#oop" -> createTestUrl(FORMS_XORIGIN_HTML_PATH) + else -> throw IllegalStateException() + } + } + + @Test fun autofillCommit() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "signon.rememberSignons" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + mainSession.loadUri(pageUrl) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + // We expect to get a call to onSessionStart and many calls to onNodeAdd depending + // on timing. + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + // Assign node values. + mainSession.evaluateJS("document.querySelector('#user1').value = 'user1x'") + mainSession.evaluateJS("document.querySelector('#pass1').value = 'pass1x'") + mainSession.evaluateJS("document.querySelector('#email1').value = 'e@mail.com'") + mainSession.evaluateJS("document.querySelector('#number1').value = '1'") + + // Submit the session. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(order = [1, 2, 3, 4]) + override fun onNodeUpdate( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + } + + @AssertCalled(order = [5]) + override fun onSessionCommit( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + val autofillSession = mainSession.autofillSession + assertThat( + "Values should match", + countAutofillNodes({ + autofillSession.dataFor(it).value == "user1x" + }), + equalTo(1), + ) + assertThat( + "Values should match", + countAutofillNodes({ + autofillSession.dataFor(it).value == "pass1x" + }), + equalTo(1), + ) + assertThat( + "Values should match", + countAutofillNodes({ + autofillSession.dataFor(it).value == "e@mail.com" + }), + equalTo(1), + ) + assertThat( + "Values should match", + countAutofillNodes({ + autofillSession.dataFor(it).value == "1" + }), + equalTo(1), + ) + } + }) + } + + @Test fun autofillCommitIdValue() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "signon.rememberSignons" to true, + "signon.userInputRequiredToCapture.enabled" to false, + ), + ) + + mainSession.loadTestPath(FORMS_ID_VALUE_HTML_PATH) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + // Assign node values. + mainSession.evaluateJS("document.querySelector('#value').value = 'pass1x'") + + // Submit the session. + mainSession.evaluateJS("document.querySelector('#form1').submit()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(order = [1]) + override fun onNodeUpdate( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + } + + @AssertCalled(order = [2]) + override fun onSessionCommit( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat( + "Values should match", + countAutofillNodes({ + mainSession.autofillSession.dataFor(it).value == "pass1x" + }), + equalTo(1), + ) + } + }) + } + + @Test fun autofill() { + // Test parts of the Oreo auto-fill API; there is another autofill test in + // SessionAccessibility for a11y auto-fill support. + mainSession.loadUri(pageUrl) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + // We expect many call to onNodeAdd while loading the page + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + val autofills = mapOf( + "#user1" to "bar", + "#user2" to "bar", + "#pass1" to "baz", + "#pass2" to "baz", + "#email1" to "a@b.c", + "#number1" to "24", + "#tel1" to "42", + ) + + // Set up promises to monitor the values changing. + val promises = autofills.map { entry -> + // Repeat each test with both the top document and the iframe document. + mainSession.evaluatePromiseJS( + """ + window.getDataForAllFrames('${entry.key}', '${entry.value}') + """, + ) + } + + val autofillValues = SparseArray() + + // Perform auto-fill and return number of auto-fills performed. + fun checkAutofillChild(child: Autofill.Node, domain: String) { + // Seal the node info instance so we can perform actions on it. + if (child.children.isNotEmpty()) { + for (c in child.children) { + checkAutofillChild(c!!, child.domain) + } + } + + if (child == mainSession.autofillSession.root) { + return + } + + assertThat( + "Should have HTML tag", + child.tag, + not(isEmptyOrNullString()), + ) + if (domain != "") { + assertThat( + "Web domain should match its parent.", + child.domain, + equalTo(domain), + ) + } + + if (child.inputType == Autofill.InputType.TEXT) { + assertThat("Input should be enabled", child.enabled, equalTo(true)) + assertThat( + "Input should be focusable", + child.focusable, + equalTo(true), + ) + + assertThat("Should have HTML tag", child.tag, equalTo("input")) + assertThat("Should have ID attribute", child.attributes.get("id"), not(isEmptyOrNullString())) + } + + val childId = mainSession.autofillSession.dataFor(child).id + autofillValues.append( + childId, + when (child.inputType) { + Autofill.InputType.NUMBER -> "24" + Autofill.InputType.PHONE -> "42" + Autofill.InputType.TEXT -> when (child.hint) { + Autofill.Hint.PASSWORD -> "baz" + Autofill.Hint.EMAIL_ADDRESS -> "a@b.c" + else -> "bar" + } + else -> "bar" + }, + ) + } + + val nodes = mainSession.autofillSession.root + checkAutofillChild(nodes, "") + + mainSession.autofillSession.autofill(autofillValues) + + // Wait on the promises and check for correct values. + for (values in promises.map { it.value.asJsonArray() }) { + for (i in 0 until values.length()) { + val (key, actual, expected, eventInterface) = values.get(i).asJSList() + + assertThat("Auto-filled value must match ($key)", actual, equalTo(expected)) + assertThat( + "input event should be dispatched with InputEvent interface", + eventInterface, + equalTo("InputEvent"), + ) + } + } + } + + @Test fun autofillUnknownValue() { + // Test parts of the Oreo auto-fill API; there is another autofill test in + // SessionAccessibility for a11y auto-fill support. + mainSession.loadUri(pageUrl) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + val autofillValues = SparseArray() + autofillValues.append(-1, "lobster") + mainSession.autofillSession.autofill(autofillValues) + } + + private fun countAutofillNodes( + cond: (Autofill.Node) -> Boolean = + { it.inputType != Autofill.InputType.NONE }, + root: Autofill.Node? = null, + ): Int { + val node = if (root !== null) root else mainSession.autofillSession.root + return (if (cond(node)) 1 else 0) + + node.children.sumOf { + countAutofillNodes(cond, it) + } + } + + @WithDisplay(width = 100, height = 100) + @Test + fun autofillNavigation() { + // Wait for the accessibility nodes to populate. + mainSession.loadUri(pageUrl) + + sessionRule.waitUntilCalled(object : + Autofill.Delegate, + ShouldContinue, + GeckoSession.ProgressDelegate { + var nodeCount = 0 + + // Continue waiting util we get all 16 nodes + override fun shouldContinue(): Boolean = nodeCount < 16 + + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("Node should be valid", node, notNullValue()) + nodeCount = countAutofillNodes() + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + assertThat( + "Initial auto-fill count should match", + countAutofillNodes(), + equalTo(16), + ) + + // Now wait for the nodes to clear. + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionCancel(session: GeckoSession) {} + + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + assertThat( + "Should not have auto-fill fields", + countAutofillNodes(), + equalTo(0), + ) + + mainSession.goBack() + sessionRule.waitUntilCalled(object : + Autofill.Delegate, + GeckoSession.ProgressDelegate, + ShouldContinue { + var nodeCount = 0 + override fun shouldContinue(): Boolean = nodeCount < 16 + + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("Node should be valid", node, notNullValue()) + nodeCount = countAutofillNodes() + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + assertThat( + "Should have auto-fill fields again", + countAutofillNodes(), + equalTo(16), + ) + + var focused = mainSession.autofillSession.focused + assertThat( + "Should not have focused field", + countAutofillNodes({ it == focused }), + equalTo(0), + ) + + mainSession.evaluateJS("document.querySelector('#pass2').focus()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + }) + + focused = mainSession.autofillSession.focused + assertThat( + "Should have one focused field", + countAutofillNodes({ it == focused }), + equalTo(1), + ) + // The focused field, its siblings, its parent, and the root node should + // be visible. + // Hidden elements are ignored. + // TODO: Is this actually correct? Should the whole focused branch be + // visible or just the nodes as described above? + assertThat( + "Should have nine visible nodes", + countAutofillNodes({ node -> mainSession.autofillSession.isVisible(node) }), + equalTo(8), + ) + + mainSession.evaluateJS("document.querySelector('#pass2').blur()") + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeBlur( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + }) + + focused = mainSession.autofillSession.focused + assertThat( + "Should not have focused field", + countAutofillNodes({ it == focused }), + equalTo(0), + ) + } + + @WithDisplay(height = 100, width = 100) + @Test + fun autofillUserpass() { + mainSession.loadTestPath(FORMS2_HTML_PATH) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + // Perform auto-fill and return number of auto-fills performed. + fun checkAutofillChild(child: Autofill.Node): Int { + var sum = 0 + // Seal the node info instance so we can perform actions on it. + for (c in child.children) { + sum += checkAutofillChild(c!!) + } + + if (child.hint == Autofill.Hint.NONE) { + return sum + } + + val childId = mainSession.autofillSession.dataFor(child).id + assertThat("ID should be valid", childId, not(equalTo(View.NO_ID))) + assertThat("Should have HTML tag", child.tag, equalTo("input")) + + return sum + 1 + } + + val root = mainSession.autofillSession.root + + // form and iframe have each have 2 nodes with hints. + assertThat( + "autofill hint count", + checkAutofillChild(root), + equalTo(4), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun autofillActiveChange() { + // We should blur the active autofill node if the session is set + // inactive. Likewise, we should focus a node once we return. + mainSession.loadUri(pageUrl) + // Wait for the auto-fill nodes to populate. + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + // For the root document and the iframe document, each has a form group and + // a group for inputs outside of forms, so the total count is 4. + @AssertCalled(count = 1) + override fun onSessionStart(session: GeckoSession) {} + + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + mainSession.evaluateJS("document.querySelector('#pass2').focus()") + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + }) + + var focused = mainSession.autofillSession.focused + assertThat( + "Should have one focused field", + countAutofillNodes({ it == focused }), + equalTo(1), + ) + + // Make sure we get NODE_BLURRED when inactive + mainSession.setActive(false) + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeBlur( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + }) + + // Make sure we get NODE_FOCUSED when active once again + mainSession.setActive(true) + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + }) + + focused = mainSession.autofillSession.focused + assertThat( + "Should have one focused field", + countAutofillNodes({ focused == it }), + equalTo(1), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun autofillAutocompleteAttribute() { + mainSession.loadTestPath(FORMS_AUTOCOMPLETE_HTML_PATH) + sessionRule.waitUntilCalled(object : Autofill.Delegate, GeckoSession.ProgressDelegate { + @AssertCalled(count = -1) + override fun onNodeAdd( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) {} + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + }) + + fun checkAutofillChild(child: Autofill.Node): Int { + var sum = 0 + for (c in child.children) { + sum += checkAutofillChild(c!!) + } + if (child.hint == Autofill.Hint.NONE) { + return sum + } + assertThat("Should have HTML tag", child.tag, equalTo("input")) + return sum + 1 + } + + val root = mainSession.autofillSession.root + // Each page has 3 nodes for autofill. + assertThat( + "autofill hint count", + checkAutofillChild(root), + equalTo(6), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun autofillWaitForKeyboard() { + // Wait for the accessibility nodes to populate. + mainSession.loadUri(pageUrl) + mainSession.waitForPageStop() + + mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT) + mainSession.evaluateJS("document.querySelector('#pass2').focus()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate, TextInputDelegate { + @AssertCalled(order = [2]) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + } + + @AssertCalled(order = [1]) + override fun showSoftInput(session: GeckoSession) {} + }) + } + + @WithDisplay(width = 300, height = 1000) + @Test + fun autofillIframe() { + // No way to click in x-origin frame. + assumeThat("Not in x-origin", iframe, not(equalTo("#oop"))) + + // Wait for the accessibility nodes to populate. + mainSession.loadUri(pageUrl) + mainSession.waitForPageStop() + + // Get non-iframe position of input element + var screenRect = Rect() + mainSession.evaluateJS("document.querySelector('#pass2').focus()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + screenRect = node.screenRect + } + }) + + mainSession.evaluateJS("document.querySelector('iframe').contentDocument.querySelector('#pass2').focus()") + + sessionRule.waitUntilCalled(object : Autofill.Delegate { + @AssertCalled(count = 1) + override fun onNodeFocus( + session: GeckoSession, + node: Autofill.Node, + data: Autofill.NodeData, + ) { + assertThat("ID should be valid", node, notNullValue()) + // iframe's input element should consider iframe's offset. 200 is enough offset. + assertThat("position is valid", node.getScreenRect().top, greaterThanOrEqualTo(screenRect.top + 200)) + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt new file mode 100644 index 0000000000..d2964aa54b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt @@ -0,0 +1,297 @@ +/* -*- 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.test + +import android.os.Parcel +import android.os.SystemClock +import android.view.KeyEvent +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Rule +import org.junit.rules.ErrorCollector +import org.junit.rules.RuleChain +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import kotlin.reflect.KClass + +/** + * Common base class for tests using GeckoSessionTestRule, + * providing the test rule and other utilities. + */ +open class BaseSessionTest(noErrorCollector: Boolean = false) { + companion object { + const val RESUBMIT_CONFIRM = "/assets/www/resubmit.html" + const val BEFORE_UNLOAD = "/assets/www/beforeunload.html" + const val CLICK_TO_RELOAD_HTML_PATH = "/assets/www/clickToReload.html" + const val CLIPBOARD_READ_HTML_PATH = "/assets/www/clipboard_read.html" + const val CONTENT_CRASH_URL = "about:crashcontent" + const val DOWNLOAD_HTML_PATH = "/assets/www/download.html" + const val FORM_BLANK_HTML_PATH = "/assets/www/form_blank.html" + const val FORMS_HTML_PATH = "/assets/www/forms.html" + const val FORMS_XORIGIN_HTML_PATH = "/assets/www/forms_xorigin.html" + const val FORMS2_HTML_PATH = "/assets/www/forms2.html" + const val FORMS3_HTML_PATH = "/assets/www/forms3.html" + const val FORMS4_HTML_PATH = "/assets/www/forms4.html" + const val FORMS5_HTML_PATH = "/assets/www/forms5.html" + const val SELECT_HTML_PATH = "/assets/www/select.html" + const val SELECT_MULTIPLE_HTML_PATH = "/assets/www/select-multiple.html" + const val SELECT_LISTBOX_HTML_PATH = "/assets/www/select-listbox.html" + const val ADDRESS_FORM_HTML_PATH = "/assets/www/address_form.html" + const val FORMS_AUTOCOMPLETE_HTML_PATH = "/assets/www/forms_autocomplete.html" + const val FORMS_ID_VALUE_HTML_PATH = "/assets/www/forms_id_value.html" + const val CC_FORM_HTML_PATH = "/assets/www/cc_form.html" + const val HELLO_HTML_PATH = "/assets/www/hello.html" + const val HELLO2_HTML_PATH = "/assets/www/hello2.html" + const val HELLO_IFRAME_HTML_PATH = "/assets/www/iframe_hello.html" + const val INPUTS_PATH = "/assets/www/inputs.html" + const val INVALID_URI = "not a valid uri" + const val LINKS_HTML_PATH = "/assets/www/links.html" + const val LOREM_IPSUM_HTML_PATH = "/assets/www/loremIpsum.html" + const val METATAGS_PATH = "/assets/www/metatags.html" + const val MOUSE_TO_RELOAD_HTML_PATH = "/assets/www/mouseToReload.html" + const val NEW_SESSION_CHILD_HTML_PATH = "/assets/www/newSession_child.html" + const val NEW_SESSION_HTML_PATH = "/assets/www/newSession.html" + const val POPUP_HTML_PATH = "/assets/www/popup.html" + const val PRINT_CONTENT_CHANGE = "/assets/www/print_content_change.html" + const val PRINT_IFRAME = "/assets/www/print_iframe.html" + const val PROMPT_HTML_PATH = "/assets/www/prompts.html" + const val SAVE_STATE_PATH = "/assets/www/saveState.html" + const val TEST_GIF_PATH = "/assets/www/images/test.gif" + const val TITLE_CHANGE_HTML_PATH = "/assets/www/titleChange.html" + const val TRACKERS_PATH = "/assets/www/trackers.html" + const val VIDEO_OGG_PATH = "/assets/www/ogg.html" + const val VIDEO_MP4_PATH = "/assets/www/mp4.html" + const val VIDEO_WEBM_PATH = "/assets/www/webm.html" + const val VIDEO_BAD_PATH = "/assets/www/badVideoPath.html" + const val UNKNOWN_HOST_URI = "https://www.test.invalid/" + const val UNKNOWN_PROTOCOL_URI = "htt://invalid" + const val FULLSCREEN_PATH = "/assets/www/fullscreen.html" + const val VIEWPORT_PATH = "/assets/www/viewport.html" + const val IFRAME_REDIRECT_LOCAL = "/assets/www/iframe_redirect_local.html" + const val IFRAME_REDIRECT_AUTOMATION = "/assets/www/iframe_redirect_automation.html" + const val AUTOPLAY_PATH = "/assets/www/autoplay.html" + const val SCROLL_TEST_PATH = "/assets/www/scroll.html" + const val COLORS_HTML_PATH = "/assets/www/colors.html" + const val FIXED_BOTTOM = "/assets/www/fixedbottom.html" + const val FIXED_VH = "/assets/www/fixedvh.html" + const val FIXED_PERCENT = "/assets/www/fixedpercent.html" + const val STORAGE_TITLE_HTML_PATH = "/assets/www/reflect_local_storage_into_title.html" + const val HUNG_SCRIPT = "/assets/www/hungScript.html" + const val PUSH_HTML_PATH = "/assets/www/push/push.html" + const val OPEN_WINDOW_PATH = "/assets/www/worker/open_window.html" + const val OPEN_WINDOW_TARGET_PATH = "/assets/www/worker/open_window_target.html" + const val DATA_URI_PATH = "/assets/www/data_uri.html" + const val IFRAME_UNKNOWN_PROTOCOL = "/assets/www/iframe_unknown_protocol.html" + const val MEDIA_SESSION_DOM1_PATH = "/assets/www/media_session_dom1.html" + const val MEDIA_SESSION_DEFAULT1_PATH = "/assets/www/media_session_default1.html" + const val TOUCH_HTML_PATH = "/assets/www/touch.html" + const val TOUCH_XORIGIN_HTML_PATH = "/assets/www/touch_xorigin.html" + const val GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH = "/assets/www/getusermedia_xorigin_container.html" + const val ROOT_100_PERCENT_HEIGHT_HTML_PATH = "/assets/www/root_100_percent_height.html" + const val ROOT_98VH_HTML_PATH = "/assets/www/root_98vh.html" + const val ROOT_100VH_HTML_PATH = "/assets/www/root_100vh.html" + const val IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH = "/assets/www/iframe_100_percent_height_no_scrollable.html" + const val IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH = "/assets/www/iframe_100_percent_height_scrollable.html" + const val IFRAME_98VH_SCROLLABLE_HTML_PATH = "/assets/www/iframe_98vh_scrollable.html" + const val IFRAME_98VH_NO_SCROLLABLE_HTML_PATH = "/assets/www/iframe_98vh_no_scrollable.html" + const val TOUCHSTART_HTML_PATH = "/assets/www/touchstart.html" + const val TOUCH_ACTION_HTML_PATH = "/assets/www/touch-action.html" + const val TOUCH_ACTION_WHEEL_LISTENER_HTML_PATH = "/assets/www/touch-action-wheel-listener.html" + const val OVERSCROLL_BEHAVIOR_AUTO_HTML_PATH = "/assets/www/overscroll-behavior-auto.html" + const val OVERSCROLL_BEHAVIOR_AUTO_NONE_HTML_PATH = "/assets/www/overscroll-behavior-auto-none.html" + const val OVERSCROLL_BEHAVIOR_NONE_AUTO_HTML_PATH = "/assets/www/overscroll-behavior-none-auto.html" + const val OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH = "/assets/www/overscroll-behavior-none-on-non-root.html" + const val SCROLL_HANDOFF_HTML_PATH = "/assets/www/scroll-handoff.html" + const val SHOW_DYNAMIC_TOOLBAR_HTML_PATH = "/assets/www/showDynamicToolbar.html" + const val CONTEXT_MENU_AUDIO_HTML_PATH = "/assets/www/context_menu_audio.html" + const val CONTEXT_MENU_IMAGE_NESTED_HTML_PATH = "/assets/www/context_menu_image_nested.html" + const val CONTEXT_MENU_IMAGE_HTML_PATH = "/assets/www/context_menu_image.html" + const val CONTEXT_MENU_LINK_HTML_PATH = "/assets/www/context_menu_link.html" + const val CONTEXT_MENU_VIDEO_HTML_PATH = "/assets/www/context_menu_video.html" + const val CONTEXT_MENU_BLOB_FULL_HTML_PATH = "/assets/www/context_menu_blob_full.html" + const val CONTEXT_MENU_BLOB_BUFFERED_HTML_PATH = "/assets/www/context_menu_blob_buffered.html" + const val REMOTE_IFRAME = "/assets/www/accessibility/test-remote-iframe.html" + const val LOCAL_IFRAME = "/assets/www/accessibility/test-local-iframe.html" + const val BODY_FULLY_COVERED_BY_GREEN_ELEMENT = "/assets/www/red-background-body-fully-covered-by-green-element.html" + const val COLOR_GRID_HTML_PATH = "/assets/www/color_grid.html" + const val COLOR_ORANGE_BACKGROUND_HTML_PATH = "/assets/www/color_orange_background.html" + const val TRACEMONKEY_PDF_PATH = "/assets/www/tracemonkey.pdf" + const val HELLO_PDF_WORLD_PDF_PATH = "/assets/www/helloPDFWorld.pdf" + const val NO_META_VIEWPORT_HTML_PATH = "/assets/www/no-meta-viewport.html" + + const val TEST_ENDPOINT = GeckoSessionTestRule.TEST_ENDPOINT + const val TEST_HOST = GeckoSessionTestRule.TEST_HOST + const val TEST_PORT = GeckoSessionTestRule.TEST_PORT + } + + val sessionRule = GeckoSessionTestRule() + + // Override this to include more `evaluate` rules in the chain + @get:Rule + open val rules = RuleChain.outerRule(sessionRule) + + @get:Rule var temporaryProfile = TemporaryProfileRule() + + @get:Rule val errors = ErrorCollector() + + val mainSession get() = sessionRule.session + + fun assertThat(reason: String, v: T, m: Matcher) = sessionRule.checkThat(reason, v, m) + fun assertInAutomationThat(reason: String, v: T, m: Matcher) = + if (sessionRule.env.isAutomation) { + assertThat(reason, v, m) + } else { + assumeThat(reason, v, m) + } + + init { + if (!noErrorCollector) { + sessionRule.errorCollector = errors + } + } + + fun forEachCall(vararg values: T): T = sessionRule.forEachCall(*values) + + fun getTestBytes(path: String) = + InstrumentationRegistry.getInstrumentation().targetContext.resources.assets + .open(path.removePrefix("/assets/")).readBytes() + + fun createTestUrl(path: String) = GeckoSessionTestRule.TEST_ENDPOINT + path + + fun GeckoSession.loadTestPath(path: String) = + this.loadUri(createTestUrl(path)) + + inline fun GeckoRuntimeSettings.toParcel(lambda: (Parcel) -> Unit) { + val parcel = Parcel.obtain() + try { + this.writeToParcel(parcel, 0) + + val pos = parcel.dataPosition() + parcel.setDataPosition(0) + + lambda(parcel) + + assertThat( + "Read parcel matches written parcel", + parcel.dataPosition(), + Matchers.equalTo(pos), + ) + } finally { + parcel.recycle() + } + } + + fun GeckoSession.open() = + sessionRule.openSession(this) + + fun GeckoSession.waitForPageStop() = + sessionRule.waitForPageStop(this) + + fun GeckoSession.waitForPageStops(count: Int) = + sessionRule.waitForPageStops(this, count) + + fun GeckoSession.waitUntilCalled(ifce: KClass<*>, vararg methods: String) = + sessionRule.waitUntilCalled(this, ifce, *methods) + + fun GeckoSession.waitUntilCalled(callback: Any) = + sessionRule.waitUntilCalled(this, callback) + + fun GeckoSession.addDisplay(x: Int, y: Int) = + sessionRule.addDisplay(this, x, y) + + fun GeckoSession.releaseDisplay() = + sessionRule.releaseDisplay(this) + + fun GeckoSession.forCallbacksDuringWait(callback: Any) = + sessionRule.forCallbacksDuringWait(this, callback) + + fun GeckoSession.delegateUntilTestEnd(callback: Any) = + sessionRule.delegateUntilTestEnd(this, callback) + + fun GeckoSession.delegateDuringNextWait(callback: Any) = + sessionRule.delegateDuringNextWait(this, callback) + + fun GeckoSession.synthesizeTap(x: Int, y: Int) = + sessionRule.synthesizeTap(this, x, y) + + fun GeckoSession.synthesizeMouseMove(x: Int, y: Int) = + sessionRule.synthesizeMouseMove(this, x, y) + + fun GeckoSession.evaluateJS(js: String): Any? = + sessionRule.evaluateJS(this, js) + + fun GeckoSession.evaluatePromiseJS(js: String): GeckoSessionTestRule.ExtensionPromise = + sessionRule.evaluatePromiseJS(this, js) + + fun GeckoSession.waitForJS(js: String): Any? = + sessionRule.waitForJS(this, js) + + fun GeckoSession.waitForRoundTrip() = sessionRule.waitForRoundTrip(this) + + fun GeckoSession.pressKey(keyCode: Int) { + // Create a Promise to listen to the key event, and wait on it below. + val promise = this.evaluatePromiseJS( + """new Promise(r => window.addEventListener( + 'keyup', r, { once: true }))""", + ) + val time = SystemClock.uptimeMillis() + val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, keyCode, 0) + this.textInput.onKeyDown(keyCode, keyEvent) + this.textInput.onKeyUp( + keyCode, + KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP), + ) + promise.value + } + + fun GeckoSession.flushApzRepaints() = sessionRule.flushApzRepaints(this) + + fun GeckoSession.promiseAllPaintsDone() = sessionRule.promiseAllPaintsDone(this) + + fun GeckoSession.getLinkColor(selector: String) = sessionRule.getLinkColor(this, selector) + + fun GeckoSession.setResolutionAndScaleTo(resolution: Float) = + sessionRule.setResolutionAndScaleTo(this, resolution) + + fun GeckoSession.triggerCookieBannerDetected() = + sessionRule.triggerCookieBannerDetected(this) + + fun GeckoSession.triggerCookieBannerHandled() = + sessionRule.triggerCookieBannerHandled(this) + + var GeckoSession.active: Boolean + get() = sessionRule.getActive(this) + set(value) = setActive(value) + + @Suppress("UNCHECKED_CAST") + fun Any?.asJsonArray(): JSONArray = this as JSONArray + + @Suppress("UNCHECKED_CAST") + fun JSONObject.asMap(): Map { + val result = HashMap() + for (key in this.keys()) { + result[key] = this[key] as V + } + return result + } + + @Suppress("UNCHECKED_CAST") + fun Any?.asJSList(): List { + val array = this.asJsonArray() + val result = ArrayList() + + for (i in 0 until array.length()) { + result.add(array[i] as T) + } + + return result + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt new file mode 100644 index 0000000000..d0ad03a439 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt @@ -0,0 +1,302 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// For ContentBlockingException +@file:Suppress("DEPRECATION") + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.ContentBlocking +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode +import org.mozilla.geckoview.ContentBlockingController +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +@RunWith(AndroidJUnit4::class) +@MediumTest +class ContentBlockingControllerTest : BaseSessionTest() { + // Smoke test for safe browsing settings, most testing is through platform tests + @Test + fun safeBrowsingSettings() { + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + val google = contentBlocking.safeBrowsingProviders.first { it.name == "google" } + val google4 = contentBlocking.safeBrowsingProviders.first { it.name == "google4" } + + // Let's make sure the initial value of safeBrowsingProviders is correct + assertThat( + "Expected number of default providers", + contentBlocking.safeBrowsingProviders.size, + equalTo(2), + ) + assertThat("Google legacy provider is present", google, notNullValue()) + assertThat("Google provider is present", google4, notNullValue()) + + // Checks that the default provider values make sense + assertThat( + "Default provider values are sensible", + google.getHashUrl, + containsString("/safebrowsing-dummy/"), + ) + assertThat( + "Default provider values are sensible", + google.advisoryUrl, + startsWith("https://developers.google.com/"), + ) + assertThat( + "Default provider values are sensible", + google4.getHashUrl, + containsString("/safebrowsing4-dummy/"), + ) + assertThat( + "Default provider values are sensible", + google4.updateUrl, + containsString("/safebrowsing4-dummy/"), + ) + assertThat( + "Default provider values are sensible", + google4.dataSharingUrl, + startsWith("https://safebrowsing.googleapis.com/"), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "browser.safebrowsing.provider.google4.updateURL", + "browser.safebrowsing.provider.google4.gethashURL", + "browser.safebrowsing.provider.google4.lists", + ) + + assertThat( + "Initial prefs value is correct", + originalPrefs[0] as String, + equalTo(google4.updateUrl), + ) + assertThat( + "Initial prefs value is correct", + originalPrefs[1] as String, + equalTo(google4.getHashUrl), + ) + assertThat( + "Initial prefs value is correct", + originalPrefs[2] as String, + equalTo(google4.lists.joinToString(",")), + ) + + // Makes sure we can override a default value + val override = ContentBlocking.SafeBrowsingProvider + .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER) + .updateUrl("http://test-update-url.com") + .getHashUrl("http://test-get-hash-url.com") + .build() + + // ... and that we can add a custom provider + val custom = ContentBlocking.SafeBrowsingProvider + .withName("custom-provider") + .updateUrl("http://test-custom-update-url.com") + .getHashUrl("http://test-custom-get-hash-url.com") + .lists("a", "b", "c") + .build() + + assertThat( + "Override value is correct", + override.updateUrl, + equalTo("http://test-update-url.com"), + ) + assertThat( + "Override value is correct", + override.getHashUrl, + equalTo("http://test-get-hash-url.com"), + ) + + assertThat( + "Custom provider value is correct", + custom.updateUrl, + equalTo("http://test-custom-update-url.com"), + ) + assertThat( + "Custom provider value is correct", + custom.getHashUrl, + equalTo("http://test-custom-get-hash-url.com"), + ) + assertThat( + "Custom provider value is correct", + custom.lists, + equalTo(arrayOf("a", "b", "c")), + ) + + contentBlocking.setSafeBrowsingProviders(override, custom) + + val prefs = sessionRule.getPrefs( + "browser.safebrowsing.provider.google4.updateURL", + "browser.safebrowsing.provider.google4.gethashURL", + "browser.safebrowsing.provider.custom-provider.updateURL", + "browser.safebrowsing.provider.custom-provider.gethashURL", + "browser.safebrowsing.provider.custom-provider.lists", + ) + + assertThat( + "Pref value is set correctly", + prefs[0] as String, + equalTo("http://test-update-url.com"), + ) + assertThat( + "Pref value is set correctly", + prefs[1] as String, + equalTo("http://test-get-hash-url.com"), + ) + assertThat( + "Pref value is set correctly", + prefs[2] as String, + equalTo("http://test-custom-update-url.com"), + ) + assertThat( + "Pref value is set correctly", + prefs[3] as String, + equalTo("http://test-custom-get-hash-url.com"), + ) + assertThat( + "Pref value is set correctly", + prefs[4] as String, + equalTo("a,b,c"), + ) + + // Restore defaults + contentBlocking.setSafeBrowsingProviders(google, google4) + + // Checks that after restoring the providers the prefs get updated + val restoredPrefs = sessionRule.getPrefs( + "browser.safebrowsing.provider.google4.updateURL", + "browser.safebrowsing.provider.google4.gethashURL", + "browser.safebrowsing.provider.google4.lists", + ) + + assertThat( + "Restored prefs value is correct", + restoredPrefs[0] as String, + equalTo(originalPrefs[0]), + ) + assertThat( + "Restored prefs value is correct", + restoredPrefs[1] as String, + equalTo(originalPrefs[1]), + ) + assertThat( + "Restored prefs value is correct", + restoredPrefs[2] as String, + equalTo(originalPrefs[2]), + ) + } + + @Test + fun getLog() { + val category = ContentBlocking.AntiTracking.TEST + sessionRule.runtime.settings.contentBlocking.setAntiTracking(category) + mainSession.settings.useTrackingProtection = true + mainSession.loadTestPath(TRACKERS_PATH) + + sessionRule.waitUntilCalled(object : ContentBlocking.Delegate { + @AssertCalled(count = 1) + override fun onContentBlocked( + session: GeckoSession, + event: ContentBlocking.BlockEvent, + ) { + } + }) + + sessionRule.waitForResult( + sessionRule.runtime.contentBlockingController.getLog(mainSession).accept { + assertThat("Log must not be null", it, notNullValue()) + assertThat("Log must have at least one entry", it?.size, not(0)) + it?.forEach { + it.blockingData.forEach { + assertThat( + "Category must match", + it.category, + equalTo(ContentBlockingController.Event.BLOCKED_TRACKING_CONTENT), + ) + assertThat("Blocked must be true", it.blocked, equalTo(true)) + assertThat("Count must be at least 1", it.count, not(0)) + } + } + }, + ) + } + + @Test + fun cookieBannerHandlingSettings() { + // Check default value + + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.cookieBannerMode, + equalTo(CookieBannerMode.COOKIE_BANNER_MODE_DISABLED), + ) + assertThat( + "Expect correct default value for private browsing", + contentBlocking.cookieBannerModePrivateBrowsing, + equalTo(CookieBannerMode.COOKIE_BANNER_MODE_REJECT), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "cookiebanners.service.mode", + "cookiebanners.service.mode.privateBrowsing", + ) + + assertThat("Initial value is correct", originalPrefs[0] as Int, equalTo(contentBlocking.cookieBannerMode)) + assertThat("Initial value is correct", originalPrefs[1] as Int, equalTo(contentBlocking.cookieBannerModePrivateBrowsing)) + + contentBlocking.cookieBannerMode = CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT + contentBlocking.cookieBannerModePrivateBrowsing = CookieBannerMode.COOKIE_BANNER_MODE_DISABLED + + val actualPrefs = sessionRule.getPrefs( + "cookiebanners.service.mode", + "cookiebanners.service.mode.privateBrowsing", + ) + + assertThat("Initial value is correct", actualPrefs[0] as Int, equalTo(contentBlocking.cookieBannerMode)) + assertThat("Initial value is correct", actualPrefs[1] as Int, equalTo(contentBlocking.cookieBannerModePrivateBrowsing)) + } + + @Test + fun cookieBannerHandlingDetectOnlyModeSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.cookieBannerDetectOnlyMode, + equalTo(false), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "cookiebanners.service.detectOnly", + ) + + assertThat( + "Initial value is correct", + originalPrefs[0] as Boolean, + equalTo(contentBlocking.cookieBannerDetectOnlyMode), + ) + + contentBlocking.cookieBannerDetectOnlyMode = true + + val actualPrefs = sessionRule.getPrefs( + "cookiebanners.service.detectOnly", + ) + + assertThat( + "Initial value is correct", + actualPrefs[0] as Boolean, + equalTo(contentBlocking.cookieBannerDetectOnlyMode), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt new file mode 100644 index 0000000000..868491cd85 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt @@ -0,0 +1,51 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeThat +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.BuildConfig +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ContentCrashTest : BaseSessionTest() { + val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext) + + @Before + fun setup() { + assertTrue(client.connect(env.defaultTimeoutMillis)) + client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_FOREGROUND_CHILD) + } + + @IgnoreCrash + @Test + fun crashContent() { + // We need the crash reporter for this test + assumeTrue(BuildConfig.MOZ_CRASHREPORTER) + + // TODO: bug 1710940 + assumeThat(sessionRule.env.isIsolatedProcess, Matchers.equalTo(false)) + + mainSession.loadUri(CONTENT_CRASH_URL) + mainSession.waitUntilCalled(ContentDelegate::class, "onCrash") + + // This test is really slow so we allow double the usual timeout + var evalResult = client.getEvalResult(env.defaultTimeoutMillis * 2) + assertTrue(evalResult.mMsg, evalResult.mResult) + } + + @After + fun teardown() { + client.disconnect() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt new file mode 100644 index 0000000000..f560a2af22 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt @@ -0,0 +1,278 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.SystemClock +import android.view.* // ktlint-disable no-wildcard-imports +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assert.assertNull +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ContentDelegateChildTest : BaseSessionTest() { + + private fun sendLongPress(x: Float, y: Float) { + val downTime = SystemClock.uptimeMillis() + var eventTime = SystemClock.uptimeMillis() + var event = MotionEvent.obtain( + downTime, + eventTime, + MotionEvent.ACTION_DOWN, + x, + y, + 0, + ) + mainSession.panZoomController.onTouchEvent(event) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnAudio() { + mainSession.loadTestPath(CONTEXT_MENU_AUDIO_HTML_PATH) + mainSession.waitForPageStop() + sendLongPress(0f, 0f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be audio.", + element.type, + equalTo(ContextElement.TYPE_AUDIO), + ) + assertThat( + "The element source should be the mp3 file.", + element.srcUri, + endsWith("owl.mp3"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnBlobBuffered() { + // Bug 1810736 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + mainSession.loadTestPath(CONTEXT_MENU_BLOB_BUFFERED_HTML_PATH) + mainSession.waitForPageStop() + mainSession.waitForRoundTrip() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be video.", + element.type, + equalTo(ContextElement.TYPE_VIDEO), + ) + assertNull( + "Buffered blob should not have a srcUri.", + element.srcUri, + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnBlobFull() { + mainSession.loadTestPath(CONTEXT_MENU_BLOB_FULL_HTML_PATH) + mainSession.waitForPageStop() + mainSession.waitForRoundTrip() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be image.", + element.type, + equalTo(ContextElement.TYPE_IMAGE), + ) + assertThat( + "Alternate text should match.", + element.altText, + equalTo("An orange circle."), + ) + assertThat( + "The element source should begin with blob.", + element.srcUri, + startsWith("blob:"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnImageNested() { + mainSession.loadTestPath(CONTEXT_MENU_IMAGE_NESTED_HTML_PATH) + mainSession.waitForPageStop() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be image.", + element.type, + equalTo(ContextElement.TYPE_IMAGE), + ) + assertThat( + "Alternate text should match.", + element.altText, + equalTo("Test Image"), + ) + assertThat( + "The element source should be the image file.", + element.srcUri, + endsWith("test.gif"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnImage() { + mainSession.loadTestPath(CONTEXT_MENU_IMAGE_HTML_PATH) + mainSession.waitForPageStop() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be image.", + element.type, + equalTo(ContextElement.TYPE_IMAGE), + ) + assertThat( + "Alternate text should match.", + element.altText, + equalTo("Test Image"), + ) + assertThat( + "The element source should be the image file.", + element.srcUri, + endsWith("test.gif"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnLink() { + mainSession.loadTestPath(CONTEXT_MENU_LINK_HTML_PATH) + mainSession.waitForPageStop() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be none.", + element.type, + equalTo(ContextElement.TYPE_NONE), + ) + assertThat( + "The element link title should be the title of the anchor.", + element.title, + equalTo("Hello Link Title"), + ) + assertThat( + "The element link URI should be the href of the anchor.", + element.linkUri, + endsWith("hello.html"), + ) + assertThat( + "The element link text content should be the text content of the anchor.", + element.textContent, + equalTo("Hello World"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun requestContextMenuOnVideo() { + // Bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + mainSession.loadTestPath(CONTEXT_MENU_VIDEO_HTML_PATH) + mainSession.waitForPageStop() + sendLongPress(50f, 50f) + + mainSession.waitUntilCalled(object : ContentDelegate { + + @AssertCalled(count = 1) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + assertThat( + "Type should be video.", + element.type, + equalTo(ContextElement.TYPE_VIDEO), + ) + assertThat( + "The element source should be the video file.", + element.srcUri, + endsWith("short.mp4"), + ) + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt new file mode 100644 index 0000000000..a871c09a5a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt @@ -0,0 +1,161 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.annotation.AnyThread +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ContentDelegateMultipleSessionsTest : BaseSessionTest() { + val contentProcNameRegex = ".*:tab\\d+$".toRegex() + + @AnyThread + fun killAllContentProcesses() { + val contentProcessPids = sessionRule.getAllSessionPids() + for (pid in contentProcessPids) { + sessionRule.killContentProcess(pid) + } + } + + fun resetContentProcesses() { + val isMainSessionAlreadyOpen = mainSession.isOpen() + killAllContentProcesses() + + if (isMainSessionAlreadyOpen) { + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onKill(session: GeckoSession) { + } + }) + } + + mainSession.open() + } + + fun getE10sProcessCount(): Int { + val extensionProcessPref = "extensions.webextensions.remote" + val isExtensionProcessEnabled = (sessionRule.getPrefs(extensionProcessPref)[0] as Boolean) + val e10sProcessCountPref = "dom.ipc.processCount" + var numContentProcesses = (sessionRule.getPrefs(e10sProcessCountPref)[0] as Int) + + if (isExtensionProcessEnabled && numContentProcesses > 1) { + // Extension process counts against the content process budget + --numContentProcesses + } + + return numContentProcesses + } + + // This function ensures that a second GeckoSession that shares the same + // content process as mainSession is returned to the test: + // + // First, we assume that we're starting with a known initial state with respect + // to sessions and content processes: + // * mainSession is the only session, it is open, and its content process is the only + // content process (but note that the content process assigned to mainSession is + // *not* guaranteed to be ":tab0"). + // * With multi-e10s configured to run N content processes, we create and open + // an additional N content processes. With the default e10s process allocation + // scheme, this means that the first N-1 new sessions we create each get their + // own content process. The Nth new session is assigned to the same content + // process as mainSession, which is the session we want to return to the test. + fun getSecondGeckoSession(): GeckoSession { + val numContentProcesses = getE10sProcessCount() + + // If we change the content process allocation scheme, this function will need to be + // fixed to ensure that we still have two test sessions in at least one content + // process (with one of those sessions being mainSession). + val additionalSessions = Array(numContentProcesses) { _ -> sessionRule.createOpenSession() } + + // The second session that shares a process with mainSession should be at + // the end of the array. + return additionalSessions.last() + } + + @Before + fun setup() { + resetContentProcesses() + } + + @IgnoreCrash + @Test + fun crashContentMultipleSessions() { + // TODO: Bug 1673952 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val newSession = getSecondGeckoSession() + + // We can inadvertently catch the `onCrash` call for the cached session if we don't specify + // individual sessions here. Therefore, assert 'onCrash' is called for the two sessions + // individually... + val mainSessionCrash = GeckoResult() + val newSessionCrash = GeckoResult() + + // ...but we use GeckoResult.allOf for waiting on the aggregated results + val allCrashesFound = GeckoResult.allOf(mainSessionCrash, newSessionCrash) + + sessionRule.delegateUntilTestEnd(object : ContentDelegate { + fun reportCrash(session: GeckoSession) { + if (session == mainSession) { + mainSessionCrash.complete(null) + } else if (session == newSession) { + newSessionCrash.complete(null) + } + } + + // Slower devices may not catch crashes in a timely manner, so we check to see + // if either `onKill` or `onCrash` is called + override fun onCrash(session: GeckoSession) { + reportCrash(session) + } + override fun onKill(session: GeckoSession) { + reportCrash(session) + } + }) + + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + mainSession.loadUri(CONTENT_CRASH_URL) + + sessionRule.waitForResult(allCrashesFound) + } + + @IgnoreCrash + @Test + fun killContentMultipleSessions() { + val newSession = getSecondGeckoSession() + + val mainSessionKilled = GeckoResult() + val newSessionKilled = GeckoResult() + + val allKillEventsReceived = GeckoResult.allOf(mainSessionKilled, newSessionKilled) + + sessionRule.delegateUntilTestEnd(object : ContentDelegate { + override fun onKill(session: GeckoSession) { + if (session == mainSession) { + mainSessionKilled.complete(null) + } else if (session == newSession) { + newSessionKilled.complete(null) + } + } + }) + + killAllContentProcesses() + + sessionRule.waitForResult(allKillEventsReceived) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt new file mode 100644 index 0000000000..65a07d384d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt @@ -0,0 +1,660 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.SurfaceTexture +import android.net.Uri +import android.view.PointerIcon +import android.view.Surface +import androidx.annotation.AnyThread +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode +import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import java.io.ByteArrayInputStream + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ContentDelegateTest : BaseSessionTest() { + @Test fun titleChange() { + mainSession.loadTestPath(TITLE_CHANGE_HTML_PATH) + + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 2) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat( + "Title should match", + title, + equalTo(forEachCall("Title1", "Title2")), + ) + } + }) + } + + @Test fun openInAppRequest() { + // Testing WebResponse behavior + val data = "Hello, World.".toByteArray() + val fileHeader = "attachment; filename=\"hello-world.txt\"" + val requestExternal = true + val skipConfirmation = true + var response = WebResponse.Builder(HELLO_HTML_PATH) + .statusCode(200) + .body(ByteArrayInputStream(data)) + .addHeader("Content-Type", "application/txt") + .addHeader("Content-Length", data.size.toString()) + .addHeader("Content-Disposition", fileHeader) + .requestExternalApp(requestExternal) + .skipConfirmation(skipConfirmation) + .build() + assertThat( + "Filename matches as expected", + response.headers["Content-Disposition"], + equalTo(fileHeader), + ) + assertThat( + "Request external response matches as expected.", + requestExternal, + equalTo(response.requestExternalApp), + ) + assertThat( + "Skipping the confirmation matches as expected.", + skipConfirmation, + equalTo(response.skipConfirmation), + ) + } + + @Test fun downloadOneRequest() { + // disable test on pgo for frequently failing Bug 1543355 + assumeThat(sessionRule.env.isDebugBuild, equalTo(true)) + + mainSession.loadTestPath(DOWNLOAD_HTML_PATH) + + sessionRule.waitUntilCalled(object : NavigationDelegate, ContentDelegate { + + @AssertCalled(count = 2) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return null + } + + @AssertCalled(false) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + + @AssertCalled(count = 1) + override fun onExternalResponse(session: GeckoSession, response: WebResponse) { + assertThat("Uri should start with data:", response.uri, startsWith("blob:")) + assertThat("We should download the thing", String(response.body?.readBytes()!!), equalTo("Downloaded Data")) + // The headers below are special headers that we try to get for responses of any kind (http, blob, etc.) + // Note the case of the header keys. In the WebResponse object, all of them are lower case. + assertThat("Content type should match", response.headers.get("content-type"), equalTo("text/plain")) + assertThat("Content length should be non-zero", response.headers.get("Content-Length")!!.toLong(), greaterThan(0L)) + assertThat("Filename should match", response.headers.get("cONTent-diSPOsiTion"), equalTo("attachment; filename=\"download.txt\"")) + assertThat("Request external response should not be set.", response.requestExternalApp, equalTo(false)) + assertThat("Should not skip the confirmation on a regular download.", response.skipConfirmation, equalTo(false)) + } + }) + } + + @IgnoreCrash + @Test + fun crashContent() { + // TODO: bug 1710940 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + mainSession.loadUri(CONTENT_CRASH_URL) + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onCrash(session: GeckoSession) { + assertThat( + "Session should be closed after a crash", + session.isOpen, + equalTo(false), + ) + } + }) + + // Recover immediately + mainSession.open() + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + @IgnoreCrash + @WithDisplay(width = 10, height = 10) + @Test + fun crashContent_tapAfterCrash() { + // TODO: bug 1710940 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + mainSession.delegateUntilTestEnd(object : ContentDelegate { + override fun onCrash(session: GeckoSession) { + mainSession.open() + mainSession.loadTestPath(HELLO_HTML_PATH) + } + }) + + mainSession.synthesizeTap(5, 5) + mainSession.loadUri(CONTENT_CRASH_URL) + mainSession.waitForPageStop() + + mainSession.synthesizeTap(5, 5) + mainSession.reload() + mainSession.waitForPageStop() + } + + @AnyThread + fun killAllContentProcesses() { + val contentProcessPids = sessionRule.getAllSessionPids() + for (pid in contentProcessPids) { + sessionRule.killContentProcess(pid) + } + } + + @IgnoreCrash + @Test + fun killContent() { + killAllContentProcesses() + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onKill(session: GeckoSession) { + assertThat( + "Session should be closed after being killed", + session.isOpen, + equalTo(false), + ) + } + }) + + mainSession.open() + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + private fun goFullscreen() { + sessionRule.setPrefsUntilTestEnd(mapOf("full-screen-api.allow-trusted-requests-only" to false)) + mainSession.loadTestPath(FULLSCREEN_PATH) + mainSession.waitForPageStop() + val promise = mainSession.evaluatePromiseJS("document.querySelector('#fullscreen').requestFullscreen()") + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { + assertThat("Div went fullscreen", fullScreen, equalTo(true)) + } + }) + promise.value + } + + private fun waitForFullscreenExit() { + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { + assertThat("Div left fullscreen", fullScreen, equalTo(false)) + } + }) + } + + @Test fun fullscreen() { + goFullscreen() + val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()") + waitForFullscreenExit() + promise.value + } + + @Test fun sessionExitFullscreen() { + goFullscreen() + mainSession.exitFullScreen() + waitForFullscreenExit() + } + + @Test fun firstComposite() { + val display = mainSession.acquireDisplay() + val texture = SurfaceTexture(0) + texture.setDefaultBufferSize(100, 100) + val surface = Surface(texture) + display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build()) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstComposite(session: GeckoSession) { + } + }) + display.surfaceDestroyed() + display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build()) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstComposite(session: GeckoSession) { + } + }) + display.surfaceDestroyed() + mainSession.releaseDisplay(display) + } + + @WithDisplay(width = 10, height = 10) + @Test + fun firstContentfulPaint() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + } + + @Test fun webAppManifestPref() { + val initialState = sessionRule.runtime.settings.getWebManifestEnabled() + val jsToRun = "document.querySelector('link[rel=manifest]').relList.supports('manifest');" + + // Check pref'ed off + sessionRule.runtime.settings.setWebManifestEnabled(false) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop(mainSession) + + var result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean) + + assertThat("Disabling pref makes relList.supports('manifest') return false", false, result) + + // Check pref'ed on + sessionRule.runtime.settings.setWebManifestEnabled(true) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop(mainSession) + + result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean) + assertThat("Enabling pref makes relList.supports('manifest') return true", true, result) + + sessionRule.runtime.settings.setWebManifestEnabled(initialState) + } + + @Test fun webAppManifest() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page load should succeed", success, equalTo(true)) + } + + @AssertCalled(count = 1) + override fun onWebAppManifest(session: GeckoSession, manifest: JSONObject) { + // These values come from the manifest at assets/www/manifest.webmanifest + assertThat("name should match", manifest.getString("name"), equalTo("App")) + assertThat("short_name should match", manifest.getString("short_name"), equalTo("app")) + assertThat("display should match", manifest.getString("display"), equalTo("standalone")) + + // The color here is "cadetblue" converted to #aarrggbb. + assertThat("theme_color should match", manifest.getString("theme_color"), equalTo("#ff5f9ea0")) + assertThat("background_color should match", manifest.getString("background_color"), equalTo("#eec0ffee")) + assertThat("start_url should match", manifest.getString("start_url"), endsWith("/assets/www/start/index.html")) + + val icon = manifest.getJSONArray("icons").getJSONObject(0) + + val iconSrc = Uri.parse(icon.getString("src")) + assertThat("icon should have a valid src", iconSrc, notNullValue()) + assertThat("icon src should be absolute", iconSrc.isAbsolute, equalTo(true)) + assertThat("icon should have sizes", icon.getString("sizes"), not(isEmptyOrNullString())) + assertThat("icon type should match", icon.getString("type"), equalTo("image/gif")) + } + }) + } + + @Test fun previewImage() { + mainSession.loadTestPath(METATAGS_PATH) + mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onPreviewImage(session: GeckoSession, previewImageUrl: String) { + assertThat("Preview image should match", previewImageUrl, equalTo("https://test.com/og-image-url")) + } + }) + } + + @Test fun viewportFit() { + mainSession.loadTestPath(VIEWPORT_PATH) + mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page load should succeed", success, equalTo(true)) + } + + @AssertCalled(count = 1) + override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) { + assertThat("viewport-fit should match", viewportFit, equalTo("cover")) + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page load should succeed", success, equalTo(true)) + } + + @AssertCalled(count = 1) + override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) { + assertThat("viewport-fit should match", viewportFit, equalTo("auto")) + } + }) + } + + @Test fun closeRequest() { + if (!sessionRule.env.isAutomation) { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.allow_scripts_to_close_windows" to true)) + } + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("window.close()") + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onCloseRequest(session: GeckoSession) { + } + }) + } + + @Test fun windowOpenClose() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val newSession = sessionRule.createClosedSession() + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return GeckoResult.fromValue(newSession) + } + }) + + mainSession.evaluateJS("const w = window.open('about:blank'); w.close()") + + newSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onCloseRequest(session: GeckoSession) { + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun cookieBannerDetectedEvent() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT, + ), + ) + + val detectHandled = GeckoResult() + mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate { + override fun onCookieBannerDetected( + session: GeckoSession, + ) { + detectHandled.complete(null) + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.triggerCookieBannerDetected() + + sessionRule.waitForResult(detectHandled) + } + + @Test fun cookieBannerHandledEvent() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT, + ), + ) + + val handleHandled = GeckoResult() + mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate { + override fun onCookieBannerHandled( + session: GeckoSession, + ) { + handleHandled.complete(null) + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.triggerCookieBannerHandled() + + sessionRule.waitForResult(handleHandled) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun setCursor() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.body.style.cursor = 'wait'") + mainSession.synthesizeMouseMove(50, 50) + + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onPointerIconChange(session: GeckoSession, icon: PointerIcon) { + // PointerIcon has no compare method. + } + }) + + val delegate = mainSession.contentDelegate + mainSession.contentDelegate = null + mainSession.evaluateJS("document.body.style.cursor = 'text'") + for (i in 51..70) { + mainSession.synthesizeMouseMove(i, 50) + // No wait function since we remove content delegate. + mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))") + } + mainSession.contentDelegate = delegate + } + + /** + * Preferences to induce wanted behaviour. + */ + private fun setHangReportTestPrefs(timeout: Int = 20000) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.max_script_run_time" to 1, + "dom.max_chrome_script_run_time" to 1, + "dom.max_ext_content_script_run_time" to 1, + "dom.ipc.cpow.timeout" to 100, + "browser.hangNotification.waitPeriod" to timeout, + ), + ) + } + + /** + * With no delegate set, the default behaviour is to stop hung scripts. + */ + @NullDelegate(ContentDelegate::class) + @Test + fun stopHungProcessDefault() { + setHangReportTestPrefs() + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "The script did not complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Started"), + ) + } + }) + sessionRule.waitForPageStop(mainSession) + } + + /** + * With no overriding implementation for onSlowScript, the default behaviour is to stop hung + * scripts. + */ + @Test fun stopHungProcessNull() { + setHangReportTestPrefs() + sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { + // default onSlowScript returns null + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "The script did not complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Started"), + ) + } + }) + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.waitForPageStop(mainSession) + } + + /** + * Test that, with a 'do nothing' delegate, the hung process completes after its delay + */ + @Test fun stopHungProcessDoNothing() { + setHangReportTestPrefs() + var scriptHungReportCount = 0 + sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { + @AssertCalled() + override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { + scriptHungReportCount += 1 + return GeckoResult.fromValue(null) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("The delegate was informed of the hang repeatedly", scriptHungReportCount, greaterThan(1)) + assertThat( + "The script did complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Finished"), + ) + } + }) + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.waitForPageStop(mainSession) + } + + /** + * Test that the delegate is called and can stop a hung script + */ + @Test fun stopHungProcess() { + setHangReportTestPrefs() + sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { + return GeckoResult.fromValue(SlowScriptResponse.STOP) + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "The script did not complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Started"), + ) + } + }) + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.waitForPageStop(mainSession) + } + + /** + * Test that the delegate is called and can continue executing hung scripts + */ + @Test fun stopHungProcessWait() { + setHangReportTestPrefs() + sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { + return GeckoResult.fromValue(SlowScriptResponse.CONTINUE) + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "The script did complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Finished"), + ) + } + }) + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.waitForPageStop(mainSession) + } + + /** + * Test that the delegate is called and paused scripts re-notify after the wait period + */ + @Test fun stopHungProcessWaitThenStop() { + setHangReportTestPrefs(500) + var scriptWaited = false + sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { + @AssertCalled(count = 2, order = [1, 2]) + override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { + return if (!scriptWaited) { + scriptWaited = true + GeckoResult.fromValue(SlowScriptResponse.CONTINUE) + } else { + GeckoResult.fromValue(SlowScriptResponse.STOP) + } + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "The script did not complete.", + mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, + equalTo("Started"), + ) + } + }) + mainSession.loadTestPath(HUNG_SCRIPT) + sessionRule.waitForPageStop(mainSession) + } + + /** + * Test that the display mode is applied to CSS media query + */ + @Test fun displayMode() { + val pwaSession = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .displayMode(GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN) + .build(), + ) + pwaSession.loadTestPath(HELLO_HTML_PATH) + pwaSession.waitForPageStop() + + val matches = pwaSession.evaluateJS("window.matchMedia('(display-mode: fullscreen)').matches") as Boolean + assertThat( + "display-mode should be fullscreen", + matches, + equalTo(true), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt new file mode 100644 index 0000000000..86c8e9cac6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt @@ -0,0 +1,23 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class DisplayTest : BaseSessionTest() { + + @Test(expected = IllegalStateException::class) + fun doubleAcquire() { + val display = mainSession.acquireDisplay() + assertThat("Display should not be null", display, notNullValue()) + try { + mainSession.acquireDisplay() + } finally { + mainSession.releaseDisplay(display) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt new file mode 100644 index 0000000000..6a79df6173 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt @@ -0,0 +1,727 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.* // ktlint-disable no-wildcard-imports +import android.graphics.Bitmap +import android.os.SystemClock +import android.util.Base64 +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.hamcrest.Matchers.closeTo +import org.hamcrest.Matchers.equalTo +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.ScrollDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import java.io.ByteArrayOutputStream + +private const val SCREEN_WIDTH = 100 +private const val SCREEN_HEIGHT = 200 + +@RunWith(AndroidJUnit4::class) +@MediumTest +class DynamicToolbarTest : BaseSessionTest() { + // Makes sure we can load a page when the dynamic toolbar is bigger than the whole content + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun outOfRangeValue() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT + 1 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + } + + private fun assertScreenshotResult(result: GeckoResult, comparisonImage: Bitmap) { + sessionRule.waitForResult(result).let { + assertThat( + "Screenshot is not null", + it, + notNullValue(), + ) + assertThat("Widths are the same", comparisonImage.width, equalTo(it.width)) + assertThat("Heights are the same", comparisonImage.height, equalTo(it.height)) + assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount)) + assertThat("Configs are the same", comparisonImage.config, equalTo(it.config)) + + if (!comparisonImage.sameAs(it)) { + val outputForComparison = ByteArrayOutputStream() + comparisonImage.compress(Bitmap.CompressFormat.PNG, 100, outputForComparison) + + val outputForActual = ByteArrayOutputStream() + it.compress(Bitmap.CompressFormat.PNG, 100, outputForActual) + val actualString: String = Base64.encodeToString(outputForActual.toByteArray(), Base64.DEFAULT) + val comparisonString: String = Base64.encodeToString(outputForComparison.toByteArray(), Base64.DEFAULT) + + assertThat("Encoded strings are the same", comparisonString, equalTo(actualString)) + } + + assertThat("Bytes are the same", comparisonImage.sameAs(it), equalTo(true)) + } + } + + /** + * Returns a whole green Bitmap. + * This Bitmap would be a reference image of tests in this file. + */ + private fun getComparisonScreenshot(width: Int, height: Int): Bitmap { + val screenshotFile = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(screenshotFile) + val paint = Paint() + paint.color = Color.rgb(0, 128, 0) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + return screenshotFile + } + + // With the dynamic toolbar max height vh units values exceed + // the top most window height. This is a test case that exceeded area + // is rendered properly (on the compositor). + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun positionFixedElementClipping() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(SCREEN_HEIGHT / 2) } + + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + // FIXED_VH is an HTML file which has a position:fixed element whose + // style is "width: 100%; height: 200vh" and the document is scaled by + // minimum-scale 0.5, so that the height of the element exceeds the + // window height. + mainSession.loadTestPath(BaseSessionTest.FIXED_VH) + mainSession.waitForPageStop() + + // Scroll down bit, if we correctly render the document, the position + // fixed element still covers whole the document area. + mainSession.evaluateJS("window.scrollTo({ top: 100, behavior: 'instant' })") + + // Wait a while to make sure the scrolling result is composited on the compositor + // since capturePixels() takes a snapshot directly from the compositor without + // waiting for a corresponding MozAfterPaint on the main-thread so it's possible + // to take a stale snapshot even if it's a result of syncronous scrolling. + mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 1000))") + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + // Asynchronous scrolling with the dynamic toolbar max height causes + // situations where the visual viewport size gets bigger than the layout + // viewport on the compositor thread because of 200vh position:fixed + // elements. This is a test case that a 200vh position element is + // properly rendered its positions. + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun layoutViewportExpansion() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(SCREEN_HEIGHT / 2) } + + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(BaseSessionTest.FIXED_VH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("window.scrollTo(0, 100)") + + // Scroll back to the original position by asynchronous scrolling. + mainSession.evaluateJS("window.scrollTo({ top: 0, behavior: 'smooth' })") + + mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 1000))") + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun visualViewportEvents() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_VH) + mainSession.waitForPageStop() + + val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double + val scale = mainSession.evaluateJS("window.visualViewport.scale") as Double + + for (i in 1..dynamicToolbarMaxHeight) { + // Simulate the dynamic toolbar is going to be hidden. + sessionRule.display?.run { setVerticalClipping(-i) } + + val expectedViewportHeight = (SCREEN_HEIGHT - dynamicToolbarMaxHeight + i) / scale / pixelRatio + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.visualViewport.addEventListener('resize', resolve(window.visualViewport.height)); + }); + """.trimIndent(), + ) + + assertThat( + "The visual viewport height should be changed in response to the dynamc toolbar transition", + promise.value as Double, + closeTo(expectedViewportHeight, .01), + ) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun percentBaseValueOnPositionFixedElement() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_PERCENT) + mainSession.waitForPageStop() + + val originalHeight = mainSession.evaluateJS( + """ + getComputedStyle(document.querySelector('#fixed-element')).height + """.trimIndent(), + ) as String + + // Set the vertical clipping value to the middle of toolbar transition. + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight / 2) } + + var height = mainSession.evaluateJS( + """ + getComputedStyle(document.querySelector('#fixed-element')).height + """.trimIndent(), + ) as String + + assertThat( + "The %-based height should be the static in the middle of toolbar tansition", + height, + equalTo(originalHeight), + ) + + // Set the vertical clipping value to hide the toolbar completely. + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + height = mainSession.evaluateJS( + """ + getComputedStyle(document.querySelector('#fixed-element')).height + """.trimIndent(), + ) as String + + val scale = mainSession.evaluateJS("window.visualViewport.scale") as Double + val expectedHeight = (SCREEN_HEIGHT / scale).toInt() + assertThat( + "The %-based height should be now recomputed based on the screen height", + height, + equalTo(expectedHeight.toString() + "px"), + ) + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun resizeEvents() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_VH) + mainSession.waitForPageStop() + + for (i in 1..dynamicToolbarMaxHeight - 1) { + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + let fired = false; + window.addEventListener('resize', () => { fired = true; }, { once: true }); + // Note that `resize` event is fired just before rAF callbacks, so under ideal + // circumstances waiting for a rAF should be sufficient, even if it's not sufficient + // unexpected resize event(s) will be caught in the next loop. + requestAnimationFrame(() => { resolve(fired); }); + }); + """.trimIndent(), + ) + + // Simulate the dynamic toolbar is going to be hidden. + sessionRule.display?.run { setVerticalClipping(-i) } + assertThat( + "'resize' event on window should not be fired in response to the dynamc toolbar transition", + promise.value as Boolean, + equalTo(false), + ) + } + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('resize', () => { resolve(true); }, { once: true }); + }); + """.trimIndent(), + ) + + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + assertThat( + "'resize' event on window should be fired when the dynamc toolbar is completely hidden", + promise.value as Boolean, + equalTo(true), + ) + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun windowInnerHeight() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + // We intentionally use FIXED_BOTTOM instead of FIXED_VH in this test since + // FIXED_VH has `minimum-scale=0.5` thus we can't properly test window.innerHeight + // with FXIED_VH for now due to bug 1598487. + mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM) + mainSession.waitForPageStop() + + val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double + + for (i in 1..dynamicToolbarMaxHeight - 1) { + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.visualViewport.addEventListener('resize', resolve(window.innerHeight)); + }); + """.trimIndent(), + ) + + // Simulate the dynamic toolbar is going to be hidden. + sessionRule.display?.run { setVerticalClipping(-i) } + assertThat( + "window.innerHeight should not be changed in response to the dynamc toolbar transition", + promise.value as Double, + closeTo(SCREEN_HEIGHT / 2 / pixelRatio, .01), + ) + } + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('resize', () => { resolve(window.innerHeight); }, { once: true }); + }); + """.trimIndent(), + ) + + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + assertThat( + "window.innerHeight should be changed when the dynamc toolbar is completely hidden", + promise.value as Double, + closeTo(SCREEN_HEIGHT / pixelRatio, .01), + ) + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun notCrashOnResizeEvent() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_VH) + mainSession.waitForPageStop() + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => window.addEventListener('resize', () => resolve(true))); + """.trimIndent(), + ) + + // Do some setVerticalClipping calls that we might try to queue two window resize events. + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight + 1) } + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + assertThat("Got a rezie event", promise.value as Boolean, equalTo(true)) + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun showDynamicToolbar() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(SHOW_DYNAMIC_TOOLBAR_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("window.scrollTo(0, " + dynamicToolbarMaxHeight + ")") + mainSession.waitUntilCalled(object : ScrollDelegate { + @AssertCalled(count = 1) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.synthesizeTap(5, 25) + + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onShowDynamicToolbar(session: GeckoSession) { + } + }) + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun showDynamicToolbarOnOverflowHidden() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(SHOW_DYNAMIC_TOOLBAR_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("window.scrollTo(0, " + dynamicToolbarMaxHeight + ")") + mainSession.waitUntilCalled(object : ScrollDelegate { + @AssertCalled(count = 1) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.evaluateJS("document.documentElement.style.overflow = 'hidden'") + + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onShowDynamicToolbar(session: GeckoSession) { + } + }) + } + + private fun getComputedViewportHeight(style: String): Double { + val viewportHeight = mainSession.evaluateJS( + """ + const target = document.createElement('div'); + target.style.height = '$style'; + document.body.appendChild(target); + parseFloat(getComputedStyle(target).height); + """.trimIndent(), + ) as Double + + return viewportHeight + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun viewportVariants() { + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.VIEWPORT_PATH) + mainSession.waitForPageStop() + + val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double + val scale = mainSession.evaluateJS("window.visualViewport.scale") as Double + + var smallViewportHeight = getComputedViewportHeight("100svh") + assertThat( + "svh value at the initial state", + smallViewportHeight, + closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1), + ) + + var largeViewportHeight = getComputedViewportHeight("100lvh") + assertThat( + "lvh value at the initial state", + largeViewportHeight, + closeTo(SCREEN_HEIGHT / scale / pixelRatio, 0.1), + ) + + var dynamicViewportHeight = getComputedViewportHeight("100dvh") + assertThat( + "dvh value at the initial state", + dynamicViewportHeight, + closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1), + ) + + // Move down the toolbar at a fourth of its position. + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight / 4) } + + smallViewportHeight = getComputedViewportHeight("100svh") + assertThat( + "svh value during toolbar transition", + smallViewportHeight, + closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight) / scale / pixelRatio, 0.1), + ) + + largeViewportHeight = getComputedViewportHeight("100lvh") + assertThat( + "lvh value during toolbar transition", + largeViewportHeight, + closeTo(SCREEN_HEIGHT / scale / pixelRatio, 0.1), + ) + + dynamicViewportHeight = getComputedViewportHeight("100dvh") + assertThat( + "dvh value during toolbar transition", + dynamicViewportHeight, + closeTo((SCREEN_HEIGHT - dynamicToolbarMaxHeight + dynamicToolbarMaxHeight / 4) / scale / pixelRatio, 0.1), + ) + } + + // With dynamic toolbar, there was a floating point rounding error in Gecko layout side. + // The error was appeared by user interactive async scrolling, not by programatic async + // scrolling, e.g. scrollTo() method. If the error happens there will appear 1px gap + // between and an element which covers up the element. + // This test simulates the situation. + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun noGapAppearsBetweenBodyAndElementFullyCoveringBody() { + // Bug 1764219 - disable the test to reduce intermittent failure rate + assumeThat(sessionRule.env.isDebugBuild, equalTo(false)) + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(BaseSessionTest.BODY_FULLY_COVERED_BY_GREEN_ELEMENT) + mainSession.waitForPageStop() + mainSession.flushApzRepaints() + + // Scrolling down by touch events. + var downTime = SystemClock.uptimeMillis() + var down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 50f, + 70f, + 0, + ) + mainSession.panZoomController.onTouchEvent(down) + var move = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + 50f, + 30f, + 0, + ) + mainSession.panZoomController.onTouchEvent(move) + var up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + 50f, + 10f, + 0, + ) + mainSession.panZoomController.onTouchEvent(up) + mainSession.flushApzRepaints() + + // Scrolling up by touch events to restore the original position. + downTime = SystemClock.uptimeMillis() + down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 50f, + 10f, + 0, + ) + mainSession.panZoomController.onTouchEvent(down) + move = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + 50f, + 30f, + 0, + ) + mainSession.panZoomController.onTouchEvent(move) + up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + 50f, + 70f, + 0, + ) + mainSession.panZoomController.onTouchEvent(up) + mainSession.flushApzRepaints() + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun zoomedOverflowHidden() { + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for foreground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM) + mainSession.waitForPageStop() + + // Change the body background color to match the reference image's background color. + mainSession.evaluateJS("document.body.style.background = 'rgb(0, 128, 0)'") + + // Hide the vertical scrollbar. + mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'") + + // Zoom in the content so that the content's visual viewport can be scrollable. + mainSession.setResolutionAndScaleTo(10.0f) + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.flushApzRepaints() + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun zoomedPositionFixedRoot() { + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for foreground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.FIXED_BOTTOM) + mainSession.waitForPageStop() + + // Change the body background color to match the reference image's background color. + mainSession.evaluateJS("document.body.style.background = 'rgb(0, 128, 0)'") + + // Change the root `overlow` style to make it scrollable and change the position style + // to `fixed` so that the root container is not scrollable. + mainSession.evaluateJS("document.body.style.overflow = 'scroll'") + mainSession.evaluateJS("document.documentElement.style.position = 'fixed'") + + // Hide the vertical scrollbar. + mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'") + + // Zoom in the content so that the content's visual viewport can be scrollable. + mainSession.setResolutionAndScaleTo(10.0f) + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.flushApzRepaints() + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun backgroundImageFixed() { + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.TOUCH_ACTION_HTML_PATH) + mainSession.waitForPageStop() + + // Specify the root background-color to match the reference image color and specify + // `background-attachment: fixed`. + mainSession.evaluateJS("document.documentElement.style.background = 'linear-gradient(green, green) fixed'") + + // Make the root element scrollable. + mainSession.evaluateJS("document.documentElement.style.height = '100vh'") + + // Hide the vertical scrollbar. + mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'") + + mainSession.flushApzRepaints() + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.flushApzRepaints() + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun backgroundAttachmentFixed() { + val reference = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + val dynamicToolbarMaxHeight = SCREEN_HEIGHT / 2 + sessionRule.display?.run { setDynamicToolbarMaxHeight(dynamicToolbarMaxHeight) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + mainSession.loadTestPath(BaseSessionTest.TOUCH_ACTION_HTML_PATH) + mainSession.waitForPageStop() + + // Specify the root background-color to match the reference image color and specify + // `background-attachment: fixed`. + mainSession.evaluateJS("document.documentElement.style.background = 'rgb(0, 128, 0) fixed'") + + // Make the root element scrollable. + mainSession.evaluateJS("document.documentElement.style.height = '100vh'") + + // Hide the vertical scrollbar. + mainSession.evaluateJS("document.documentElement.style.scrollbarWidth = 'none'") + + mainSession.flushApzRepaints() + + // Simulate the dynamic toolbar being hidden by the scroll + sessionRule.display?.run { setVerticalClipping(-dynamicToolbarMaxHeight) } + + mainSession.flushApzRepaints() + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), reference) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt new file mode 100644 index 0000000000..833d8091fa --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt @@ -0,0 +1,878 @@ +package org.mozilla.geckoview.test + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.equalTo +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.Image.ImageProcessingException +import org.mozilla.geckoview.WebExtension +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@MediumTest +@RunWith(Parameterized::class) +class ExtensionActionTest : BaseSessionTest() { + private var extension: WebExtension? = null + private var otherExtension: WebExtension? = null + private var default: WebExtension.Action? = null + private var backgroundPort: WebExtension.Port? = null + private var windowPort: WebExtension.Port? = null + + companion object { + @get:Parameterized.Parameters(name = "{0}") + @JvmStatic + val parameters = listOf( + arrayOf("#pageAction"), + arrayOf("#browserAction"), + ) + } + + @field:Parameterized.Parameter(0) + @JvmField + var id: String = "" + + private val controller + get() = sessionRule.runtime.webExtensionController + + @Before + fun setup() { + controller.setTabActive(mainSession, true) + + // This method installs the extension, opens up ports with the background script and the + // content script and captures the default action definition from the manifest + val browserActionDefaultResult = GeckoResult() + val pageActionDefaultResult = GeckoResult() + + val windowPortResult = GeckoResult() + val backgroundPortResult = GeckoResult() + + extension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/actions/"), + ) + // Another dummy extension, only used to check restrictions related to setting + // another extension url as a popup url, and so there is no delegate needed for it. + otherExtension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/dummy/"), + ) + + mainSession.webExtensionController.setMessageDelegate( + extension!!, + object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + windowPortResult.complete(port) + } + }, + "browser", + ) + extension!!.setMessageDelegate( + object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + backgroundPortResult.complete(port) + } + }, + "browser", + ) + + sessionRule.addExternalDelegateDuringNextWait( + WebExtension.ActionDelegate::class, + extension!!::setActionDelegate, + { extension!!.setActionDelegate(null) }, + object : WebExtension.ActionDelegate { + override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + assertEquals(action.title, "Test action default") + browserActionDefaultResult.complete(action) + } + override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + assertEquals(action.title, "Test action default") + pageActionDefaultResult.complete(action) + } + }, + ) + + mainSession.loadUri("http://example.com") + sessionRule.waitForPageStop() + + val pageAction = sessionRule.waitForResult(pageActionDefaultResult) + val browserAction = sessionRule.waitForResult(browserActionDefaultResult) + + default = when (id) { + "#pageAction" -> pageAction + "#browserAction" -> browserAction + else -> throw IllegalArgumentException() + } + + windowPort = sessionRule.waitForResult(windowPortResult) + backgroundPort = sessionRule.waitForResult(backgroundPortResult) + + if (id == "#pageAction") { + // Make sure that the pageAction starts enabled for this tab + testActionApi("""{"action": "enable"}""") { action -> + assertEquals(action.enabled, true) + } + } + } + + private val type: String + get() = when (id) { + "#pageAction" -> "pageAction" + "#browserAction" -> "browserAction" + else -> throw IllegalArgumentException() + } + + @After + fun tearDown() { + if (extension != null) { + extension!!.setMessageDelegate(null, "browser") + extension!!.setActionDelegate(null) + sessionRule.waitForResult(controller.uninstall(extension!!)) + } + + if (otherExtension != null) { + sessionRule.waitForResult(controller.uninstall(otherExtension!!)) + } + } + + private fun testBackgroundActionApi(message: String, tester: (WebExtension.Action) -> Unit) { + val result = GeckoResult() + + val json = JSONObject(message) + json.put("type", type) + + backgroundPort!!.postMessage(json) + + sessionRule.addExternalDelegateDuringNextWait( + WebExtension.ActionDelegate::class, + extension!!::setActionDelegate, + { extension!!.setActionDelegate(null) }, + object : WebExtension.ActionDelegate { + override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + if (sessionRule.currentCall.counter == 1) { + // When attaching the delegate, we will receive a default message, ignore it + return + } + assertEquals(id, "#browserAction") + default = action + tester(action) + result.complete(null) + } + override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + if (sessionRule.currentCall.counter == 1) { + // When attaching the delegate, we will receive a default message, ignore it + return + } + assertEquals(id, "#pageAction") + default = action + tester(action) + result.complete(null) + } + }, + ) + + sessionRule.waitForResult(result) + } + + private fun testSetPopup(popupUrl: String, isUrlAllowed: Boolean) { + val setPopupResult = GeckoResult() + + backgroundPort!!.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + val json = message as JSONObject + if (json.getString("resultFor") == "setPopup" && + json.getString("type") == type + ) { + if (isUrlAllowed != json.getBoolean("success")) { + val expectedResString = when (isUrlAllowed) { + true -> "allowed" + else -> "disallowed" + } + setPopupResult.completeExceptionally( + IllegalArgumentException( + "Expected \"${popupUrl}\" to be ${ expectedResString }", + ), + ) + } else { + setPopupResult.complete(null) + } + } else { + // We should NOT receive the expected message result. + setPopupResult.completeExceptionally( + IllegalArgumentException( + "Received unexpected result for: ${json.getString("type")} ${json.getString("resultFor")}", + ), + ) + } + } + }) + + var json = JSONObject( + """{ + "action": "setPopupCheckRestrictions", + "popup": "$popupUrl" + }""", + ) + + json.put("type", type) + windowPort!!.postMessage(json) + + sessionRule.waitForResult(setPopupResult) + } + + private fun testActionApi(message: String, tester: (WebExtension.Action) -> Unit) { + val result = GeckoResult() + + val json = JSONObject(message) + json.put("type", type) + + windowPort!!.postMessage(json) + + sessionRule.addExternalDelegateDuringNextWait( + WebExtension.ActionDelegate::class, + { delegate -> + mainSession.webExtensionController.setActionDelegate(extension!!, delegate) + }, + { mainSession.webExtensionController.setActionDelegate(extension!!, null) }, + object : WebExtension.ActionDelegate { + override fun onBrowserAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + assertEquals(id, "#browserAction") + val resolved = action.withDefault(default!!) + tester(resolved) + result.complete(null) + } + override fun onPageAction(extension: WebExtension, session: GeckoSession?, action: WebExtension.Action) { + assertEquals(id, "#pageAction") + val resolved = action.withDefault(default!!) + tester(resolved) + result.complete(null) + } + }, + ) + + sessionRule.waitForResult(result) + } + + @Test + fun disableTest() { + testActionApi("""{"action": "disable"}""") { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, false) + } + } + + @Test + fun attachingDelegateTriggersDefaultUpdate() { + val result = GeckoResult() + + // We should always get a default update after we attach the delegate + when (id) { + "#browserAction" -> { + extension!!.setActionDelegate(object : WebExtension.ActionDelegate { + override fun onBrowserAction( + extension: WebExtension, + session: GeckoSession?, + action: WebExtension.Action, + ) { + assertEquals(action.title, "Test action default") + result.complete(null) + } + }) + } + "#pageAction" -> { + extension!!.setActionDelegate(object : WebExtension.ActionDelegate { + override fun onPageAction( + extension: WebExtension, + session: GeckoSession?, + action: WebExtension.Action, + ) { + assertEquals(action.title, "Test action default") + result.complete(null) + } + }) + } + else -> throw IllegalArgumentException() + } + + sessionRule.waitForResult(result) + } + + @Test + fun enableTest() { + // First, make sure the action is disabled + testActionApi("""{"action": "disable"}""") { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, false) + } + + testActionApi("""{"action": "enable"}""") { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + } + } + + @Test + fun setOverridenTitle() { + testActionApi( + """{ + "action": "setTitle", + "title": "overridden title" + }""", + ) { action -> + assertEquals(action.title, "overridden title") + assertEquals(action.enabled, true) + } + } + + @Test + fun setBadgeText() { + assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction")) + + testActionApi( + """{ + "action": "setBadgeText", + "text": "12" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.badgeText, "12") + assertEquals(action.enabled, true) + } + } + + @Test + fun setBadgeBackgroundColor() { + assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction")) + + colorTest("setBadgeBackgroundColor", "#ABCDEF", "#FFABCDEF") + colorTest("setBadgeBackgroundColor", "#F0A", "#FFFF00AA") + colorTest("setBadgeBackgroundColor", "red", "#FFFF0000") + colorTest("setBadgeBackgroundColor", "rgb(0, 0, 255)", "#FF0000FF") + colorTest("setBadgeBackgroundColor", "rgba(0, 255, 0, 0.5)", "#8000FF00") + colorRawTest("setBadgeBackgroundColor", "[0, 0, 255, 128]", "#800000FF") + } + + private fun colorTest(actionName: String, color: String, expectedHex: String) { + colorRawTest(actionName, "\"$color\"", expectedHex) + } + + private fun colorRawTest(actionName: String, color: String, expectedHex: String) { + testActionApi( + """{ + "action": "$actionName", + "color": $color + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.badgeText, "") + assertEquals(action.enabled, true) + + val result = when (actionName) { + "setBadgeTextColor" -> action.badgeTextColor!! + "setBadgeBackgroundColor" -> action.badgeBackgroundColor!! + else -> throw IllegalArgumentException() + } + + val hexColor = String.format("#%08X", result) + assertEquals(hexColor, expectedHex) + } + } + + @Test + fun setBadgeTextColor() { + assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction")) + + colorTest("setBadgeTextColor", "#ABCDEF", "#FFABCDEF") + colorTest("setBadgeTextColor", "#F0A", "#FFFF00AA") + colorTest("setBadgeTextColor", "red", "#FFFF0000") + colorTest("setBadgeTextColor", "rgb(0, 0, 255)", "#FF0000FF") + colorTest("setBadgeTextColor", "rgba(0, 255, 0, 0.5)", "#8000FF00") + colorRawTest("setBadgeTextColor", "[0, 0, 255, 128]", "#800000FF") + } + + @Test + fun setDefaultTitle() { + assumeThat("Only browserAction supports default properties.", id, equalTo("#browserAction")) + + // Setting a default value will trigger the default handler on the extension object + testBackgroundActionApi( + """{ + "action": "setTitle", + "title": "new default title" + }""", + ) { action -> + assertEquals(action.title, "new default title") + assertEquals(action.badgeText, "") + assertEquals(action.enabled, true) + } + + // When an overridden title is set, the default has no effect + testActionApi( + """{ + "action": "setTitle", + "title": "test override" + }""", + ) { action -> + assertEquals(action.title, "test override") + assertEquals(action.badgeText, "") + assertEquals(action.enabled, true) + } + + // When the override is null, the new default takes effect + testActionApi( + """{ + "action": "setTitle", + "title": null + }""", + ) { action -> + assertEquals(action.title, "new default title") + assertEquals(action.badgeText, "") + assertEquals(action.enabled, true) + } + + // When the default value is null, the manifest value is used + testBackgroundActionApi( + """{ + "action": "setTitle", + "title": null + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.badgeText, "") + assertEquals(action.enabled, true) + } + } + + private fun compareBitmap(expectedLocation: String, actual: Bitmap) { + val stream = InstrumentationRegistry.getInstrumentation().targetContext.assets + .open(expectedLocation) + + val expected = BitmapFactory.decodeStream(stream) + for (x in 0 until actual.height) { + for (y in 0 until actual.width) { + assertEquals(expected.getPixel(x, y), actual.getPixel(x, y)) + } + } + } + + @Test + fun setIconSvg() { + val svg = GeckoResult() + + testActionApi( + """{ + "action": "setIcon", + "path": "button/icon.svg" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + + action.icon!!.getBitmap(100).accept { actual -> + compareBitmap("web_extensions/actions/button/expected.png", actual!!) + svg.complete(null) + } + } + + sessionRule.waitForResult(svg) + } + + @Test + fun themeIcons() { + assumeThat("Only browserAction supports this API.", id, equalTo("#browserAction")) + + val png32 = GeckoResult() + + default!!.icon!!.getBitmap(32).accept({ actual -> + compareBitmap("web_extensions/actions/button/beasts-32.png", actual!!) + png32.complete(null) + }, { error -> + png32.completeExceptionally(error!!) + }) + + sessionRule.waitForResult(png32) + } + + @Test + fun setIconPng() { + val png100 = GeckoResult() + val png38 = GeckoResult() + val png19 = GeckoResult() + val png10 = GeckoResult() + + testActionApi( + """{ + "action": "setIcon", + "path": { + "19": "button/geo-19.png", + "38": "button/geo-38.png" + } + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + + action.icon!!.getBitmap(100).accept { actual -> + compareBitmap("web_extensions/actions/button/geo-38.png", actual!!) + png100.complete(null) + } + + action.icon!!.getBitmap(38).accept { actual -> + compareBitmap("web_extensions/actions/button/geo-38.png", actual!!) + png38.complete(null) + } + + action.icon!!.getBitmap(19).accept { actual -> + compareBitmap("web_extensions/actions/button/geo-19.png", actual!!) + png19.complete(null) + } + + action.icon!!.getBitmap(10).accept { actual -> + compareBitmap("web_extensions/actions/button/geo-19.png", actual!!) + png10.complete(null) + } + } + + sessionRule.waitForResult(png100) + sessionRule.waitForResult(png38) + sessionRule.waitForResult(png19) + sessionRule.waitForResult(png10) + } + + @Test + fun setIconError() { + val error = GeckoResult() + + testActionApi( + """{ + "action": "setIcon", + "path": "invalid/path/image.png" + }""", + ) { action -> + action.icon!!.getBitmap(38).accept({ + error.completeExceptionally(RuntimeException("Should not succeed.")) + }, { exception -> + if (!(exception is ImageProcessingException)) { + throw exception!! + } + error.complete(null) + }) + } + + sessionRule.waitForResult(error) + } + + @Test + fun testSetPopupRestrictions() { + testSetPopup("https://example.com", false) + testSetPopup("${otherExtension!!.metaData.baseUrl}other-extension.html", false) + testSetPopup("${extension!!.metaData.baseUrl}same-extension.html", true) + testSetPopup("relative-url-01.html", true) + testSetPopup("/relative-url-02.html", true) + } + + @Test + @GeckoSessionTestRule.WithDisplay(width = 100, height = 100) + fun testOpenPopup() { + // First, let's make sure we have a popup set + val actionResult = GeckoResult() + testActionApi( + """{ + "action": "setPopup", + "popup": "test-popup.html" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + + actionResult.complete(null) + } + sessionRule.waitForResult(actionResult) + + val url = when (id) { + "#browserAction" -> "test-open-popup-browser-action.html" + "#pageAction" -> "test-open-popup-page-action.html" + else -> throw IllegalArgumentException() + } + + var location = extension!!.metaData.baseUrl + mainSession.loadUri("$location$url") + sessionRule.waitForPageStop() + + val openPopup = GeckoResult() + mainSession.webExtensionController.setActionDelegate( + extension!!, + object : WebExtension.ActionDelegate { + override fun onOpenPopup( + extension: WebExtension, + popupAction: WebExtension.Action, + ): GeckoResult? { + assertEquals(extension, this@ExtensionActionTest.extension) + openPopup.complete(null) + return null + } + }, + ) + + // openPopup needs user activation + mainSession.synthesizeTap(50, 50) + + sessionRule.waitForResult(openPopup) + } + + @Test + fun testClickWhenPopupIsNotDefined() { + val pong = GeckoResult() + + backgroundPort!!.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + val json = message as JSONObject + if (json.getString("method") == "pong") { + pong.complete(null) + } else { + // We should NOT receive onClicked here + pong.completeExceptionally( + IllegalArgumentException( + "Received unexpected: ${json.getString("method")}", + ), + ) + } + } + }) + + val actionResult = GeckoResult() + + testActionApi( + """{ + "action": "setPopup", + "popup": "test-popup.html" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + + actionResult.complete(action) + } + + val togglePopup = GeckoResult() + val action = sessionRule.waitForResult(actionResult) + + extension!!.setActionDelegate(object : WebExtension.ActionDelegate { + override fun onTogglePopup( + extension: WebExtension, + popupAction: WebExtension.Action, + ): GeckoResult? { + assertEquals(extension, this@ExtensionActionTest.extension) + assertEquals(popupAction, action) + togglePopup.complete(null) + return null + } + }) + + // This click() will not cause an onClicked callback because popup is set + action.click() + + // but it will cause togglePopup to be called + sessionRule.waitForResult(togglePopup) + + // If the response to ping reaches us before the onClicked we know onClicked wasn't called + backgroundPort!!.postMessage( + JSONObject( + """{ + "type": "ping" + }""", + ), + ) + + sessionRule.waitForResult(pong) + } + + @Test + fun testClickWhenPopupIsDefined() { + val onClicked = GeckoResult() + backgroundPort!!.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + val json = message as JSONObject + assertEquals(json.getString("method"), "onClicked") + assertEquals(json.getString("type"), type) + onClicked.complete(null) + } + }) + + testActionApi( + """{ + "action": "setPopup", + "popup": null + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + + // This click() WILL cause an onClicked callback + action.click() + } + + sessionRule.waitForResult(onClicked) + } + + @Test + fun testPopupMessaging() { + val popupSession = sessionRule.createOpenSession() + + val actionResult = GeckoResult() + testActionApi( + """{ + "action": "setPopup", + "popup": "test-popup-messaging.html" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + actionResult.complete(action) + } + + val messages = mutableListOf() + val messageResult = GeckoResult>() + val portResult = GeckoResult() + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(extension!!.id, sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + sender.environmentType, + ) + assertEquals(sender.isTopLevel, true) + assertEquals( + "${extension!!.metaData.baseUrl}test-popup-messaging.html", + sender.url, + ) + assertEquals(sender.session, popupSession) + messages.add(message as String) + if (messages.size == 2) { + messageResult.complete(messages) + return null + } else { + return GeckoResult.fromValue("TEST_RESPONSE") + } + } + + override fun onConnect(port: WebExtension.Port) { + assertEquals(extension!!.id, port.sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + port.sender.environmentType, + ) + assertEquals(true, port.sender.isTopLevel) + assertEquals( + "${extension!!.metaData.baseUrl}test-popup-messaging.html", + port.sender.url, + ) + assertEquals(port.sender.session, popupSession) + portResult.complete(port) + } + } + + popupSession.webExtensionController.setMessageDelegate( + extension!!, + messageDelegate, + "browser", + ) + + val action = sessionRule.waitForResult(actionResult) + extension!!.setActionDelegate(object : WebExtension.ActionDelegate { + override fun onTogglePopup( + extension: WebExtension, + popupAction: WebExtension.Action, + ): GeckoResult? { + assertEquals(extension, this@ExtensionActionTest.extension) + assertEquals(popupAction, action) + return GeckoResult.fromValue(popupSession) + } + }) + + action.click() + + val message = sessionRule.waitForResult(messageResult) + assertThat( + "Message should match", + message, + equalTo( + listOf( + "testPopupMessage", + "response: TEST_RESPONSE", + ), + ), + ) + + val port = sessionRule.waitForResult(portResult) + val portMessageResult = GeckoResult() + + port.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, p: WebExtension.Port) { + assertEquals(port, p) + portMessageResult.complete(message as String) + } + }) + + val portMessage = sessionRule.waitForResult(portMessageResult) + assertThat( + "Message should match", + portMessage, + equalTo("testPopupPortMessage"), + ) + } + + @Test + fun testPopupsCanCloseThemselves() { + val onCloseRequestResult = GeckoResult() + val popupSession = sessionRule.createOpenSession() + popupSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onCloseRequest(session: GeckoSession) { + onCloseRequestResult.complete(null) + } + }) + + val actionResult = GeckoResult() + testActionApi( + """{ + "action": "setPopup", + "popup": "test-popup.html" + }""", + ) { action -> + assertEquals(action.title, "Test action default") + assertEquals(action.enabled, true) + actionResult.complete(action) + } + + val togglePopup = GeckoResult() + val action = sessionRule.waitForResult(actionResult) + extension!!.setActionDelegate(object : WebExtension.ActionDelegate { + override fun onTogglePopup( + extension: WebExtension, + popupAction: WebExtension.Action, + ): GeckoResult? { + assertEquals(extension, this@ExtensionActionTest.extension) + assertEquals(popupAction, action) + togglePopup.complete(null) + return GeckoResult.fromValue(popupSession) + } + }) + action.click() + sessionRule.waitForResult(togglePopup) + + sessionRule.waitForResult(onCloseRequestResult) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt new file mode 100644 index 0000000000..beff344ef7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt @@ -0,0 +1,456 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession + +@RunWith(AndroidJUnit4::class) +@MediumTest +class FinderTest : BaseSessionTest() { + + @Test fun find() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + // Initial search. + var result = sessionRule.waitForResult(mainSession.finder.find("dolore", 0)) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(1)) + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("dolore"), + ) + assertThat("Flags should be correct", result.flags, equalTo(0)) + + // Search again using new flags. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should have wrapped", result.wrapped, equalTo(true)) + assertThat("Current count should be correct", result.current, equalTo(2)) + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("dolore"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + // And again using same flags. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(1)) + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("dolore"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + // And again but go forward. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(2)) + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("dolore"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + } + + @Test fun find_notFound() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("foo", 0)) + + assertThat("Should not be found", result.found, equalTo(false)) + assertThat("Should have wrapped", result.wrapped, equalTo(true)) + assertThat("Current count should be correct", result.current, equalTo(0)) + assertThat("Total count should be correct", result.total, equalTo(0)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("foo"), + ) + assertThat("Flags should be correct", result.flags, equalTo(0)) + + result = sessionRule.waitForResult(mainSession.finder.find("lore", 0)) + + assertThat("Should be found", result.found, equalTo(true)) + } + + @Test fun find_matchCase() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("lore", 0)) + + assertThat("Total count should be correct", result.total, equalTo(3)) + + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_MATCH_CASE, + ), + ) + + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Flags should be correct", + result.flags, + equalTo(GeckoSession.FINDER_FIND_MATCH_CASE), + ) + } + + @Test fun find_wholeWord() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("dolor", 0)) + + assertThat("Total count should be correct", result.total, equalTo(4)) + + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Total count should be correct", result.total, equalTo(2)) + assertThat( + "Flags should be correct", + result.flags, + equalTo(GeckoSession.FINDER_FIND_WHOLE_WORD), + ) + } + + @Test fun find_linksOnly() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + val result = sessionRule.waitForResult( + mainSession.finder.find( + "nim", + GeckoSession.FINDER_FIND_LINKS_ONLY, + ), + ) + + assertThat("Total count should be correct", result.total, equalTo(1)) + assertThat( + "Flags should be correct", + result.flags, + equalTo(GeckoSession.FINDER_FIND_LINKS_ONLY), + ) + } + + @Test fun clear() { + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + val result = sessionRule.waitForResult(mainSession.finder.find("lore", 0)) + + assertThat("Match should be found", result.found, equalTo(true)) + + assertThat( + "Match should be selected", + mainSession.evaluateJS("window.getSelection().toString()") as String, + equalTo("Lore"), + ) + + mainSession.finder.clear() + + assertThat( + "Match should be cleared", + mainSession.evaluateJS("window.getSelection().isCollapsed") as Boolean, + equalTo(true), + ) + } + + @Test fun find_in_pdf() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + // Initial search. + var result = sessionRule.waitForResult(mainSession.finder.find("trace", 0)) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(1)) + assertThat("Total count should be correct", result.total, equalTo(141)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("trace"), + ) + assertThat("Flags should be correct", result.flags, equalTo(0)) + + // Search again using new flags. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(6)) + assertThat("Total count should be correct", result.total, equalTo(85)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("trace"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + // And again using same flags. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(5)) + assertThat("Total count should be correct", result.total, equalTo(85)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("trace"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_BACKWARDS + or GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + // And again but go forward. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should not have wrapped", result.wrapped, equalTo(false)) + assertThat("Current count should be correct", result.current, equalTo(6)) + assertThat("Total count should be correct", result.total, equalTo(85)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("trace"), + ) + assertThat( + "Flags should be correct", + result.flags, + equalTo( + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + } + + @Test fun find_in_pdf_with_wrapped_result() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + // Initial search. + var result = sessionRule.waitForResult( + mainSession.finder.find( + "SpiderMonkey", + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + for (count in 1..4) { + assertThat("Should be found", result.found, equalTo(true)) + assertThat("Should (not) have wrapped", result.wrapped, equalTo(count == 4)) + assertThat("Current count should be correct", result.current, equalTo(if (count == 4) 1 else count)) + assertThat("Total count should be correct", result.total, equalTo(3)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("SpiderMonkey"), + ) + + // And again. + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_MATCH_CASE + or GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + } + } + + @Test fun find_in_pdf_notFound() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("foo", 0)) + + assertThat("Should not be found", result.found, equalTo(false)) + assertThat("Should have wrapped", result.wrapped, equalTo(true)) + assertThat("Current count should be correct", result.current, equalTo(0)) + assertThat("Total count should be correct", result.total, equalTo(0)) + assertThat( + "Search string should be correct", + result.searchString, + equalTo("foo"), + ) + assertThat("Flags should be correct", result.flags, equalTo(0)) + + result = sessionRule.waitForResult(mainSession.finder.find("Spi", 0)) + + assertThat("Should be found", result.found, equalTo(true)) + } + + @Test fun find_in_pdf_matchCase() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("language", 0)) + + assertThat("Total count should be correct", result.total, equalTo(15)) + + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_MATCH_CASE, + ), + ) + + assertThat("Total count should be correct", result.total, equalTo(13)) + assertThat( + "Flags should be correct", + result.flags, + equalTo(GeckoSession.FINDER_FIND_MATCH_CASE), + ) + } + + @Test fun find_in_pdf_wholeWord() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("speed", 0)) + + assertThat("Total count should be correct", result.total, equalTo(5)) + + result = sessionRule.waitForResult( + mainSession.finder.find( + null, + GeckoSession.FINDER_FIND_WHOLE_WORD, + ), + ) + + assertThat("Total count should be correct", result.total, equalTo(1)) + assertThat( + "Flags should be correct", + result.flags, + equalTo(GeckoSession.FINDER_FIND_WHOLE_WORD), + ) + } + + @Test fun find_in_pdf_and_html() { + for (i in 1..2) { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + var result = sessionRule.waitForResult(mainSession.finder.find("trace", 0)) + + assertThat("Total count should be correct", result.total, equalTo(141)) + + mainSession.loadTestPath(LOREM_IPSUM_HTML_PATH) + mainSession.waitForPageStop() + + result = sessionRule.waitForResult(mainSession.finder.find("dolore", 0)) + + assertThat("Total count should be correct", result.total, equalTo(2)) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt new file mode 100644 index 0000000000..c05820012d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt @@ -0,0 +1,120 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.text.format.DateFormat +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.gecko.GeckoAppShell +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule + +@RunWith(AndroidJUnit4::class) +@MediumTest +class GeckoAppShellTest : BaseSessionTest() { + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private var prior24HourSetting = true + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + activityRule.scenario.onActivity { + prior24HourSetting = DateFormat.is24HourFormat(context) + it.view.setSession(sessionRule.session) + } + } + + @After + fun cleanup() { + activityRule.scenario.onActivity { + // Return the test harness back to original setting + setAndroid24HourTimeFormat(prior24HourSetting) + it.view.releaseSession() + } + } + + // Sets the Android system is24HourFormat preference + private fun setAndroid24HourTimeFormat(timeFormat: Boolean) { + val setting = if (timeFormat) "24" else "12" + Settings.System.putString(context.contentResolver, Settings.System.TIME_12_24, setting) + } + + // Sends app to background, then to foreground, and finally loads a page + private fun goHomeAndReturnWithPageLoad() { + // Ensures a return to the foreground (onResume) + Handler(Looper.getMainLooper()).postDelayed({ + sessionRule.requestActivityToForeground(context) + // Will call onLoadRequest and allow test to finish + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + }, 1500) + + // Will cause onPause event to occur + sessionRule.simulatePressHome(context) + } + + @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class) + @Test + fun testChange24HourClockSettings() { + activityRule.scenario.onActivity { + var onLoadRequestCount = 0 + + // First clock settings change, takes effect on next onResume + // Time format that does not use AM/PM, e.g., 13:00 + setAndroid24HourTimeFormat(true) + // Causes an onPause event, onResume event, and finally a page load request + goHomeAndReturnWithPageLoad() + + // This is waiting and holding the test harness open while Android Lifecycle events complete + mainSession.waitUntilCalled(object : GeckoSession.ContentDelegate, GeckoSession.NavigationDelegate { + @GeckoSessionTestRule.AssertCalled(count = 2) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + // Result of first clock settings change + if (onLoadRequestCount == 0) { + assertThat( + "Should use a 24 hour clock.", + GeckoAppShell.getIs24HourFormat(), + equalTo(true), + ) + onLoadRequestCount++ + + // Calling second clock settings change + // Time format that does use AM/PM, e.g., 1:00 PM + setAndroid24HourTimeFormat(false) + goHomeAndReturnWithPageLoad() + + // Result of second clock settings change + } else { + assertThat( + "Should use a 12 hour clock.", + GeckoAppShell.getIs24HourFormat(), + equalTo(false), + ) + } + } + }) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java new file mode 100644 index 0000000000..8ffd4bcbec --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java @@ -0,0 +1,673 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; + +import android.os.Handler; +import android.os.Looper; +import androidx.test.annotation.UiThreadTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.MediumTest; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.test.util.Environment; +import org.mozilla.geckoview.test.util.UiThreadUtils; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class GeckoResultTest { + private static class MockException extends RuntimeException {} + + private boolean mDone; + + private final Environment mEnv = new Environment(); + + private void waitUntilDone() { + assertThat("We should not be done", mDone, equalTo(false)); + UiThreadUtils.waitForCondition(() -> mDone, mEnv.getDefaultTimeoutMillis()); + } + + private void done() { + UiThreadUtils.HANDLER.post(() -> mDone = true); + } + + @Before + public void setup() { + mDone = false; + } + + @Test + @UiThreadTest + public void thenWithResult() { + GeckoResult.fromValue(42) + .accept( + value -> { + assertThat("Value should match", value, equalTo(42)); + done(); + }); + + waitUntilDone(); + } + + @Test + @UiThreadTest + public void thenWithException() { + final Throwable boom = new Exception("boom"); + GeckoResult.fromException(boom) + .accept( + null, + error -> { + assertThat("Exception should match", error, equalTo(boom)); + done(); + }); + + waitUntilDone(); + } + + @Test(expected = IllegalArgumentException.class) + @UiThreadTest + public void thenNoListeners() { + GeckoResult.fromValue(42).then(null, null); + } + + @Test + @UiThreadTest + public void testCopy() { + final GeckoResult result = new GeckoResult<>(GeckoResult.fromValue(42)); + result.accept( + value -> { + assertThat("Value should match", value, equalTo(42)); + done(); + }); + + waitUntilDone(); + } + + @Test + @UiThreadTest + public void allOfError() throws Throwable { + final GeckoResult> result = + GeckoResult.allOf( + new GeckoResult<>(GeckoResult.fromValue(12)), + new GeckoResult<>(GeckoResult.fromValue(35)), + new GeckoResult<>(GeckoResult.fromException(new RuntimeException("Sorry not sorry"))), + new GeckoResult<>(GeckoResult.fromValue(0))); + + UiThreadUtils.waitForResult( + result.accept( + value -> { + throw new AssertionError("result should fail"); + }, + error -> { + assertThat("Error should match", error instanceof RuntimeException, is(true)); + assertThat("Error should match", error.getMessage(), equalTo("Sorry not sorry")); + }), + mEnv.getDefaultTimeoutMillis()); + } + + @Test + @UiThreadTest + public void allOfEmpty() { + final GeckoResult> result = GeckoResult.allOf(); + + result.accept( + value -> { + assertThat("Value should match", value.isEmpty(), is(true)); + done(); + }); + + waitUntilDone(); + } + + @Test + @UiThreadTest + public void allOfNull() { + final GeckoResult> result = GeckoResult.allOf((List>) null); + + result.accept( + value -> { + assertThat("Value should match", value, equalTo(null)); + done(); + }); + + waitUntilDone(); + } + + @Test + @UiThreadTest + public void allOfMany() { + final GeckoResult pending1 = new GeckoResult<>(); + final GeckoResult pending2 = new GeckoResult<>(); + + final GeckoResult> result = + GeckoResult.allOf( + pending1, + new GeckoResult<>(GeckoResult.fromValue(12)), + pending2, + new GeckoResult<>(GeckoResult.fromValue(35)), + new GeckoResult<>(GeckoResult.fromValue(9)), + new GeckoResult<>(GeckoResult.fromValue(0))); + + result.accept( + value -> { + assertThat("Value should match", value, equalTo(Arrays.asList(123, 12, 321, 35, 9, 0))); + done(); + }); + + try { + Thread.sleep(50); + } catch (final InterruptedException ex) { + } + + // Complete the results out of order so that we can verify the input order is preserved + pending2.complete(321); + pending1.complete(123); + waitUntilDone(); + } + + @Test(expected = IllegalStateException.class) + @UiThreadTest + public void completeMultiple() { + final GeckoResult deferred = new GeckoResult<>(); + deferred.complete(42); + deferred.complete(43); + } + + @Test(expected = IllegalStateException.class) + @UiThreadTest + public void completeMultipleExceptions() { + final GeckoResult deferred = new GeckoResult<>(); + deferred.completeExceptionally(new Exception("boom")); + deferred.completeExceptionally(new Exception("boom again")); + } + + @Test(expected = IllegalStateException.class) + @UiThreadTest + public void completeMixed() { + final GeckoResult deferred = new GeckoResult<>(); + deferred.complete(42); + deferred.completeExceptionally(new Exception("boom again")); + } + + @Test(expected = IllegalArgumentException.class) + @UiThreadTest + public void completeExceptionallyNull() { + new GeckoResult().completeExceptionally(null); + } + + @Test + @UiThreadTest + public void completeThreaded() { + final GeckoResult deferred = new GeckoResult<>(); + final Thread thread = new Thread(() -> deferred.complete(42)); + + deferred.accept( + value -> { + assertThat("Value should match", value, equalTo(42)); + ThreadUtils.assertOnUiThread(); + done(); + }); + + thread.start(); + waitUntilDone(); + } + + @Test + @UiThreadTest + public void dispatchOnInitialThread() throws InterruptedException { + final Thread thread = + new Thread( + () -> { + Looper.prepare(); + final Thread dispatchThread = Thread.currentThread(); + + GeckoResult.fromValue(42) + .accept( + value -> { + assertThat( + "Thread should match", Thread.currentThread(), equalTo(dispatchThread)); + Looper.myLooper().quit(); + }); + + Looper.loop(); + }); + + thread.start(); + thread.join(); + } + + @Test + @UiThreadTest + public void completeExceptionallyThreaded() { + final GeckoResult deferred = new GeckoResult<>(); + final Throwable boom = new Exception("boom"); + final Thread thread = new Thread(() -> deferred.completeExceptionally(boom)); + + deferred.exceptionally( + error -> { + assertThat("Exception should match", error, equalTo(boom)); + ThreadUtils.assertOnUiThread(); + done(); + return null; + }); + + thread.start(); + waitUntilDone(); + } + + @Test + @UiThreadTest + public void testFinallyException() { + final GeckoResult subject = new GeckoResult<>(); + final Throwable boom = new Exception("boom"); + + subject + .map( + value -> { + assertThat("This should not be called", true, equalTo(false)); + return null; + }, + error -> { + assertThat("Error matches", error, equalTo(boom)); + return error; + }) + .finally_(() -> done()); + + subject.completeExceptionally(boom); + waitUntilDone(); + } + + @Test + @UiThreadTest + public void testFinallySuccessful() { + final GeckoResult subject = new GeckoResult<>(); + + subject.accept(value -> assertThat("Value matches", value, equalTo(42))).finally_(() -> done()); + + subject.complete(42); + waitUntilDone(); + } + + @UiThreadTest + @Test + public void resultMapChaining() { + assertThat( + "We're on the UI thread", + Thread.currentThread(), + equalTo(Looper.getMainLooper().getThread())); + + GeckoResult.fromValue(42) + .map( + value -> { + assertThat("Value should match", value, equalTo(42)); + return "hello"; + }) + .map( + value -> { + assertThat("Value should match", value, equalTo("hello")); + return 42.0f; + }) + .map( + value -> { + assertThat("Value should match", value, equalTo(42.0f)); + throw new Exception("boom"); + }) + .map( + null, + error -> { + assertThat("Error message should match", error.getMessage(), equalTo("boom")); + return new MockException(); + }) + .accept( + null, + exception -> { + assertThat( + "Exception should be MockException", exception, instanceOf(MockException.class)); + done(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void resultChaining() { + assertThat( + "We're on the UI thread", + Thread.currentThread(), + equalTo(Looper.getMainLooper().getThread())); + + GeckoResult.fromValue(42) + .then( + value -> { + assertThat("Value should match", value, equalTo(42)); + return GeckoResult.fromValue("hello"); + }) + .then( + value -> { + assertThat("Value should match", value, equalTo("hello")); + return GeckoResult.fromValue(42.0f); + }) + .then( + value -> { + assertThat("Value should match", value, equalTo(42.0f)); + return GeckoResult.fromException(new Exception("boom")); + }) + .exceptionally( + error -> { + assertThat("Error message should match", error.getMessage(), equalTo("boom")); + throw new MockException(); + }) + .accept( + null, + exception -> { + assertThat( + "Exception should be MockException", exception, instanceOf(MockException.class)); + done(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void then_propagatedValue() { + // The first GeckoResult only has an exception listener, so when the value 42 is + // propagated to subsequent GeckoResult instances, the propagated value is coerced to null. + GeckoResult.fromValue(42) + .exceptionally(error -> null) + .accept( + value -> { + assertThat("Propagated value is null", value, nullValue()); + done(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test(expected = GeckoResult.UncaughtException.class) + public void then_uncaughtException() { + GeckoResult.fromValue(42) + .then( + value -> { + throw new MockException(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test(expected = GeckoResult.UncaughtException.class) + public void then_propagatedUncaughtException() { + GeckoResult.fromValue(42) + .then( + value -> { + throw new MockException(); + }) + .accept(value -> {}); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void then_caughtException() { + GeckoResult.fromValue(42) + .then( + value -> { + throw new MockException(); + }) + .accept(value -> {}) + .exceptionally( + exception -> { + assertThat( + "Exception should be expected", exception, instanceOf(MockException.class)); + done(); + return null; + }); + + waitUntilDone(); + } + + @Test(expected = IllegalThreadStateException.class) + public void noLooperThenThrows() { + assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue()); + GeckoResult.fromValue(42).then(value -> null); + } + + @Test + public void noLooperPoll() throws Throwable { + assertThat("We shouldn't have a Looper", Looper.myLooper(), nullValue()); + assertThat("Value should match", GeckoResult.fromValue(42).poll(0), equalTo(42)); + } + + @Test + public void withHandler() { + + final SynchronousQueue queue = new SynchronousQueue<>(); + final Thread thread = + new Thread( + () -> { + Looper.prepare(); + + try { + queue.put(new Handler()); + } catch (final InterruptedException e) { + throw new RuntimeException(e); + } + + Looper.loop(); + }); + + thread.start(); + + final GeckoResult result = GeckoResult.fromValue(42); + assertThat("We shouldn't have a Looper", result.getLooper(), nullValue()); + + try { + result + .withHandler(queue.take()) + .accept( + value -> { + assertThat("Thread should match", Thread.currentThread(), equalTo(thread)); + assertThat("Value should match", value, equalTo(42)); + Looper.myLooper().quit(); + }); + + thread.join(); + } catch (final InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Test + public void pollCompleteWithValue() throws Throwable { + assertThat("Value should match", GeckoResult.fromValue(42).poll(0), equalTo(42)); + } + + @Test(expected = MockException.class) + public void pollCompleteWithError() throws Throwable { + GeckoResult.fromException(new MockException()).poll(0); + } + + @Test(expected = TimeoutException.class) + public void pollTimeout() throws Throwable { + new GeckoResult().poll(1); + } + + @UiThreadTest + @Test(expected = TimeoutException.class) + public void pollTimeoutWithLooper() throws Throwable { + new GeckoResult().poll(1); + } + + @UiThreadTest + @Test(expected = IllegalThreadStateException.class) + public void pollWithLooper() throws Throwable { + new GeckoResult().poll(); + } + + @UiThreadTest + @Test + public void cancelNoDelegate() { + final GeckoResult result = new GeckoResult(); + result + .cancel() + .accept( + value -> { + assertThat("Cancellation should fail", value, equalTo(false)); + done(); + }); + waitUntilDone(); + } + + private GeckoResult createCancellableResult() { + final GeckoResult result = new GeckoResult<>(); + result.setCancellationDelegate( + new GeckoResult.CancellationDelegate() { + @Override + public GeckoResult cancel() { + return GeckoResult.fromValue(true); + } + }); + + return result; + } + + @UiThreadTest + @Test + public void cancelSuccess() { + final GeckoResult result = createCancellableResult(); + + result + .cancel() + .accept( + value -> { + assertThat("Cancel should succeed", value, equalTo(true)); + result.exceptionally( + exception -> { + assertThat( + "Exception should match", + exception, + instanceOf(CancellationException.class)); + done(); + + return null; + }); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void cancelCompleted() { + final GeckoResult result = createCancellableResult(); + result.complete(42); + result + .cancel() + .accept( + value -> { + assertThat("Cancel should fail", value, equalTo(false)); + done(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void cancelParent() { + final GeckoResult result = createCancellableResult(); + final GeckoResult result2 = result.then(value -> GeckoResult.fromValue(42)); + + result + .cancel() + .accept( + value -> { + assertThat("Cancel should succeed", value, equalTo(true)); + result2.exceptionally( + exception -> { + assertThat( + "Exception should match", + exception, + instanceOf(CancellationException.class)); + done(); + + return null; + }); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void cancelChildParentNotComplete() { + final GeckoResult result = + new GeckoResult() + .then(value -> createCancellableResult()) + .then(value -> new GeckoResult()); + + result + .cancel() + .accept( + value -> { + assertThat("Cancel should fail", value, equalTo(false)); + done(); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void cancelChildParentComplete() { + final GeckoResult result = + GeckoResult.fromValue(42) + .then(value -> createCancellableResult()) + .then(value -> new GeckoResult()); + + final Handler handler = new Handler(); + handler.post( + () -> { + result + .cancel() + .accept( + value -> { + assertThat("Cancel should succeed", value, equalTo(true)); + done(); + }); + }); + + waitUntilDone(); + } + + @UiThreadTest + @Test + public void getOrAccept() + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + final Method ai = + GeckoResult.class.getDeclaredMethod("getOrAccept", GeckoResult.Consumer.class); + ai.setAccessible(true); + + final AtomicBoolean ran = new AtomicBoolean(false); + ai.invoke(GeckoResult.fromValue(42), (GeckoResult.Consumer) o -> ran.set(true)); + assertThat("Should've ran", ran.get(), equalTo(true)); + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt new file mode 100644 index 0000000000..41602d9493 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt @@ -0,0 +1,37 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +package org.mozilla.geckoview.test + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.test.util.Environment + +val env = Environment() + +fun GeckoResult.pollDefault(): T? = + this.poll(env.defaultTimeoutMillis) + +class GeckoResultTestKotlin { + class MockException : RuntimeException() + + @Test fun pollIncompleteWithValue() { + val result = GeckoResult() + val thread = Thread { result.complete(42) } + + thread.start() + assertThat("Value should match", result.pollDefault(), equalTo(42)) + } + + @Test(expected = MockException::class) + fun pollIncompleteWithError() { + val result = GeckoResult() + + val thread = Thread { result.completeExceptionally(MockException()) } + thread.start() + + result.pollDefault() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt new file mode 100644 index 0000000000..d7169c0266 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt @@ -0,0 +1,2114 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Handler +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.HistoryDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.GeckoSession.PromptDelegate +import org.mozilla.geckoview.GeckoSession.ScrollDelegate +import org.mozilla.geckoview.GeckoSession.SessionState +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.test.util.UiThreadUtils + +/** + * Test for the GeckoSessionTestRule class, to ensure it properly sets up a session for + * each test, and to ensure it can properly wait for and assert delegate + * callbacks. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class GeckoSessionTestRuleTest : BaseSessionTest(noErrorCollector = true) { + + @Test fun getSession() { + assertThat("Can get session", mainSession, notNullValue()) + assertThat( + "Session is open", + mainSession.isOpen, + equalTo(true), + ) + } + + @ClosedSessionAtStart + @Test + fun getSession_closedSession() { + assertThat("Session is closed", mainSession.isOpen, equalTo(false)) + } + + @Setting.List( + Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true"), + Setting(key = Setting.Key.DISPLAY_MODE, value = "DISPLAY_MODE_MINIMAL_UI"), + Setting(key = Setting.Key.ALLOW_JAVASCRIPT, value = "false"), + ) + @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, value = "true") + @Test + fun settingsApplied() { + assertThat( + "USE_PRIVATE_MODE should be set", + mainSession.settings.usePrivateMode, + equalTo(true), + ) + assertThat( + "DISPLAY_MODE should be set", + mainSession.settings.displayMode, + equalTo(GeckoSessionSettings.DISPLAY_MODE_MINIMAL_UI), + ) + assertThat( + "USE_TRACKING_PROTECTION should be set", + mainSession.settings.useTrackingProtection, + equalTo(true), + ) + assertThat( + "ALLOW_JAVASCRIPT should be set", + mainSession.settings.allowJavascript, + equalTo(false), + ) + } + + @Test(expected = UiThreadUtils.TimeoutException::class) + @TimeoutMillis(2000) + fun noPendingCallbacks() { + // Make sure we don't have unexpected pending callbacks at the start of a test. + sessionRule.waitUntilCalled(object : ProgressDelegate, HistoryDelegate { + // There may be extraneous onSessionStateChange and onHistoryStateChange calls + // after a test, so ignore the first received. + @AssertCalled(count = 2) + override fun onSessionStateChange(session: GeckoSession, state: SessionState) { + } + + @AssertCalled(count = 2) + override fun onHistoryStateChange(session: GeckoSession, historyList: HistoryDelegate.HistoryList) { + } + }) + } + + @NullDelegate.List( + NullDelegate(ContentDelegate::class), + NullDelegate(NavigationDelegate::class), + ) + @NullDelegate(ScrollDelegate::class) + @Test + fun nullDelegate() { + assertThat( + "Content delegate should be null", + mainSession.contentDelegate, + nullValue(), + ) + assertThat( + "Navigation delegate should be null", + mainSession.navigationDelegate, + nullValue(), + ) + assertThat( + "Scroll delegate should be null", + mainSession.scrollDelegate, + nullValue(), + ) + + assertThat( + "Progress delegate should not be null", + mainSession.progressDelegate, + notNullValue(), + ) + } + + @NullDelegate(ProgressDelegate::class) + @ClosedSessionAtStart + @Test + fun nullDelegate_closed() { + assertThat( + "Progress delegate should be null", + mainSession.progressDelegate, + nullValue(), + ) + } + + @Test(expected = AssertionError::class) + @NullDelegate(ProgressDelegate::class) + @ClosedSessionAtStart + fun nullDelegate_requireProgressOnOpen() { + assertThat( + "Progress delegate should be null", + mainSession.progressDelegate, + nullValue(), + ) + + mainSession.open() + } + + @Test fun waitForPageStop() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun waitForPageStops() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test(expected = AssertionError::class) + @NullDelegate(ProgressDelegate::class) + @ClosedSessionAtStart + fun waitForPageStops_throwOnNullDelegate() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.open(sessionRule.runtime) // Avoid waiting for initial load + mainSession.reload() + mainSession.waitForPageStops(2) + } + + @Test fun waitUntilCalled_anyInterfaceMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(ProgressDelegate::class) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + + override fun onSecurityChange( + session: GeckoSession, + securityInfo: ProgressDelegate.SecurityInformation, + ) { + counter++ + } + + override fun onProgressChange(session: GeckoSession, progress: Int) { + counter++ + } + + override fun onSessionStateChange(session: GeckoSession, state: SessionState) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun waitUntilCalled_specificInterfaceMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled( + ProgressDelegate::class, + "onPageStart", + "onPageStop", + ) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun waitUntilCalled_shouldContinue() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate, ShouldContinue { + var pageStart = false + + override fun shouldContinue(): Boolean = pageStart + + override fun onPageStart(session: GeckoSession, url: String) { + pageStart = true + } + + // This is here to verify that we don't wait on all methods of this object + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + + // This is to verify that the above only waits until pageStart, but not pageStop. + // If the above block waits until pageStop, this will time out, indicating a problem. + sessionRule.waitForPageStop() + } + + @Test(expected = AssertionError::class) + fun waitUntilCalled_throwOnNotGeckoSessionInterface() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(CharSequence::class) + } + + fun waitUntilCalled_notThrowOnCallbackInterface() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(ProgressDelegate::class) + } + + @NullDelegate(ScrollDelegate::class) + @Test + fun waitUntilCalled_notThrowOnNonNullDelegateMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.reload() + mainSession.waitUntilCalled(ProgressDelegate::class, "onPageStop") + } + + @Test fun waitUntilCalled_anyObjectMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + var counter = 0 + + sessionRule.waitUntilCalled(object : ProgressDelegate { + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + + override fun onSecurityChange( + session: GeckoSession, + securityInfo: ProgressDelegate.SecurityInformation, + ) { + counter++ + } + + override fun onProgressChange(session: GeckoSession, progress: Int) { + counter++ + } + + override fun onSessionStateChange(session: GeckoSession, state: SessionState) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun waitUntilCalled_specificObjectMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + var counter = 0 + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test(expected = AssertionError::class) + @NullDelegate(ScrollDelegate::class) + fun waitUntilCalled_throwOnNullDelegateObject() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.reload() + mainSession.waitUntilCalled(object : ScrollDelegate { + @AssertCalled + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @NullDelegate(ScrollDelegate::class) + @Test + fun waitUntilCalled_notThrowOnNonNullDelegateObject() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.reload() + mainSession.waitUntilCalled(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun waitUntilCalled_multipleCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + + var counter = 0 + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(4)) + } + + @Test fun waitUntilCalled_currentCall() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + + var counter = 0 + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 2, order = [1, 2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + val info = sessionRule.currentCall + assertThat("Method info should be valid", info, notNullValue()) + assertThat( + "Counter should be correct", + info.counter, + equalTo(forEachCall(1, 2)), + ) + assertThat( + "Order should equal counter", + info.order, + equalTo(info.counter), + ) + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test(expected = IllegalStateException::class) + fun waitUntilCalled_passThroughExceptions() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + throw IllegalStateException() + } + }) + } + + @Test fun waitUntilCalled_zeroCount() { + // Support having @AssertCalled(count = 0) annotations for waitUntilCalled calls. + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate, ScrollDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + + @AssertCalled(count = 0) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @Test fun forCallbacksDuringWait_anyMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnAnyMethodNotCalled() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ScrollDelegate {}) + } + + @Test fun forCallbacksDuringWait_specificMethod() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun forCallbacksDuringWait_specificMethodMultipleTimes() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(4)) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnSpecificMethodNotCalled() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ScrollDelegate { + @AssertCalled + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @Test fun waitUntilCalled_specificCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + + var counter = 0 + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(4)) + } + + @Test fun forCallbacksDuringWait_specificCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(4)) + } + + @Test(expected = AssertionError::class) + fun waitUntilCalled_throwOnWrongCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnWrongCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun waitUntilCalled_specificOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_specificOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test(expected = AssertionError::class) + fun waitUntilCalled_throwOnWrongOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(order = [2]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [1]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnWrongOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(order = [2]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [1]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_multipleOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(order = [1, 3, 1]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [2, 4, 1]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnWrongMultipleOrder() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + sessionRule.waitForPageStops(2) + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(order = [1, 2, 1]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(order = [3, 4, 1]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_notCalled() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ScrollDelegate { + @AssertCalled(false) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @Test(expected = AssertionError::class) + fun waitUntilCalled_throwOnCallingZeroCall() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 0) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + fun waitUntilCalled_assertCalledFalseNoTimeout() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + sessionRule.waitUntilCalled(object : ProgressDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }) + } + + @Test(expected = AssertionError::class) + fun waitUntilCalled_throwOnCallingNoCall() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) {} + + @AssertCalled(false) + override fun onPageStart(session: GeckoSession, url: String) {} + }) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnCallingNoCall() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_zeroCountEqualsNotCalled() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ScrollDelegate { + @AssertCalled(count = 0) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @Test(expected = AssertionError::class) + fun forCallbacksDuringWait_throwOnCallingZeroCount() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 0) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_limitedToLastWait() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + mainSession.reload() + mainSession.reload() + + // Wait for Gecko to finish all loads. + Thread.sleep(100) + + sessionRule.waitForPageStop() // Wait for loadUri. + sessionRule.waitForPageStop() // Wait for first reload. + + var counter = 0 + + // assert should only apply to callbacks within range (loadUri, first reload]. + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun forCallbacksDuringWait_currentCall() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + val info = sessionRule.currentCall + assertThat("Method info should be valid", info, notNullValue()) + assertThat( + "Counter should be correct", + info.counter, + equalTo(1), + ) + assertThat( + "Order should equal counter", + info.order, + equalTo(0), + ) + } + }) + } + + @Test(expected = IllegalStateException::class) + fun forCallbacksDuringWait_passThroughExceptions() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + throw IllegalStateException() + } + }) + } + + @Test(expected = AssertionError::class) + @NullDelegate(ScrollDelegate::class) + fun forCallbacksDuringWait_throwOnAnyNullDelegate() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + mainSession.reload() + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : NavigationDelegate, ScrollDelegate {}) + } + + @Test(expected = AssertionError::class) + @NullDelegate(ScrollDelegate::class) + fun forCallbacksDuringWait_throwOnSpecificNullDelegate() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + mainSession.reload() + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : ScrollDelegate { + @AssertCalled + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @NullDelegate(ScrollDelegate::class) + @Test + fun forCallbacksDuringWait_notThrowOnNonNullDelegate() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + mainSession.reload() + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test(expected = AssertionError::class) + fun getCurrentCall_throwOnNoCurrentCall() { + sessionRule.currentCall + } + + @Test fun delegateUntilTestEnd() { + var counter = 0 + + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun delegateUntilTestEnd_notCalled() { + sessionRule.delegateUntilTestEnd(object : ScrollDelegate { + @AssertCalled(false) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + } + + @Test(expected = AssertionError::class) + fun delegateUntilTestEnd_throwOnNotCalled() { + sessionRule.delegateUntilTestEnd(object : ScrollDelegate { + @AssertCalled(count = 1) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + sessionRule.performTestEndCheck() + } + + @Test(expected = AssertionError::class) + fun delegateUntilTestEnd_throwOnCallingNoCall() { + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test(expected = AssertionError::class) + fun delegateUntilTestEnd_throwOnWrongOrder() { + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1, order = [2]) + override fun onPageStart(session: GeckoSession, url: String) { + } + + @AssertCalled(count = 1, order = [1]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test fun delegateUntilTestEnd_currentCall() { + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + val info = sessionRule.currentCall + assertThat("Method info should be valid", info, notNullValue()) + assertThat( + "Counter should be correct", + info.counter, + equalTo(1), + ) + assertThat( + "Order should equal counter", + info.order, + equalTo(0), + ) + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test fun delegateDuringNextWait() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + var counter = 0 + + sessionRule.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + counter++ + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat("Should have delegated", counter, equalTo(2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + assertThat("Delegate should be cleared", counter, equalTo(2)) + } + + @Test(expected = AssertionError::class) + fun delegateDuringNextWait_throwOnNotCalled() { + sessionRule.delegateDuringNextWait(object : ScrollDelegate { + @AssertCalled(count = 1) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test(expected = AssertionError::class) + fun delegateDuringNextWait_throwOnNotCalledAtTestEnd() { + sessionRule.delegateDuringNextWait(object : ScrollDelegate { + @AssertCalled(count = 1) + override fun onScrollChanged(session: GeckoSession, scrollX: Int, scrollY: Int) { + } + }) + sessionRule.performTestEndCheck() + } + + @Test fun delegateDuringNextWait_hasPrecedence() { + var testCounter = 0 + var waitCounter = 0 + + sessionRule.delegateUntilTestEnd(object : + ProgressDelegate, + NavigationDelegate { + @AssertCalled(count = 1, order = [2]) + override fun onPageStart(session: GeckoSession, url: String) { + testCounter++ + } + + @AssertCalled(count = 1, order = [4]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + testCounter++ + } + + @AssertCalled(count = 2, order = [1, 3]) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + testCounter++ + } + + @AssertCalled(count = 2, order = [1, 3]) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + testCounter++ + } + }) + + sessionRule.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + waitCounter++ + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + waitCounter++ + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat( + "Text delegate should be overridden", + testCounter, + equalTo(2), + ) + assertThat("Wait delegate should be used", waitCounter, equalTo(2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + assertThat("Test delegate should be used", testCounter, equalTo(6)) + assertThat("Wait delegate should be cleared", waitCounter, equalTo(2)) + } + + @Test(expected = IllegalStateException::class) + fun delegateDuringNextWait_passThroughExceptions() { + sessionRule.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + throw IllegalStateException() + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test(expected = AssertionError::class) + @NullDelegate(NavigationDelegate::class) + fun delegateDuringNextWait_throwOnNullDelegate() { + mainSession.delegateDuringNextWait(object : NavigationDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + } + }) + } + + @Test fun wrapSession() { + val session = sessionRule.wrapSession( + GeckoSession(mainSession.settings), + ) + sessionRule.openSession(session) + session.reload() + session.waitForPageStop() + } + + @Test fun createOpenSession() { + val newSession = sessionRule.createOpenSession() + assertThat("Can create session", newSession, notNullValue()) + assertThat("New session is open", newSession.isOpen, equalTo(true)) + assertThat( + "New session has same settings", + newSession.settings, + equalTo(mainSession.settings), + ) + } + + @Test fun createOpenSession_withSettings() { + val settings = GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build() + + val newSession = sessionRule.createOpenSession(settings) + assertThat("New session has same settings", newSession.settings, equalTo(settings)) + } + + @Test fun createOpenSession_canInterleaveOtherCalls() { + // TODO: Bug 1673953 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + mainSession.loadTestPath(HELLO_HTML_PATH) + + val newSession = sessionRule.createOpenSession() + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + + newSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + + mainSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun createClosedSession() { + val newSession = sessionRule.createClosedSession() + assertThat("Can create session", newSession, notNullValue()) + assertThat("New session is open", newSession.isOpen, equalTo(false)) + assertThat( + "New session has same settings", + newSession.settings, + equalTo(mainSession.settings), + ) + } + + @Test fun createClosedSession_withSettings() { + val settings = GeckoSessionSettings.Builder(mainSession.settings).usePrivateMode(true).build() + + val newSession = sessionRule.createClosedSession(settings) + assertThat("New session has same settings", newSession.settings, equalTo(settings)) + } + + @Test(expected = UiThreadUtils.TimeoutException::class) + @TimeoutMillis(2000) + @ClosedSessionAtStart + fun noPendingCallbacks_withSpecificSession() { + sessionRule.createOpenSession() + // Make sure we don't have unexpected pending callbacks after opening a session. + sessionRule.waitUntilCalled(object : HistoryDelegate, ProgressDelegate { + // There may be extraneous onSessionStateChange and onHistoryStateChange calls + // after a test, so ignore the first received. + @AssertCalled(count = 2) + override fun onSessionStateChange(session: GeckoSession, state: SessionState) { + } + + @AssertCalled(count = 2) + override fun onHistoryStateChange(session: GeckoSession, historyList: HistoryDelegate.HistoryList) { + } + }) + } + + @Test fun waitForPageStop_withSpecificSession() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + } + + @Test fun waitForPageStop_withAllSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + @Test(expected = AssertionError::class) + fun waitForPageStop_throwOnNotWrapped() { + GeckoSession(mainSession.settings).waitForPageStop() + } + + @Test fun waitForPageStops_withSpecificSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.reload() + newSession.waitForPageStops(2) + } + + @Test fun waitForPageStops_withAllSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + } + + @Test fun waitForPageStops_acrossSessionCreation() { + // TODO: Bug 1673953 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + mainSession.loadTestPath(HELLO_HTML_PATH) + val session = sessionRule.createOpenSession() + mainSession.reload() + session.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(3) + } + + @Test fun waitUntilCalled_interfaceWithSpecificSession() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitUntilCalled(ProgressDelegate::class, "onPageStop") + } + + @Test fun waitUntilCalled_interfaceWithAllSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(ProgressDelegate::class, "onPageStop") + } + + @Test fun waitUntilCalled_callbackWithSpecificSession() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun waitUntilCalled_callbackWithAllSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }) + } + + @Test fun forCallbacksDuringWait_withSpecificSession() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + var counter = 0 + + newSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + mainSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun forCallbacksDuringWait_withAllSessions() { + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + + var counter = 0 + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 2) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun forCallbacksDuringWait_limitedToLastSessionWait() { + val newSession = sessionRule.createOpenSession() + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + // forCallbacksDuringWait calls strictly apply to the last wait, session-specific or not. + var counter = 0 + + mainSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + newSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @Test fun delegateUntilTestEnd_withSpecificSession() { + val newSession = sessionRule.createOpenSession() + + var counter = 0 + + newSession.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + mainSession.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun delegateUntilTestEnd_withAllSessions() { + var counter = 0 + + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun delegateDuringNextWait_hasPrecedenceWithSpecificSession() { + val newSession = sessionRule.createOpenSession() + var counter = 0 + + newSession.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + newSession.delegateUntilTestEnd(object : ProgressDelegate { + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + newSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + + assertThat("Callback count should be correct", counter, equalTo(1)) + } + + @Test fun delegateDuringNextWait_specificSessionOverridesAll() { + val newSession = sessionRule.createOpenSession() + var counter = 0 + + newSession.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + sessionRule.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + counter++ + } + }) + + newSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + + assertThat("Callback count should be correct", counter, equalTo(2)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun synthesizeTap() { + mainSession.loadTestPath(CLICK_TO_RELOAD_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.synthesizeTap(50, 50) + mainSession.waitForPageStop() + } + + @WithDisplay(width = 100, height = 100) + @Test + fun synthesizeMouseMove() { + mainSession.loadTestPath(MOUSE_TO_RELOAD_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.synthesizeMouseMove(50, 50) + mainSession.waitForPageStop() + } + + @Test fun evaluateExtensionJS() { + assertThat( + "JS string result should be correct", + sessionRule.evaluateExtensionJS("return 'foo';") as String, + equalTo("foo"), + ) + + assertThat( + "JS number result should be correct", + sessionRule.evaluateExtensionJS("return 1+1;") as Double, + equalTo(2.0), + ) + + assertThat( + "JS boolean result should be correct", + sessionRule.evaluateExtensionJS("return !0;") as Boolean, + equalTo(true), + ) + + val expected = JSONObject("{bar:42,baz:true,foo:'bar'}") + val actual = sessionRule.evaluateExtensionJS("return {foo:'bar',bar:42,baz:true};") as JSONObject + for (key in expected.keys()) { + assertThat( + "JS object result should be correct", + actual.get(key), + equalTo(expected.get(key)), + ) + } + + assertThat( + "JS array result should be correct", + sessionRule.evaluateExtensionJS("return [1,2,3];") as JSONArray, + equalTo(JSONArray("[1,2,3]")), + ) + + assertThat( + "Can access extension APIS", + sessionRule.evaluateExtensionJS("return !!browser.runtime;") as Boolean, + equalTo(true), + ) + + assertThat( + "Can access extension APIS", + sessionRule.evaluateExtensionJS( + """ + return true; + // Comments at the end are allowed + """.trimIndent(), + ) as Boolean, + equalTo(true), + ) + + try { + sessionRule.evaluateExtensionJS("test({ what") + assertThat("Should fail", true, equalTo(false)) + } catch (e: RejectedPromiseException) { + assertThat( + "Syntax errors are reported", + e.message, + containsString("SyntaxError"), + ) + } + } + + @Test fun evaluateJS() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + assertThat( + "JS string result should be correct", + mainSession.evaluateJS("'foo'") as String, + equalTo("foo"), + ) + + assertThat( + "JS number result should be correct", + mainSession.evaluateJS("1+1") as Double, + equalTo(2.0), + ) + + assertThat( + "JS boolean result should be correct", + mainSession.evaluateJS("!0") as Boolean, + equalTo(true), + ) + + val expected = JSONObject("{bar:42,baz:true,foo:'bar'}") + val actual = mainSession.evaluateJS("({foo:'bar',bar:42,baz:true})") as JSONObject + for (key in expected.keys()) { + assertThat( + "JS object result should be correct", + actual.get(key), + equalTo(expected.get(key)), + ) + } + + assertThat( + "JS array result should be correct", + mainSession.evaluateJS("[1,2,3]") as JSONArray, + equalTo(JSONArray("[1,2,3]")), + ) + + assertThat( + "JS DOM object result should be correct", + mainSession.evaluateJS("document.body.tagName") as String, + equalTo("BODY"), + ) + } + + @Test fun evaluateJS_windowObject() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + assertThat( + "JS DOM window result should be correct", + (mainSession.evaluateJS("window.location.pathname")) as String, + equalTo(HELLO_HTML_PATH), + ) + } + + @Test fun evaluateJS_multipleSessions() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("this.foo = 42") + assertThat( + "Variable should be set", + mainSession.evaluateJS("this.foo") as Double, + equalTo(42.0), + ) + + val newSession = sessionRule.createOpenSession() + newSession.loadTestPath(HELLO_HTML_PATH) + newSession.waitForPageStop() + + val result = newSession.evaluateJS("this.foo") + assertThat( + "New session should have separate JS context", + result, + nullValue(), + ) + } + + @Test fun evaluateJS_supportPromises() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + assertThat( + "Can get resolved promise", + mainSession.evaluatePromiseJS( + "new Promise(resolve => resolve('foo'))", + ).value as String, + equalTo("foo"), + ) + + val promise = mainSession.evaluatePromiseJS( + "new Promise(r => window.resolve = r)", + ) + + mainSession.evaluateJS("window.resolve('bar')") + + assertThat( + "Can wait for promise to resolve", + promise.value as String, + equalTo("bar"), + ) + } + + @Test(expected = RejectedPromiseException::class) + fun evaluateJS_throwOnRejectedPromise() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluatePromiseJS("Promise.reject('foo')").value + } + + @Test fun evaluateJS_notBlockMainThread() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + // Test that we can still receive delegate callbacks during evaluateJS, + // by calling alert(), which blocks until prompt delegate is called. + assertThat( + "JS blocking result should be correct", + mainSession.evaluateJS("alert(); 'foo'") as String, + equalTo("foo"), + ) + } + + @TimeoutMillis(1000) + @Test(expected = UiThreadUtils.TimeoutException::class) + fun evaluateJS_canTimeout() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.delegateUntilTestEnd(object : PromptDelegate { + override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult { + // Return a GeckoResult that we will never complete, so it hangs. + val res = GeckoResult() + return res + } + }) + mainSession.evaluateJS("new Promise(resolve => window.setTimeout(resolve, 2000))") + } + + @Test(expected = RuntimeException::class) + fun evaluateJS_throwOnJSException() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("throw Error()") + } + + @Test(expected = RuntimeException::class) + fun evaluateJS_throwOnSyntaxError() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("<{[") + } + + @Test(expected = RuntimeException::class) + fun evaluateJS_throwOnChromeAccess() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("ChromeUtils") + } + + @Test fun getPrefs_undefinedPrefReturnsNull() { + assertThat( + "Undefined pref should have null value", + sessionRule.getPrefs("invalid.pref")[0], + equalTo(JSONObject.NULL), + ) + } + + @Test fun setPrefsUntilTestEnd() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "test.pref.bool" to true, + "test.pref.int" to 1, + "test.pref.foo" to "foo", + ), + ) + + var prefs = sessionRule.getPrefs( + "test.pref.bool", + "test.pref.int", + "test.pref.foo", + "test.pref.bar", + ) + + assertThat("Prefs should be set", prefs[0] as Boolean, equalTo(true)) + assertThat("Prefs should be set", prefs[1] as Int, equalTo(1)) + assertThat("Prefs should be set", prefs[2] as String, equalTo("foo")) + assertThat("Prefs should be set", prefs[3], equalTo(JSONObject.NULL)) + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "test.pref.foo" to "bar", + "test.pref.bar" to "baz", + ), + ) + + prefs = sessionRule.getPrefs( + "test.pref.bool", + "test.pref.int", + "test.pref.foo", + "test.pref.bar", + ) + + assertThat("New prefs should be set", prefs[0] as Boolean, equalTo(true)) + assertThat("New prefs should be set", prefs[1] as Int, equalTo(1)) + assertThat("New prefs should be set", prefs[2] as String, equalTo("bar")) + assertThat("New prefs should be set", prefs[3] as String, equalTo("baz")) + } + + @Test fun setPrefsDuringNextWait() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.setPrefsDuringNextWait( + mapOf( + "test.pref.bool" to true, + "test.pref.int" to 1, + "test.pref.foo" to "foo", + ), + ) + + var prefs = sessionRule.getPrefs( + "test.pref.bool", + "test.pref.int", + "test.pref.foo", + ) + + assertThat("Prefs should be set before wait", prefs[0] as Boolean, equalTo(true)) + assertThat("Prefs should be set before wait", prefs[1] as Int, equalTo(1)) + assertThat("Prefs should be set before wait", prefs[2] as String, equalTo("foo")) + + mainSession.reload() + mainSession.waitForPageStop() + + prefs = sessionRule.getPrefs( + "test.pref.bool", + "test.pref.int", + "test.pref.foo", + ) + + assertThat("Prefs should be cleared after wait", prefs[0], equalTo(JSONObject.NULL)) + assertThat("Prefs should be cleared after wait", prefs[1], equalTo(JSONObject.NULL)) + assertThat("Prefs should be cleared after wait", prefs[2], equalTo(JSONObject.NULL)) + } + + @Test fun setPrefsDuringNextWait_hasPrecedence() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "test.pref.int" to 1, + "test.pref.foo" to "foo", + ), + ) + + sessionRule.setPrefsDuringNextWait( + mapOf( + "test.pref.foo" to "bar", + "test.pref.bar" to "baz", + ), + ) + + var prefs = sessionRule.getPrefs( + "test.pref.int", + "test.pref.foo", + "test.pref.bar", + ) + + assertThat("Prefs should be overridden", prefs[0] as Int, equalTo(1)) + assertThat("Prefs should be overridden", prefs[1] as String, equalTo("bar")) + assertThat("Prefs should be overridden", prefs[2] as String, equalTo("baz")) + + mainSession.reload() + mainSession.waitForPageStop() + + prefs = sessionRule.getPrefs( + "test.pref.int", + "test.pref.foo", + "test.pref.bar", + ) + + assertThat("Overriden prefs should be restored", prefs[0] as Int, equalTo(1)) + assertThat("Overriden prefs should be restored", prefs[1] as String, equalTo("foo")) + assertThat("Overriden prefs should be restored", prefs[2], equalTo(JSONObject.NULL)) + } + + @Test fun waitForJS() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat( + "waitForJS should return correct result", + mainSession.waitForJS("alert(), 'foo'") as String, + equalTo("foo"), + ) + + mainSession.forCallbacksDuringWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult? { + return null + } + }) + } + + @Test fun waitForJS_resolvePromise() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + assertThat( + "waitForJS should wait for promises", + mainSession.waitForJS("Promise.resolve('foo')") as String, + equalTo("foo"), + ) + } + + @Test fun waitForJS_delegateDuringWait() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var count = 0 + mainSession.delegateDuringNextWait(object : PromptDelegate { + override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult { + count++ + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.waitForJS("alert()") + mainSession.waitForJS("alert()") + + // The delegate set through delegateDuringNextWait + // should have been cleared after the first wait. + assertThat("Delegate should only run once", count, equalTo(1)) + } + + @Test(expected = RejectedPromiseException::class) + fun waitForJS_whileNavigating() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + // Trigger navigation and try again + mainSession.loadTestPath(HELLO2_HTML_PATH) + mainSession.waitForPageStop() + + // Navigate away and trigger a waitForJS that never completes, this will + // fail because the page navigates away (disconnecting the port) before + // the page can respond. + mainSession.goBack() + mainSession.waitForJS("new Promise(resolve => {})") + } + + private interface TestDelegate { + fun onDelegate(foo: String, bar: String): Int + } + + @Test fun addExternalDelegateUntilTestEnd() { + lateinit var delegate: TestDelegate + + sessionRule.addExternalDelegateUntilTestEnd( + TestDelegate::class, + { newDelegate -> delegate = newDelegate }, + { }, + object : TestDelegate { + @AssertCalled(count = 1) + override fun onDelegate(foo: String, bar: String): Int { + assertThat("First argument should be correct", foo, equalTo("foo")) + assertThat("Second argument should be correct", bar, equalTo("bar")) + return 42 + } + }, + ) + + assertThat("Delegate should be registered", delegate, notNullValue()) + assertThat( + "Delegate return value should be correct", + delegate.onDelegate("foo", "bar"), + equalTo(42), + ) + sessionRule.performTestEndCheck() + } + + @Test(expected = AssertionError::class) + fun addExternalDelegateUntilTestEnd_throwOnNotCalled() { + sessionRule.addExternalDelegateUntilTestEnd( + TestDelegate::class, + { }, + { }, + object : TestDelegate { + @AssertCalled(count = 1) + override fun onDelegate(foo: String, bar: String): Int { + return 42 + } + }, + ) + sessionRule.performTestEndCheck() + } + + @Test fun addExternalDelegateDuringNextWait() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var delegate: Runnable? = null + + sessionRule.addExternalDelegateDuringNextWait( + Runnable::class, + { newDelegate -> delegate = newDelegate }, + { delegate = null }, + Runnable { }, + ) + + assertThat("Delegate should be registered", delegate, notNullValue()) + delegate?.run() + + mainSession.reload() + mainSession.waitForPageStop() + mainSession.forCallbacksDuringWait(Runnable @AssertCalled(count = 1) {}) // ktlint-disable annotation + + assertThat("Delegate should be unregistered after wait", delegate, nullValue()) + } + + @Test fun addExternalDelegateDuringNextWait_hasPrecedence() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + var delegate: TestDelegate? = null + val register = { newDelegate: TestDelegate -> delegate = newDelegate } + val unregister = { _: TestDelegate -> delegate = null } + + sessionRule.addExternalDelegateDuringNextWait( + TestDelegate::class, + register, + unregister, + object : TestDelegate { + @AssertCalled(count = 1) + override fun onDelegate(foo: String, bar: String): Int { + return 24 + } + }, + ) + + sessionRule.addExternalDelegateUntilTestEnd( + TestDelegate::class, + register, + unregister, + object : TestDelegate { + @AssertCalled(count = 1) + override fun onDelegate(foo: String, bar: String): Int { + return 42 + } + }, + ) + + assertThat("Wait delegate should be registered", delegate, notNullValue()) + assertThat( + "Wait delegate return value should be correct", + delegate?.onDelegate("", ""), + equalTo(24), + ) + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat("Test delegate should still be registered", delegate, notNullValue()) + assertThat( + "Test delegate return value should be correct", + delegate?.onDelegate("", ""), + equalTo(42), + ) + sessionRule.performTestEndCheck() + } + + @IgnoreCrash + @Test + fun contentCrashIgnored() { + // TODO: Bug 1673953 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + // TODO: bug 1710940 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + mainSession.loadUri(CONTENT_CRASH_URL) + mainSession.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onCrash(session: GeckoSession) = Unit + }) + } + + @Test(expected = ChildCrashedException::class) + fun contentCrashFails() { + assumeThat(sessionRule.env.shouldShutdownOnCrash(), equalTo(false)) + + mainSession.loadUri(CONTENT_CRASH_URL) + sessionRule.waitForPageStop() + } + + @Test fun waitForResult() { + val handler = Handler(Looper.getMainLooper()) + val result = object : GeckoResult() { + init { + handler.postDelayed({ + complete(42) + }, 100) + } + } + + val value = sessionRule.waitForResult(result) + assertThat("Value should match", value, equalTo(42)) + } + + @Test(expected = IllegalStateException::class) + fun waitForResultExceptionally() { + val handler = Handler(Looper.getMainLooper()) + val result = object : GeckoResult() { + init { + handler.postDelayed({ + completeExceptionally(IllegalStateException("boom")) + }, 100) + } + } + + sessionRule.waitForResult(result) + } + + @Test fun checkCookieBannerRuleForSession() { + // set preferences. We have a cookie rule for example.com + val testRules = "[{\"id\":\"87815b2d-a840-4155-8713-f8a26d1f483a\",\"click\":{\"optOut\":\"#optOutBtn\",\"presence\": \"#cookieBanner\"},\"cookies\":{\"optOut\":[{\"name\":\"foo\", \"value\":\"bar\"}]}, \"domains\":[\"example.org\"]}]" + sessionRule.setPrefsUntilTestEnd( + mapOf( + "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT, + "cookiebanners.listService.testSkipRemoteSettings" to true, + "cookiebanners.listService.testRules" to testRules, + "cookiebanners.service.detectOnly" to false, + ), + ) + var prefs = sessionRule.getPrefs( + "cookiebanners.service.mode", + "cookiebanners.listService.testSkipRemoteSettings", + "cookiebanners.listService.testRules", + "cookiebanners.service.detectOnly", + ) + assertThat("Cookie banner service mode should be correct", prefs[0] as Int, equalTo(1)) + assertThat("Cookie banner remote settings should be skipped", prefs[1] as Boolean, equalTo(true)) + assertThat("Cookie banner rule should be set", prefs[2] as String, equalTo(testRules)) + assertThat("Cookie banner service should not be in detect only mode", prefs[3] as Boolean, equalTo(false)) + + // session 1 - load url for which there is no rule + mainSession.loadUri(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + val response1 = mainSession.hasCookieBannerRuleForBrowsingContextTree() + sessionRule.waitForResult(response1).let { + assertThat("There should be no rule", it, equalTo(false)) + } + + // session 1 - load url for which there is a rule + mainSession.loadUri("http://example.org/") + sessionRule.waitForPageStop() + val response2 = mainSession.hasCookieBannerRuleForBrowsingContextTree() + sessionRule.waitForResult(response2).let { + assertThat("There should be a rule", it, equalTo(true)) + } + + // session 2 load url for which there is no rule + val session2 = sessionRule.createOpenSession() + session2.loadUri(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + val response3 = session2.hasCookieBannerRuleForBrowsingContextTree() + sessionRule.waitForResult(response3).let { + assertThat("There should be no rule", it, equalTo(false)) + } + + // API shoul return the correct result for the page we have loaded in session 1 + val response4 = mainSession.hasCookieBannerRuleForBrowsingContextTree() + sessionRule.waitForResult(response4).let { + assertThat("There should be a rule the second time", it, equalTo(true)) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt new file mode 100644 index 0000000000..82af2c6475 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt @@ -0,0 +1,462 @@ +package org.mozilla.geckoview.test + +import android.content.Context +import android.graphics.Matrix +import android.os.Build +import android.os.Bundle +import android.os.LocaleList +import android.util.Pair +import android.util.SparseArray +import android.view.View +import android.view.ViewStructure +import android.view.autofill.AutofillId +import android.view.autofill.AutofillValue +import androidx.core.view.ViewCompat +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.filters.SdkSuppress +import org.hamcrest.Matchers.equalTo +import org.junit.* // ktlint-disable no-wildcard-imports +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeThat +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoView +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import org.mozilla.geckoview.test.util.UiThreadUtils +import java.io.File + +@RunWith(AndroidJUnit4::class) +@LargeTest +class GeckoViewTest : BaseSessionTest() { + val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + + @get:Rule + override val rules = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + activityRule.scenario.onActivity { + // Attach the default session from the session rule to the GeckoView + it.view.setSession(sessionRule.session) + } + } + + @After + fun cleanup() { + activityRule.scenario.onActivity { + it.view.releaseSession() + } + } + + @Test + fun setSessionOnClosed() { + activityRule.scenario.onActivity { + it.view.session!!.close() + it.view.setSession(GeckoSession()) + } + } + + @Test + fun setSessionOnOpenDoesNotThrow() { + activityRule.scenario.onActivity { + assertThat("Session is open", it.view.session!!.isOpen, equalTo(true)) + val newSession = GeckoSession() + it.view.setSession(newSession) + assertThat( + "The new session should be correctly set.", + it.view.session, + equalTo(newSession), + ) + } + } + + @Test(expected = java.lang.IllegalStateException::class) + fun displayAlreadyAcquired() { + activityRule.scenario.onActivity { + assertThat( + "View should be attached", + ViewCompat.isAttachedToWindow(it.view), + equalTo(true), + ) + it.view.session!!.acquireDisplay() + } + } + + @Test + fun relaseOnDetach() { + activityRule.scenario.onActivity { + // The GeckoDisplay should be released when the View is detached from the window... + it.view.onDetachedFromWindow() + it.view.session!!.releaseDisplay(it.view.session!!.acquireDisplay()) + } + } + + private fun waitUntilContentProcessPriority(high: List, low: List) { + val highPids = high.map { sessionRule.getSessionPid(it) }.toSet() + val lowPids = low.map { sessionRule.getSessionPid(it) }.toSet() + + UiThreadUtils.waitForCondition({ + val shouldBeHighPri = getContentProcessesOomScore(highPids) + val shouldBeLowPri = getContentProcessesOomScore(lowPids) + // Note that higher oom score means less priority + shouldBeHighPri.count { it > 100 } == 0 && + shouldBeLowPri.count { it < 300 } == 0 + }, env.defaultTimeoutMillis) + } + + fun getContentProcessesOomScore(pids: Collection): List { + return pids.map { pid -> + File("/proc/$pid/oom_score").readText(Charsets.UTF_8).trim().toInt() + } + } + + fun setupPriorityTest(): GeckoSession { + // This makes the test a little bit faster + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.ipc.processPriorityManager.backgroundGracePeriodMS" to 0, + "dom.ipc.processPriorityManager.backgroundPerceivableGracePeriodMS" to 0, + ), + ) + + val otherSession = sessionRule.createOpenSession() + // The process manager sets newly created processes to FOREGROUND priority until they + // are de-prioritized, so we need to activate and deactivate the session to trigger + // a setPriority call. + otherSession.setActive(true) + otherSession.setActive(false) + + // Need a dummy page to be able to get the PID from the session + otherSession.loadUri("https://example.com") + otherSession.waitForPageStop() + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + waitUntilContentProcessPriority( + high = listOf(mainSession), + low = listOf(otherSession), + ) + + return otherSession + } + + @Test + @NullDelegate(Autofill.Delegate::class) + fun setTabActiveKeepsTabAtHighPriority() { + // Bug 1768102 - Doesn't seem to work on Fission + assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false)) + activityRule.scenario.onActivity { + val otherSession = setupPriorityTest() + + // A tab with priority hint does not get de-prioritized even when + // the surface is destroyed + mainSession.setPriorityHint(GeckoSession.PRIORITY_HIGH) + + // This will destroy mainSession's surface and create a surface for otherSession + it.view.setSession(otherSession) + + waitUntilContentProcessPriority(high = listOf(mainSession, otherSession), low = listOf()) + + // Destroying otherSession's surface should leave mainSession as the sole high priority + // tab + it.view.releaseSession() + + waitUntilContentProcessPriority(high = listOf(mainSession), low = listOf()) + + // Cleanup + mainSession.setPriorityHint(GeckoSession.PRIORITY_DEFAULT) + } + } + + @Test + @NullDelegate(Autofill.Delegate::class) + fun processPriorityTest() { + // Doesn't seem to work on Fission + assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false)) + activityRule.scenario.onActivity { + val otherSession = setupPriorityTest() + + // After setting otherSession to the view, otherSession should be high priority + // and mainSession should be de-prioritized + it.view.setSession(otherSession) + + waitUntilContentProcessPriority( + high = listOf(otherSession), + low = listOf(mainSession), + ) + + // After releasing otherSession, both sessions should be low priority + it.view.releaseSession() + + waitUntilContentProcessPriority( + high = listOf(), + low = listOf(mainSession, otherSession), + ) + + // Test that re-setting mainSession in the view raises the priority again + it.view.setSession(mainSession) + waitUntilContentProcessPriority( + high = listOf(mainSession), + low = listOf(otherSession), + ) + + // Setting the session to active should also raise priority + otherSession.setActive(true) + waitUntilContentProcessPriority( + high = listOf(mainSession, otherSession), + low = listOf(), + ) + } + } + + @Test + @NullDelegate(Autofill.Delegate::class) + fun setPriorityHint() { + // Bug 1768102 - Doesn't seem to work on Fission + assumeThat(env.isFission || env.isIsolatedProcess, equalTo(false)) + + val otherSession = setupPriorityTest() + + // Setting priorityHint to PRIORITY_HIGH raises priority + otherSession.setPriorityHint(GeckoSession.PRIORITY_HIGH) + + waitUntilContentProcessPriority( + high = listOf(mainSession, otherSession), + low = listOf(), + ) + + // Setting priorityHint to PRIORITY_DEFAULT should lower priority + otherSession.setPriorityHint(GeckoSession.PRIORITY_DEFAULT) + + waitUntilContentProcessPriority( + high = listOf(mainSession), + low = listOf(otherSession), + ) + } + + private fun visit(node: MockViewStructure, callback: (MockViewStructure) -> Unit) { + callback(node) + + for (child in node.children) { + if (child != null) { + visit(child, callback) + } + } + } + + @Test + @NullDelegate(Autofill.Delegate::class) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) + fun autofillWithNoSession() { + mainSession.loadTestPath(FORMS_XORIGIN_HTML_PATH) + mainSession.waitForPageStop() + + val autofills = mapOf( + "#user1" to "username@example.com", + "#user2" to "username@example.com", + "#pass1" to "test-password", + "#pass2" to "test-password", + ) + + // Set up promises to monitor the values changing. + val promises = autofills.map { entry -> + // Repeat each test with both the top document and the iframe document. + mainSession.evaluatePromiseJS( + """ + window.getDataForAllFrames('${entry.key}', '${entry.value}') + """, + ) + } + + activityRule.scenario.onActivity { + val root = MockViewStructure(View.NO_ID) + it.view.onProvideAutofillVirtualStructure(root, 0) + + val data = SparseArray() + visit(root) { node -> + if (node.hints?.indexOf(View.AUTOFILL_HINT_USERNAME) != -1) { + data.set(node.id, AutofillValue.forText("username@example.com")) + } else if (node.hints?.indexOf(View.AUTOFILL_HINT_PASSWORD) != -1) { + data.set(node.id, AutofillValue.forText("test-password")) + } + } + + // Releasing the session will set mSession in GeckoView to null + // this test verifies that we can still autofill correctly even in released state + val session = it.view.releaseSession()!! + it.view.autofill(data) + + // Put back the session and verifies that the autofill went through anyway + it.view.setSession(session) + + // Wait on the promises and check for correct values. + for (values in promises.map { p -> p.value.asJsonArray() }) { + for (i in 0 until values.length()) { + val (key, actual, expected, eventInterface) = values.get(i).asJSList() + + assertThat("Auto-filled value must match ($key)", actual, equalTo(expected)) + assertThat( + "input event should be dispatched with InputEvent interface", + eventInterface, + equalTo("InputEvent"), + ) + } + } + } + } + + @Test + @NullDelegate(Autofill.Delegate::class) + fun activityContextDelegate() { + var delegateCalled = false + activityRule.scenario.onActivity { + class TestActivityDelegate : GeckoView.ActivityContextDelegate { + override fun getActivityContext(): Context { + delegateCalled = true + return it + } + } + // Set view delegate + it.view.activityContextDelegate = TestActivityDelegate() + val context = it.view.activityContextDelegate?.activityContext + assertTrue("The activity context delegate was called.", delegateCalled) + assertTrue("The activity context delegate provided the expected context.", context == it) + } + } + + class MockViewStructure(var id: Int, var parent: MockViewStructure? = null) : ViewStructure() { + private var enabled: Boolean = false + private var inputType = 0 + var children = Array(0, { null }) + var childIndex = 0 + var hints: Array? = null + + override fun setId(p0: Int, p1: String?, p2: String?, p3: String?) { + id = p0 + } + + override fun setEnabled(p0: Boolean) { + enabled = p0 + } + + override fun setChildCount(p0: Int) { + children = Array(p0, { null }) + } + + override fun getChildCount(): Int { + return children.size + } + + override fun newChild(p0: Int): ViewStructure { + val child = MockViewStructure(p0, this) + children[childIndex++] = child + return child + } + + override fun asyncNewChild(p0: Int): ViewStructure { + return newChild(p0) + } + + override fun setInputType(p0: Int) { + inputType = p0 + } + + fun getInputType(): Int { + return inputType + } + + override fun setAutofillHints(p0: Array?) { + hints = p0 + } + + override fun addChildCount(p0: Int): Int { + TODO() + } + + override fun setDimens(p0: Int, p1: Int, p2: Int, p3: Int, p4: Int, p5: Int) {} + override fun setTransformation(p0: Matrix?) {} + override fun setElevation(p0: Float) {} + override fun setAlpha(p0: Float) {} + override fun setVisibility(p0: Int) {} + override fun setClickable(p0: Boolean) {} + override fun setLongClickable(p0: Boolean) {} + override fun setContextClickable(p0: Boolean) {} + override fun setFocusable(p0: Boolean) {} + override fun setFocused(p0: Boolean) {} + override fun setAccessibilityFocused(p0: Boolean) {} + override fun setCheckable(p0: Boolean) {} + override fun setChecked(p0: Boolean) {} + override fun setSelected(p0: Boolean) {} + override fun setActivated(p0: Boolean) {} + override fun setOpaque(p0: Boolean) {} + override fun setClassName(p0: String?) {} + override fun setContentDescription(p0: CharSequence?) {} + override fun setText(p0: CharSequence?) {} + override fun setText(p0: CharSequence?, p1: Int, p2: Int) {} + override fun setTextStyle(p0: Float, p1: Int, p2: Int, p3: Int) {} + override fun setTextLines(p0: IntArray?, p1: IntArray?) {} + override fun setHint(p0: CharSequence?) {} + override fun getText(): CharSequence { + return "" + } + override fun getTextSelectionStart(): Int { + return 0 + } + override fun getTextSelectionEnd(): Int { + return 0 + } + override fun getHint(): CharSequence { + return "" + } + override fun getExtras(): Bundle { + return Bundle() + } + override fun hasExtras(): Boolean { + return false + } + + override fun getAutofillId(): AutofillId? { + return null + } + override fun setAutofillId(p0: AutofillId) {} + override fun setAutofillId(p0: AutofillId, p1: Int) {} + override fun setAutofillType(p0: Int) {} + override fun setAutofillValue(p0: AutofillValue?) {} + override fun setAutofillOptions(p0: Array?) {} + override fun setDataIsSensitive(p0: Boolean) {} + override fun asyncCommit() {} + override fun setWebDomain(p0: String?) {} + override fun setLocaleList(p0: LocaleList?) {} + + override fun newHtmlInfoBuilder(p0: String): HtmlInfo.Builder { + return MockHtmlInfoBuilder() + } + override fun setHtmlInfo(p0: HtmlInfo) { + } + } + + class MockHtmlInfoBuilder : ViewStructure.HtmlInfo.Builder() { + override fun addAttribute(p0: String, p1: String): ViewStructure.HtmlInfo.Builder { + return this + } + + override fun build(): ViewStructure.HtmlInfo { + return MockHtmlInfo() + } + } + + class MockHtmlInfo : ViewStructure.HtmlInfo() { + override fun getTag(): String { + TODO("Not yet implemented") + } + + override fun getAttributes(): MutableList>? { + TODO("Not yet implemented") + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java new file mode 100644 index 0000000000..bc1ffb14b9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import android.app.Activity; +import android.content.ContextWrapper; +import android.os.Bundle; +import org.mozilla.geckoview.GeckoView; + +public class GeckoViewTestActivity extends Activity { + public GeckoView view; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + view = new GeckoView(new ContextWrapper(this)); + setContentView(view); + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt new file mode 100644 index 0000000000..4c51a4d65c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt @@ -0,0 +1,294 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test +import android.content.Context +import android.location.LocationManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.lifecycle.* // ktlint-disable no-wildcard-imports +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.core.IsNot.not +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.MockLocationProvider + +@RunWith(AndroidJUnit4::class) +@LargeTest +class GeolocationTest : BaseSessionTest() { + private val LOGTAG = "GeolocationTest" + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private lateinit var locManager: LocationManager + private lateinit var mockGpsProvider: MockLocationProvider + private lateinit var mockNetworkProvider: MockLocationProvider + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + activityRule.scenario.onActivity { activity -> + activity.view.setSession(mainSession) + // Prevents using the network provider for these tests + sessionRule.setPrefsUntilTestEnd(mapOf("geo.provider.testing" to false)) + locManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager + mockGpsProvider = sessionRule.MockLocationProvider(locManager, LocationManager.GPS_PROVIDER, 0.0, 0.0, true) + mockNetworkProvider = sessionRule.MockLocationProvider(locManager, LocationManager.NETWORK_PROVIDER, 0.0, 0.0, true) + } + } + + @After + fun cleanup() { + try { + activityRule.scenario.onActivity { activity -> + activity.view.releaseSession() + } + mockGpsProvider.removeMockLocationProvider() + mockNetworkProvider.removeMockLocationProvider() + } catch (e: Exception) {} + } + + private fun setEnableLocationPermissions() { + sessionRule.delegateDuringNextWait(object : GeckoSession.PermissionDelegate { + override fun onContentPermissionRequest( + session: GeckoSession, + perm: GeckoSession.PermissionDelegate.ContentPermission, + ): + GeckoResult { + return GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW) + } + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: GeckoSession.PermissionDelegate.Callback, + ) { + callback.grant() + } + }) + } + + private fun getCurrentPositionJS(maximumAge: Number = 0, timeout: Number = 3000, enableHighAccuracy: Boolean = false): JSONObject { + return mainSession.evaluatePromiseJS( + """ + new Promise((resolve, reject) => + window.navigator.geolocation.getCurrentPosition( + position => resolve( + {latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy}), + error => reject(error.code), + {maximumAge: $maximumAge, + timeout: $timeout, + enableHighAccuracy: $enableHighAccuracy }))""", + ).value as JSONObject + } + + private fun getCurrentPositionJSWithWait(): JSONObject { + return mainSession.evaluatePromiseJS( + """ + new Promise((resolve, reject) => + setTimeout(() => { + window.navigator.geolocation.getCurrentPosition( + position => resolve( + {latitude: position.coords.latitude, longitude: position.coords.longitude})), + error => reject(error.code) + }, "750"))""", + ).value as JSONObject + } + + @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class) + // General test that location can be requested from JS and that the mock provider is providing location + @Test + fun jsContentRequestForLocation() { + val mockLat = 1.1111 + val mockLon = 2.2222 + mockGpsProvider.setMockLocation(mockLat, mockLon) + mockGpsProvider.setDoContinuallyPost(true) + mockGpsProvider.postLocation() + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + setEnableLocationPermissions() + + val position = getCurrentPositionJS() + mockGpsProvider.stopPostingLocation() + assertThat("Mocked latitude matches.", position["latitude"] as Number, equalTo(mockLat)) + assertThat("Mocked longitude matches.", position["longitude"] as Number, equalTo(mockLon)) + } + + @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class) + // Testing that more accurate location providers are selected without high accuracy enabled + @Test + fun accurateProviderSelected() { + val highAccuracy = .000001f + val highMockLat = 1.1111 + val highMockLon = 2.2222 + + // Lower accuracy should still be better than device provider ~20m + val lowAccuracy = 10.01f + val lowMockLat = 3.3333 + val lowMockLon = 4.4444 + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + setEnableLocationPermissions() + + // Test when lower accuracy is more recent + mockGpsProvider.setMockLocation(highMockLat, highMockLon, highAccuracy) + mockGpsProvider.setDoContinuallyPost(false) + mockGpsProvider.postLocation() + + // Sleep ensures the mocked locations have different clock times + Thread.sleep(10) + // Set inaccurate second, so that it is the most recent location + mockNetworkProvider.setMockLocation(lowMockLat, lowMockLon, lowAccuracy) + mockNetworkProvider.setDoContinuallyPost(false) + mockNetworkProvider.postLocation() + + val position = getCurrentPositionJS(0, 3000, false) + assertThat("Higher accuracy latitude is expected.", position["latitude"] as Number, equalTo(highMockLat)) + assertThat("Higher accuracy longitude is expected.", position["longitude"] as Number, equalTo(highMockLon)) + + // Test that higher accuracy becomes stale after 6 seconds + mockGpsProvider.postLocation() + Thread.sleep(6001) + mockNetworkProvider.postLocation() + val inaccuratePosition = getCurrentPositionJS(0, 3000, false) + assertThat("Lower accuracy latitude is expected.", inaccuratePosition["latitude"] as Number, equalTo(lowMockLat)) + assertThat("Lower accuracy longitude is expected.", inaccuratePosition["longitude"] as Number, equalTo(lowMockLon)) + } + + @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class) + // Testing that high accuracy requests a fresh location + @Test + fun highAccuracyTest() { + val accuracyMed = 4f + val accuracyHigh = .000001f + val latMedAcc = 1.1111 + val lonMedAcc = 2.2222 + val latHighAcc = 3.3333 + val lonHighAcc = 4.4444 + + // High accuracy usage requires HTTPS + mainSession.loadUri("https://example.com/") + mainSession.waitForPageStop() + setEnableLocationPermissions() + + // Have two location providers posting locations + mockNetworkProvider.setMockLocation(latMedAcc, lonMedAcc, accuracyMed) + mockNetworkProvider.setDoContinuallyPost(true) + mockNetworkProvider.postLocation() + + mockGpsProvider.setMockLocation(latHighAcc, lonHighAcc, accuracyHigh) + mockGpsProvider.setDoContinuallyPost(true) + mockGpsProvider.postLocation() + + val highAccuracyPosition = getCurrentPositionJS(0, 6001, true) + mockGpsProvider.stopPostingLocation() + mockNetworkProvider.stopPostingLocation() + + assertThat("High accuracy latitude is expected.", highAccuracyPosition["latitude"] as Number, equalTo(latHighAcc)) + assertThat("High accuracy longitude is expected.", highAccuracyPosition["longitude"] as Number, equalTo(lonHighAcc)) + } + + @GeckoSessionTestRule.NullDelegate(Autofill.Delegate::class) + // Checks that location services is reenabled after going to background + @Test + fun locationOnBackground() { + val beforePauseLat = 1.1111 + val beforePauseLon = 2.2222 + val afterPauseLat = 3.3333 + val afterPauseLon = 4.4444 + mockGpsProvider.setDoContinuallyPost(true) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + setEnableLocationPermissions() + + var actualResumeCount = 0 + var actualPauseCount = 0 + + // Monitor lifecycle changes + ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + Log.i(LOGTAG, "onResume Event") + actualResumeCount++ + super.onResume(owner) + try { + mainSession.setActive(true) + // onResume is also called when starting too + if (actualResumeCount > 1) { + // Ensures the location has had time to post + Thread.sleep(3001) + val onResumeFromPausePosition = getCurrentPositionJS() + assertThat("Latitude after onPause matches.", onResumeFromPausePosition["latitude"] as Number, equalTo(afterPauseLat)) + assertThat("Longitude after onPause matches.", onResumeFromPausePosition["longitude"] as Number, equalTo(afterPauseLon)) + } + } catch (e: Exception) { + // Intermittent CI test issue where Activity is gone after resume occurs + assertThat("onResume count matches.", actualResumeCount, equalTo(2)) + assertThat("onPause count matches.", actualPauseCount, equalTo(1)) + try { + mockGpsProvider.removeMockLocationProvider() + } catch (e: Exception) { + // Cleanup could have already occurred + } + } + } + override fun onPause(owner: LifecycleOwner) { + Log.i(LOGTAG, "onPause Event") + actualPauseCount++ + super.onPause(owner) + try { + mockGpsProvider.setMockLocation(afterPauseLat, afterPauseLon) + mockGpsProvider.postLocation() + } catch (e: Exception) { + Log.w(LOGTAG, "onPause was called too late.") + // Potential situation where onPause is called too late + } + } + }) + + // Before onPause Event + mockGpsProvider.setMockLocation(beforePauseLat, beforePauseLon) + mockGpsProvider.postLocation() + val beforeOnPausePosition = getCurrentPositionJS() + assertThat("Latitude before onPause matches.", beforeOnPausePosition["latitude"] as Number, equalTo(beforePauseLat)) + assertThat("Longitude before onPause matches.", beforeOnPausePosition["longitude"] as Number, equalTo(beforePauseLon)) + + // Ensures a return to the foreground + Handler(Looper.getMainLooper()).postDelayed({ + sessionRule.requestActivityToForeground(context) + }, 1500) + + // Will cause onPause event to occur + sessionRule.simulatePressHome(context) + + // After/During onPause Event + val whilePausingPosition = getCurrentPositionJSWithWait() + mockGpsProvider.stopPostingLocation() + assertThat("Latitude after/during onPause matches.", whilePausingPosition["latitude"] as Number, equalTo(afterPauseLat)) + assertThat("Longitude after/during onPause matches.", whilePausingPosition["longitude"] as Number, equalTo(afterPauseLon)) + + assertThat("onResume count matches.", actualResumeCount, equalTo(2)) + assertThat("onPause count matches.", actualPauseCount, equalTo(1)) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt new file mode 100644 index 0000000000..ef361a8860 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt @@ -0,0 +1,63 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.BuildConfig +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash +import org.mozilla.geckoview.test.util.UiThreadUtils + +@RunWith(AndroidJUnit4::class) +@MediumTest +class GpuCrashTest : BaseSessionTest() { + val client = TestCrashHandler.Client(InstrumentationRegistry.getInstrumentation().targetContext) + + @Before + fun setup() { + assertTrue(client.connect(sessionRule.env.defaultTimeoutMillis)) + client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_BACKGROUND_CHILD) + } + + @IgnoreCrash + @Test + fun crashGpu() { + // We need the crash reporter for this test + assumeTrue(BuildConfig.MOZ_CRASHREPORTER) + + // We need the GPU process for this test + assumeTrue(sessionRule.usingGpuProcess()) + + // Cause the GPU process to crash. + sessionRule.crashGpuProcess() + + val evalResult = client.getEvalResult(sessionRule.env.defaultTimeoutMillis) + assertTrue(evalResult.mMsg, evalResult.mResult) + } + + @Test(expected = UiThreadUtils.TimeoutException::class) + fun killGpuNoCrashReport() { + // We need the crash reporter for this test + assumeTrue(BuildConfig.MOZ_CRASHREPORTER) + + // We need the GPU process for this test + assumeTrue(sessionRule.usingGpuProcess()) + + // Cleanly kill GPU process + sessionRule.killGpuProcess() + + // Expect this to time out as no crash should be reported + client.getEvalResult(sessionRule.env.defaultTimeoutMillis) + } + + @After + fun teardown() { + client.disconnect() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt new file mode 100644 index 0000000000..a4ec7c3139 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt @@ -0,0 +1,303 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.HistoryDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.util.UiThreadUtils + +@RunWith(AndroidJUnit4::class) +@MediumTest +class HistoryDelegateTest : BaseSessionTest() { + companion object { + // Keep in sync with the styles in `LINKS_HTML_PATH`. + const val UNVISITED_COLOR = "rgb(0, 0, 255)" + const val VISITED_COLOR = "rgb(255, 0, 0)" + } + + @Test fun getVisited() { + val testUri = createTestUrl(LINKS_HTML_PATH) + sessionRule.delegateDuringNextWait(object : GeckoSession.HistoryDelegate { + @AssertCalled(count = 1) + override fun onVisited( + session: GeckoSession, + url: String, + lastVisitedURL: String?, + flags: Int, + ): GeckoResult? { + assertThat("Should pass visited URL", url, equalTo(testUri)) + assertThat("Should not pass last visited URL", lastVisitedURL, nullValue()) + assertThat( + "Should set visit flags", + flags, + equalTo(GeckoSession.HistoryDelegate.VISIT_TOP_LEVEL), + ) + return GeckoResult.fromValue(true) + } + + @AssertCalled(count = 1) + override fun getVisited( + session: GeckoSession, + urls: Array, + ): GeckoResult? { + val expected = arrayOf( + "https://mozilla.org/", + "https://getfirefox.com/", + "https://bugzilla.mozilla.org/", + "https://testpilot.firefox.com/", + "https://accounts.firefox.com/", + ) + assertThat( + "Should pass URLs to check", + urls.sorted(), + equalTo(expected.sorted()), + ) + + val visits = BooleanArray(urls.size, { + when (urls[it]) { + "https://mozilla.org/", "https://testpilot.firefox.com/" -> true + else -> false + } + }) + return GeckoResult.fromValue(visits) + } + }) + + // Since `getVisited` is called asynchronously after the page loads, we + // can't use `waitForPageStop` here. + mainSession.loadUri(testUri) + mainSession.waitUntilCalled( + GeckoSession.HistoryDelegate::class, + "onVisited", + "getVisited", + ) + + // Sometimes link changes are not applied immediately, wait for a little bit + UiThreadUtils.waitForCondition({ + mainSession.getLinkColor("#mozilla") == VISITED_COLOR + }, sessionRule.env.defaultTimeoutMillis) + + assertThat( + "Mozilla should be visited", + mainSession.getLinkColor("#mozilla"), + equalTo(VISITED_COLOR), + ) + + assertThat( + "Test Pilot should be visited", + mainSession.getLinkColor("#testpilot"), + equalTo(VISITED_COLOR), + ) + + assertThat( + "Bugzilla should be unvisited", + mainSession.getLinkColor("#bugzilla"), + equalTo(UNVISITED_COLOR), + ) + } + + @Ignore // disable test on debug for frequent failures Bug 1544169 + @Test + fun onHistoryStateChange() { + mainSession.loadTestPath(HELLO_HTML_PATH) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have one entry", + state.size, + equalTo(1), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO_HTML_PATH), + ) + assertThat( + "History index should be 0", + state.currentIndex, + equalTo(0), + ) + } + }) + + mainSession.loadTestPath(HELLO2_HTML_PATH) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO2_HTML_PATH), + ) + assertThat( + "History index should be 1", + state.currentIndex, + equalTo(1), + ) + } + }) + + mainSession.goBack() + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO_HTML_PATH), + ) + assertThat( + "History index should be 0", + state.currentIndex, + equalTo(0), + ) + } + }) + + mainSession.goForward() + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO2_HTML_PATH), + ) + assertThat( + "History index should be 1", + state.currentIndex, + equalTo(1), + ) + } + }) + + mainSession.gotoHistoryIndex(0) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO_HTML_PATH), + ) + assertThat( + "History index should be 1", + state.currentIndex, + equalTo(0), + ) + } + }) + + mainSession.gotoHistoryIndex(1) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO2_HTML_PATH), + ) + assertThat( + "History index should be 1", + state.currentIndex, + equalTo(1), + ) + } + }) + } + + @Test fun onHistoryStateChangeSavingState() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + // This is a smaller version of the above test, in the hopes to minimize race conditions + mainSession.loadTestPath(HELLO_HTML_PATH) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have one entry", + state.size, + equalTo(1), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO_HTML_PATH), + ) + assertThat( + "History index should be 0", + state.currentIndex, + equalTo(0), + ) + } + }) + + mainSession.loadTestPath(HELLO2_HTML_PATH) + + sessionRule.waitUntilCalled(object : HistoryDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: GeckoSession.HistoryDelegate.HistoryList) { + assertThat( + "History should have two entries", + state.size, + equalTo(2), + ) + assertThat( + "URLs should match", + state[state.currentIndex].uri, + endsWith(HELLO2_HTML_PATH), + ) + assertThat( + "History index should be 1", + state.currentIndex, + equalTo(1), + ) + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt new file mode 100644 index 0000000000..6d535b8ad1 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt @@ -0,0 +1,306 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.gecko.util.ImageResource +import org.mozilla.geckoview.GeckoResult + +class TestImage( + val path: String, + val type: String?, + val sizes: String?, + val widths: Array?, + val heights: Array?, +) + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ImageResourceTest : BaseSessionTest() { + companion object { + val kValidTestImage1 = TestImage( + "path.ico", + "image/icon", + "16x16 32x32 64x64", + arrayOf(16, 32, 64), + arrayOf(16, 32, 64), + ) + + val kValidTestImage2 = TestImage( + "path.png", + "image/png", + "128x128", + arrayOf(128), + arrayOf(128), + ) + + val kValidTestImage3 = TestImage( + "path.jpg", + "image/jpg", + "256x256", + arrayOf(256), + arrayOf(256), + ) + + val kValidTestImage4 = TestImage( + "path.png", + "image/png", + "300x128", + arrayOf(300), + arrayOf(128), + ) + + val kValidTestImage5 = TestImage( + "path.svg", + "image/svg", + "any", + arrayOf(0), + arrayOf(0), + ) + + val kValidTestImage6 = TestImage( + "path.svg", + null, + null, + null, + null, + ) + } + + fun verifyEqual(image: ImageResource, base: TestImage) { + assertThat( + "Path should match", + image.src, + equalTo(base.path), + ) + assertThat( + "Type should match", + image.type, + equalTo(base.type), + ) + + assertThat( + "Sizes should match", + image.sizes?.size, + equalTo(base.widths?.size), + ) + + assertThat( + "Sizes should match", + image.sizes?.size, + equalTo(base.heights?.size), + ) + + if (image.sizes == null) { + return + } + for (i in 0 until image.sizes!!.size) { + assertThat( + "Sizes widths should match", + image.sizes!![i].width, + equalTo(base.widths!![i]), + ) + assertThat( + "Sizes heights should match", + image.sizes!![i].height, + equalTo(base.heights!![i]), + ) + } + } + + fun testValidImage(base: TestImage) { + var image = ImageResource(base.path, base.type, base.sizes) + verifyEqual(image, base) + } + + fun buildCollection(bases: Array): ImageResource.Collection { + val builder = ImageResource.Collection.Builder() + + bases.forEach { + builder.add(ImageResource(it.path, it.type, it.sizes)) + } + + return builder.build() + } + + @Test + fun validImage() { + testValidImage(kValidTestImage1) + testValidImage(kValidTestImage2) + testValidImage(kValidTestImage3) + testValidImage(kValidTestImage4) + testValidImage(kValidTestImage5) + testValidImage(kValidTestImage6) + } + + @Test + fun invalidImageSize() { + val invalidImage1 = TestImage( + "path.ico", + "image/icon", + "16x16 32", + arrayOf(16), + arrayOf(16), + ) + testValidImage(invalidImage1) + + val invalidImage2 = TestImage( + "path.ico", + "image/icon", + "16x16 32xa32", + arrayOf(16), + arrayOf(16), + ) + testValidImage(invalidImage2) + + val invalidImage3 = TestImage( + "path.ico", + "image/icon", + "", + null, + null, + ) + testValidImage(invalidImage3) + + val invalidImage4 = TestImage( + "path.ico", + "image/icon", + "abxab", + null, + null, + ) + testValidImage(invalidImage4) + } + + @Test + fun getBestRegular() { + val collection = buildCollection( + arrayOf( + kValidTestImage1, + kValidTestImage2, + kValidTestImage3, + kValidTestImage4, + ), + ) + // 16, 32, 64 + verifyEqual(collection.getBest(10)!!, kValidTestImage1) + verifyEqual(collection.getBest(16)!!, kValidTestImage1) + verifyEqual(collection.getBest(30)!!, kValidTestImage1) + verifyEqual(collection.getBest(90)!!, kValidTestImage1) + + // 128 + verifyEqual(collection.getBest(100)!!, kValidTestImage2) + verifyEqual(collection.getBest(120)!!, kValidTestImage2) + verifyEqual(collection.getBest(140)!!, kValidTestImage2) + + // 256 + verifyEqual(collection.getBest(210)!!, kValidTestImage3) + verifyEqual(collection.getBest(256)!!, kValidTestImage3) + verifyEqual(collection.getBest(270)!!, kValidTestImage3) + + // 300 + verifyEqual(collection.getBest(280)!!, kValidTestImage4) + verifyEqual(collection.getBest(10000)!!, kValidTestImage4) + } + + @Test + fun getBestAny() { + val collection = buildCollection( + arrayOf( + kValidTestImage1, + kValidTestImage2, + kValidTestImage3, + kValidTestImage4, + kValidTestImage5, + ), + ) + // any + verifyEqual(collection.getBest(10)!!, kValidTestImage5) + verifyEqual(collection.getBest(16)!!, kValidTestImage5) + verifyEqual(collection.getBest(30)!!, kValidTestImage5) + verifyEqual(collection.getBest(90)!!, kValidTestImage5) + verifyEqual(collection.getBest(100)!!, kValidTestImage5) + verifyEqual(collection.getBest(120)!!, kValidTestImage5) + verifyEqual(collection.getBest(140)!!, kValidTestImage5) + verifyEqual(collection.getBest(210)!!, kValidTestImage5) + verifyEqual(collection.getBest(256)!!, kValidTestImage5) + verifyEqual(collection.getBest(270)!!, kValidTestImage5) + verifyEqual(collection.getBest(280)!!, kValidTestImage5) + verifyEqual(collection.getBest(10000)!!, kValidTestImage5) + } + + @Test + fun getBestNull() { + // Don't include `any` since two `any` cases would result in undefined + // results. + val collection = buildCollection( + arrayOf( + kValidTestImage1, + kValidTestImage2, + kValidTestImage3, + kValidTestImage4, + kValidTestImage6, + ), + ) + // null, handled as any + verifyEqual(collection.getBest(10)!!, kValidTestImage6) + verifyEqual(collection.getBest(16)!!, kValidTestImage6) + verifyEqual(collection.getBest(30)!!, kValidTestImage6) + verifyEqual(collection.getBest(90)!!, kValidTestImage6) + verifyEqual(collection.getBest(100)!!, kValidTestImage6) + verifyEqual(collection.getBest(120)!!, kValidTestImage6) + verifyEqual(collection.getBest(140)!!, kValidTestImage6) + verifyEqual(collection.getBest(210)!!, kValidTestImage6) + verifyEqual(collection.getBest(256)!!, kValidTestImage6) + verifyEqual(collection.getBest(270)!!, kValidTestImage6) + verifyEqual(collection.getBest(280)!!, kValidTestImage6) + verifyEqual(collection.getBest(10000)!!, kValidTestImage6) + } + + @Test + fun getBitmap() { + val actualWidth = 265 + val actualHeight = 199 + + val testImage = TestImage( + createTestUrl("/assets/www/images/test.gif"), + "image/gif", + "any", + arrayOf(0), + arrayOf(0), + ) + val collection = buildCollection(arrayOf(testImage)) + val image = collection.getBest(actualWidth) + + verifyEqual(image!!, testImage) + + sessionRule.waitForResult( + image.getBitmap(actualWidth) + .then { bitmap -> + assertThat( + "Bitmap should be non-null", + bitmap, + notNullValue(), + ) + assertThat( + "Bitmap width should match", + bitmap!!.getWidth(), + equalTo(actualWidth), + ) + assertThat( + "Bitmap height should match", + bitmap.getHeight(), + equalTo(actualHeight), + ) + + GeckoResult.fromValue(null) + }, + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt new file mode 100644 index 0000000000..cfe0bcaf12 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt @@ -0,0 +1,417 @@ +package org.mozilla.geckoview.test + +import android.os.SystemClock +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.PanZoomController +import org.mozilla.geckoview.PanZoomController.InputResultDetail +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@RunWith(AndroidJUnit4::class) +@MediumTest +class InputResultDetailTest : BaseSessionTest() { + private val scrollWaitTimeout = 10000.0 // 10 seconds + + private fun setupDocument(documentPath: String) { + mainSession.loadTestPath(documentPath) + sessionRule.waitUntilCalled(object : ContentDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + mainSession.flushApzRepaints() + } + + private fun sendDownEvent(x: Float, y: Float): GeckoResult { + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + x, + y, + 0, + ) + + val result = mainSession.panZoomController.onTouchEventForDetailResult(down) + + val up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + x, + y, + 0, + ) + + mainSession.panZoomController.onTouchEvent(up) + + return result + } + + private fun assertResultDetail( + testName: String, + actual: InputResultDetail, + expectedHandledResult: Int, + expectedScrollableDirections: Int, + expectedOverscrollDirections: Int, + ) { + assertThat( + testName + ": The handled result", + actual.handledResult(), + equalTo(expectedHandledResult), + ) + assertThat( + testName + ": The scrollable directions", + actual.scrollableDirections(), + equalTo(expectedScrollableDirections), + ) + assertThat( + testName + ": The overscroll directions", + actual.overscrollDirections(), + equalTo(expectedOverscrollDirections), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testTouchAction() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + for (subframe in arrayOf(true, false)) { + for (scrollable in arrayOf(true, false)) { + for (event in arrayOf(true, false)) { + for (touchAction in arrayOf("auto", "none", "pan-x", "pan-y")) { + var url = TOUCH_ACTION_HTML_PATH + "?" + if (subframe) { + url += "subframe&" + } + if (scrollable) { + url += "scrollable&" + } + if (event) { + url += "event&" + } + url += ("touch-action=" + touchAction) + + setupDocument(url) + + // Since sendDownEvent() just sends a touch-down, APZ doesn't + // yet know the direction, hence it allows scrolling in both + // the pan-x and pan-y cases. + var expectedPlace = if (touchAction == "none" || (subframe && scrollable)) { + PanZoomController.INPUT_RESULT_HANDLED_CONTENT + } else if (scrollable) { + PanZoomController.INPUT_RESULT_HANDLED + } else { + PanZoomController.INPUT_RESULT_UNHANDLED + } + + var expectedScrollableDirections = if (scrollable) { + PanZoomController.SCROLLABLE_FLAG_BOTTOM + } else { + PanZoomController.SCROLLABLE_FLAG_NONE + } + + // FIXME: There are a couple of bugs here: + // 1. In the case where touch-action allows the scrolling, the + // overscroll directions shouldn't depend on the presence of + // an event handler, but they do. + // 2. In the case where touch-action doesn't allow the scrolling, + // the overscroll directions should probably be NONE. + var expectedOverscrollDirections = if (touchAction != "none" && !scrollable && event) { + PanZoomController.OVERSCROLL_FLAG_NONE + } else { + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL) + } + + var value = sessionRule.waitForResult(sendDownEvent(50f, 20f)) + assertResultDetail( + "`subframe=$subframe, scrollable=$scrollable, event=$event, touch-action=$touchAction`", + value, + expectedPlace, + expectedScrollableDirections, + expectedOverscrollDirections, + ) + } + } + } + } + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testScrollableWithDynamicToolbar() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + // Set active since setVerticalClipping call affects only for forground tab. + mainSession.setActive(true) + + setupDocument(ROOT_100VH_HTML_PATH + "?event") + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + ROOT_100VH_HTML_PATH, + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + + // Prepare a resize event listener. + val resizePromise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.visualViewport.addEventListener('resize', () => { + resolve(true); + }, { once: true }); + }); + """.trimIndent(), + ) + + // Hide the dynamic toolbar. + sessionRule.display?.run { setVerticalClipping(-20) } + + // Wait a visualViewport resize event to make sure the toolbar change has been reflected. + assertThat("resize", resizePromise.value as Boolean, equalTo(true)) + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertResultDetail( + ROOT_100VH_HTML_PATH, + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_TOP, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testOverscrollBehaviorAuto() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + setupDocument(OVERSCROLL_BEHAVIOR_AUTO_HTML_PATH) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "`overscroll-behavior: auto`", + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testOverscrollBehaviorAutoNone() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + setupDocument(OVERSCROLL_BEHAVIOR_AUTO_NONE_HTML_PATH) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "`overscroll-behavior: auto, none`", + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + PanZoomController.OVERSCROLL_FLAG_HORIZONTAL, + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testOverscrollBehaviorNoneAuto() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + setupDocument(OVERSCROLL_BEHAVIOR_NONE_AUTO_HTML_PATH) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "`overscroll-behavior: none, auto`", + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + PanZoomController.OVERSCROLL_FLAG_VERTICAL, + ) + } + + // NOTE: This function requires #scroll element in the target document. + private fun scrollToBottom() { + // Prepare a scroll event listener. + val scrollPromise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const scroll = document.getElementById('scroll'); + scroll.addEventListener('scroll', () => { + resolve(true); + }, { once: true }); + }); + """.trimIndent(), + ) + + // Scroll to the bottom edge of the scroll container. + mainSession.evaluateJS( + """ + const scroll = document.getElementById('scroll'); + scroll.scrollTo(0, scroll.scrollHeight); + """.trimIndent(), + ) + assertThat("scroll", scrollPromise.value as Boolean, equalTo(true)) + mainSession.flushApzRepaints() + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testScrollHandoff() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + setupDocument(SCROLL_HANDOFF_HTML_PATH) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + // There is a child scroll container and its overscroll-behavior is `contain auto` + assertResultDetail( + "handoff", + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + PanZoomController.OVERSCROLL_FLAG_VERTICAL, + ) + + // Scroll to the bottom edge + scrollToBottom() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + // Now the touch event should be handed to the root scroller. + assertResultDetail( + "handoff", + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testOverscrollBehaviorNoneOnNonRoot() { + var files = arrayOf( + OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH, + ) + + for (file in files) { + setupDocument(file) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "`overscroll-behavior: none` on non root scroll container", + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + PanZoomController.OVERSCROLL_FLAG_NONE, + ) + + // Scroll to the bottom edge so that the container is no longer scrollable downwards. + scrollToBottom() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + // The touch event should be handled in the scroll container content. + assertResultDetail( + "`overscroll-behavior: none` on non root scroll container", + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + PanZoomController.SCROLLABLE_FLAG_TOP, + PanZoomController.OVERSCROLL_FLAG_NONE, + ) + } + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testOverscrollBehaviorNoneOnNonRootWithDynamicToolbar() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + var files = arrayOf( + OVERSCROLL_BEHAVIOR_NONE_NON_ROOT_HTML_PATH, + ) + + for (file in files) { + setupDocument(file) + + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "`overscroll-behavior: none` on non root scroll container", + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + PanZoomController.OVERSCROLL_FLAG_NONE, + ) + + // Scroll to the bottom edge so that the container is no longer scrollable downwards. + scrollToBottom() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + // Now the touch event should be handed to the root scroller even if + // the scroll container's `overscroll-behavior` is none to move + // the dynamic toolbar. + assertResultDetail( + "`overscroll-behavior: none, none`", + value, + PanZoomController.INPUT_RESULT_HANDLED, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + } + } + + // Tests a situation where converting a scrollport size between CSS units and app units will + // result different values, and the difference causes an issue that unscrollable documents + // behave as if it's scrollable. + // + // Note about metrics that this test uses. + // A basic here is that documents having no meta viewport tags are laid out on 980px width + // canvas, the 980px is defined as "browser.viewport.desktopWidth". + // + // So, if the device screen size is (1080px, 2160px) then the document is scaled to + // (1080 / 980) = 1.10204. Then if the dynamic toolbar height is 59px, the scaled document + // height is (2160 - 59) / 1.10204 = 1906.46 (in CSS units). It's converted and actually rounded + // to 114388 (= 1906.46 * 60) in app units. And it's converted back to 1906.47 (114388 / 60) in + // CSS units unfortunately. + @WithDisplay(width = 1080, height = 2160) + @Test + fun testFractionalScrollPortSize() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "browser.viewport.desktopWidth" to 980, + ), + ) + sessionRule.display?.run { setDynamicToolbarMaxHeight(59) } + + setupDocument(NO_META_VIEWPORT_HTML_PATH) + + // Try to scroll down to see if the document is scrollable or not. + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + + assertResultDetail( + "The document isn't not scrollable at all", + value, + PanZoomController.INPUT_RESULT_UNHANDLED, + PanZoomController.SCROLLABLE_FLAG_NONE, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt new file mode 100644 index 0000000000..69deac1c89 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt @@ -0,0 +1,43 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith + +@MediumTest +@RunWith(AndroidJUnit4::class) +class LocaleTest : BaseSessionTest() { + + @Test fun setLocale() { + sessionRule.runtime.settings.setLocales(arrayOf("en-GB")) + assertThat( + "Requested locale is found", + sessionRule.requestedLocales.indexOf("en-GB"), + greaterThanOrEqualTo(0), + ) + } + + @Test fun duplicateLocales() { + sessionRule.runtime.settings.setLocales(arrayOf("en-gb", "en-US", "en-gb", "en-fr", "en-us", "en-FR")) + assertThat( + "Locales have no duplicates", + sessionRule.requestedLocales, + equalTo(listOf("en-GB", "en-US", "en-FR")), + ) + } + + @Test fun lowerCaseToUpperCaseLocales() { + sessionRule.runtime.settings.setLocales(arrayOf("en-gb", "en-us", "en-fr")) + assertThat( + "Locales are formatted properly", + sessionRule.requestedLocales, + equalTo(listOf("en-GB", "en-US", "en-FR")), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt new file mode 100644 index 0000000000..19488835e3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt @@ -0,0 +1,177 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.MediaDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule + +@RunWith(AndroidJUnit4::class) +@MediumTest +@Suppress("DEPRECATION") +class MediaDelegateTest : BaseSessionTest() { + + private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) { + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array?, + audio: Array?, + callback: GeckoSession.PermissionDelegate.MediaCallback, + ) { + if (!(allowAudio || allowCamera)) { + callback.reject() + return + } + var audioDevice: GeckoSession.PermissionDelegate.MediaSource? = null + var videoDevice: GeckoSession.PermissionDelegate.MediaSource? = null + if (allowAudio) { + audioDevice = audio!![0] + } + if (allowCamera) { + videoDevice = video!![0] + } + + if (videoDevice != null || audioDevice != null) { + callback.grant(videoDevice, audioDevice) + } + } + + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: GeckoSession.PermissionDelegate.Callback, + ) { + callback.grant() + } + }) + + mainSession.delegateDuringNextWait(object : MediaDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onRecordingStatusChanged( + session: GeckoSession, + devices: Array, + ) { + var audioActive = false + var cameraActive = false + for (device in devices) { + if (device.type == org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Type.MICROPHONE) { + audioActive = device.status != org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Status.INACTIVE + } + if (device.type == org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Type.CAMERA) { + cameraActive = device.status != org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice.Status.INACTIVE + } + } + + assertThat( + "Camera is ${if (allowCamera) { "active" } else { "inactive" }}", + cameraActive, + Matchers.equalTo(allowCamera), + ) + + assertThat( + "Audio is ${if (allowAudio) { "active" } else { "inactive" }}", + audioActive, + Matchers.equalTo(allowAudio), + ) + } + }) + + var code: String? + if (allowAudio && allowCamera) { + code = """this.stream = window.navigator.mediaDevices.getUserMedia({ + video: { width: 320, height: 240, frameRate: 10 }, + audio: true + });""" + } else if (allowAudio) { + code = """this.stream = window.navigator.mediaDevices.getUserMedia({ + audio: true, + });""" + } else if (allowCamera) { + code = """this.stream = window.navigator.mediaDevices.getUserMedia({ + video: { width: 320, height: 240, frameRate: 10 }, + });""" + } else { + return + } + + // Stop the stream and check active flag and id + val isActive = mainSession.waitForJS( + """$code + this.stream.then(stream => { + if (!stream.active || stream.id == '') { + return false; + } + + return true; + }) + """.trimMargin(), + ) as Boolean + + assertThat( + "Stream should be active and id should not be empty.", + isActive, + Matchers.equalTo(true), + ) + } + + @Test fun testDeviceRecordingEventAudio() { + // disable test on debug Bug 1555656 + assumeThat(sessionRule.env.isDebugBuild, Matchers.equalTo(false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.waitForJS( + "window.navigator.mediaDevices.enumerateDevices()", + ).asJSList() + val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" } + if (audioDevice != null) { + requestRecordingPermission(allowAudio = true, allowCamera = false) + } + } + + @Test fun testDeviceRecordingEventVideo() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, Matchers.equalTo(false)) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.waitForJS( + "window.navigator.mediaDevices.enumerateDevices()", + ).asJSList() + + val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" } + if (videoDevice != null) { + requestRecordingPermission(allowAudio = false, allowCamera = true) + } + } + + @Test fun testDeviceRecordingEventAudioAndVideo() { + // disabled test on debug builds Bug 1554189 + assumeThat(sessionRule.env.isDebugBuild, Matchers.equalTo(false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.waitForJS( + "window.navigator.mediaDevices.enumerateDevices()", + ).asJSList() + val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" } + val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" } + if (audioDevice != null && videoDevice != null) { + requestRecordingPermission(allowAudio = true, allowCamera = true) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt new file mode 100644 index 0000000000..2caa71fc71 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt @@ -0,0 +1,197 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.MediaDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule + +@RunWith(AndroidJUnit4::class) +@MediumTest +@Suppress("DEPRECATION") +class MediaDelegateXOriginTest : BaseSessionTest() { + + private fun requestRecordingPermission(allowAudio: Boolean, allowCamera: Boolean) { + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array?, + audio: Array?, + callback: GeckoSession.PermissionDelegate.MediaCallback, + ) { + if (!(allowAudio || allowCamera)) { + callback.reject() + return + } + var audioDevice: GeckoSession.PermissionDelegate.MediaSource? = null + var videoDevice: GeckoSession.PermissionDelegate.MediaSource? = null + if (allowAudio) { + audioDevice = audio!![0] + } + if (allowCamera) { + videoDevice = video!![0] + } + + if (videoDevice != null || audioDevice != null) { + callback.grant(videoDevice, audioDevice) + } + } + + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: GeckoSession.PermissionDelegate.Callback, + ) { + callback.grant() + } + }) + + mainSession.delegateDuringNextWait(object : MediaDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onRecordingStatusChanged( + session: GeckoSession, + devices: Array, + ) { + var audioActive = false + var cameraActive = false + for (device in devices) { + if (device.type == MediaDelegate.RecordingDevice.Type.MICROPHONE) { + audioActive = device.status != MediaDelegate.RecordingDevice.Status.INACTIVE + } + if (device.type == MediaDelegate.RecordingDevice.Type.CAMERA) { + cameraActive = device.status != MediaDelegate.RecordingDevice.Status.INACTIVE + } + } + + assertThat( + "Camera is ${if (allowCamera) { "active" } else { "inactive" }}", + cameraActive, + Matchers.equalTo(allowCamera), + ) + + assertThat( + "Audio is ${if (allowAudio) { "active" } else { "inactive" }}", + audioActive, + Matchers.equalTo(allowAudio), + ) + } + }) + + var constraints: String? + if (allowAudio && allowCamera) { + constraints = """{ + video: { width: 320, height: 240, frameRate: 10 }, + audio: true + }""" + } else if (allowAudio) { + constraints = "{ audio: true }" + } else if (allowCamera) { + constraints = "{video: { width: 320, height: 240, frameRate: 10 }}" + } else { + return + } + + val started = mainSession.waitForJS("Start($constraints)") as String + assertThat("getUserMedia should have succeeded", started, Matchers.equalTo("ok")) + + val stopped = mainSession.waitForJS("Stop()") as Boolean + assertThat("stream should have been stopped", stopped, Matchers.equalTo(true)) + } + + private fun requestRecordingPermissionNoAllow(allowAudio: Boolean, allowCamera: Boolean) { + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @GeckoSessionTestRule.AssertCalled(count = 0) + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array?, + audio: Array?, + callback: GeckoSession.PermissionDelegate.MediaCallback, + ) { + callback.reject() + } + + @GeckoSessionTestRule.AssertCalled(count = 0) + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: GeckoSession.PermissionDelegate.Callback, + ) { + callback.reject() + } + }) + + mainSession.delegateDuringNextWait(object : MediaDelegate { + @GeckoSessionTestRule.AssertCalled(count = 0) + override fun onRecordingStatusChanged( + session: GeckoSession, + devices: Array, + ) {} + }) + + var constraints: String? + if (allowAudio && allowCamera) { + constraints = """{ + video: { width: 320, height: 240, frameRate: 10 }, + audio: true + }""" + } else if (allowAudio) { + constraints = "{ audio: true }" + } else if (allowCamera) { + constraints = "{video: { width: 320, height: 240, frameRate: 10 }}" + } else { + return + } + + val started = mainSession.waitForJS("StartNoAllow($constraints)") as String + assertThat("getUserMedia should not be allowed", started, Matchers.startsWith("NotAllowedError")) + + val stopped = mainSession.waitForJS("Stop()") as Boolean + assertThat("stream stop should fail", stopped, Matchers.equalTo(false)) + } + + @Test fun testDeviceRecordingEventAudioAndVideoInXOriginIframe() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, Matchers.equalTo(false)) + + mainSession.loadTestPath(GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.waitForJS( + "window.navigator.mediaDevices.enumerateDevices()", + ).asJSList() + val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" } + val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" } + requestRecordingPermission( + allowAudio = audioDevice != null, + allowCamera = videoDevice != null, + ) + } + + @Test fun testDeviceRecordingEventAudioAndVideoInXOriginIframeNoAllow() { + mainSession.loadTestPath(GETUSERMEDIA_XORIGIN_CONTAINER_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.waitForJS( + "window.navigator.mediaDevices.enumerateDevices()", + ).asJSList() + val audioDevice = devices.find { map -> map.getString("kind") == "audioinput" } + val videoDevice = devices.find { map -> map.getString("kind") == "videoinput" } + requestRecordingPermissionNoAllow( + allowAudio = audioDevice != null, + allowCamera = videoDevice != null, + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt new file mode 100644 index 0000000000..ac0e69663c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt @@ -0,0 +1,1031 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.After +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.MediaSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +class Metadata( + title: String?, + artist: String?, + album: String?, +) : + MediaSession.Metadata(title, artist, album, null) + +@RunWith(AndroidJUnit4::class) +@MediumTest +class MediaSessionTest : BaseSessionTest() { + companion object { + // See MEDIA_SESSION_DOM1_PATH file for details. + const val DOM_TEST_TITLE1 = "hoot" + const val DOM_TEST_TITLE2 = "hoot2" + const val DOM_TEST_TITLE3 = "hoot3" + const val DOM_TEST_ARTIST1 = "owl" + const val DOM_TEST_ARTIST2 = "stillowl" + const val DOM_TEST_ARTIST3 = "immaowl" + const val DOM_TEST_ALBUM1 = "hoots" + const val DOM_TEST_ALBUM2 = "dahoots" + const val DOM_TEST_ALBUM3 = "mahoots" + const val DEFAULT_TEST_TITLE1 = "MediaSessionDefaultTest1" + const val TEST_DURATION1 = 3.34 + const val WEBM_TEST_DURATION = 5.59 + const val WEBM_TEST_WIDTH = 560L + const val WEBM_TEST_HEIGHT = 320L + + val DOM_META = arrayOf( + Metadata( + DOM_TEST_TITLE1, + DOM_TEST_ARTIST1, + DOM_TEST_ALBUM1, + ), + Metadata( + DOM_TEST_TITLE2, + DOM_TEST_ARTIST2, + DOM_TEST_ALBUM2, + ), + Metadata( + DOM_TEST_TITLE3, + DOM_TEST_ARTIST3, + DOM_TEST_ALBUM3, + ), + ) + } + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "media.mediacontrol.stopcontrol.aftermediaends" to false, + "dom.media.mediasession.enabled" to true, + ), + ) + } + + @After + fun teardown() { + } + + @Test + fun domMetadataPlayback() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + val onActivatedCalled = arrayOf(GeckoResult()) + val onMetadataCalled = arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ) + val onPlayCalled = arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ) + val onPauseCalled = arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ) + + // Test: + // 1. Load DOM Media Session page which contains 3 audio tracks. + // 2. Track 1 is played on page load. + // a. Ensure onActivated is called. + // b. Ensure onMetadata (1) is called. + // c. Ensure onPlay (1) is called. + val completedStep2 = GeckoResult.allOf( + onActivatedCalled[0], + onMetadataCalled[0], + onPlayCalled[0], + ) + + // 3. Pause playback of track 1. + // a. Ensure onPause (1) is called. + val completedStep3 = GeckoResult.allOf( + onPauseCalled[0], + ) + + // 4. Resume playback (1). + // a. Ensure onMetadata (1) is called. + // b. Ensure onPlay (1) is called. + val completedStep4 = GeckoResult.allOf( + onPlayCalled[1], + onMetadataCalled[1], + ) + + // 5. Wait for track 1 end. + // a. Ensure onPause (1) is called. + val completedStep5 = GeckoResult.allOf( + onPauseCalled[1], + ) + + // 6. Play next track (2). + // a. Ensure onMetadata (2) is called. + // b. Ensure onPlay (2) is called. + val completedStep6 = GeckoResult.allOf( + onMetadataCalled[2], + onPlayCalled[2], + ) + + // 7. Play next track (3). + // a. Ensure onPause (2) is called. + // b. Ensure onMetadata (3) is called. + // c. Ensure onPlay (3) is called. + val completedStep7 = GeckoResult.allOf( + onPauseCalled[2], + onMetadataCalled[3], + onPlayCalled[3], + ) + + // 8. Play previous track (2). + // a. Ensure onPause (3) is called. + // b. Ensure onMetadata (2) is called. + // c. Ensure onPlay (2) is called. + val completedStep8a = GeckoResult.allOf( + onPauseCalled[3], + ) + // Without the split, this seems to race and we don't get the pause event. + val completedStep8b = GeckoResult.allOf( + onMetadataCalled[4], + onPlayCalled[4], + ) + + // 9. Wait for track 2 end. + // a. Ensure onPause (2) is called. + val completedStep9 = GeckoResult.allOf( + onPauseCalled[4], + ) + + val path = MEDIA_SESSION_DOM1_PATH + val session1 = sessionRule.createOpenSession() + + var mediaSession1: MediaSession? = null + // 1. + session1.loadTestPath(path) + + session1.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1, order = [1]) + override fun onActivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onActivatedCalled[0].complete(null) + mediaSession1 = mediaSession + } + + @AssertCalled(false) + override fun onDeactivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + } + + @AssertCalled + override fun onFeatures( + session: GeckoSession, + mediaSession: MediaSession, + features: Long, + ) { + val play = (features and MediaSession.Feature.PLAY) != 0L + val pause = (features and MediaSession.Feature.PAUSE) != 0L + val stop = (features and MediaSession.Feature.PAUSE) != 0L + val next = (features and MediaSession.Feature.PAUSE) != 0L + val prev = (features and MediaSession.Feature.PAUSE) != 0L + + assertThat( + "Playback constrols should be supported", + play && pause && stop && next && prev, + equalTo(true), + ) + } + + @AssertCalled(count = 5, order = [2]) + override fun onMetadata( + session: GeckoSession, + mediaSession: MediaSession, + meta: MediaSession.Metadata, + ) { + assertThat( + "Title should match", + meta.title, + equalTo( + forEachCall( + DOM_META[0].title, + DOM_META[0].title, + DOM_META[1].title, + DOM_META[2].title, + DOM_META[1].title, + ), + ), + ) + assertThat( + "Artist should match", + meta.artist, + equalTo( + forEachCall( + DOM_META[0].artist, + DOM_META[0].artist, + DOM_META[1].artist, + DOM_META[2].artist, + DOM_META[1].artist, + ), + ), + ) + assertThat( + "Album should match", + meta.album, + equalTo( + forEachCall( + DOM_META[0].album, + DOM_META[0].album, + DOM_META[1].album, + DOM_META[2].album, + DOM_META[1].album, + ), + ), + ) + assertThat( + "Artwork image should be non-null", + meta.artwork!!.getBitmap(200), + notNullValue(), + ) + + onMetadataCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + + @AssertCalled + override fun onPositionState( + session: GeckoSession, + mediaSession: MediaSession, + state: MediaSession.PositionState, + ) { + assertThat( + "Duration should match", + state.duration, + closeTo(TEST_DURATION1, 0.01), + ) + + assertThat( + "Playback rate should match", + state.playbackRate, + closeTo(1.0, 0.01), + ) + + assertThat( + "Position should be >= 0", + state.position, + greaterThanOrEqualTo(0.0), + ) + } + + @AssertCalled(count = 5, order = [2]) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + + @AssertCalled(count = 5) + override fun onPause( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPauseCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + }) + + sessionRule.waitForResult(completedStep2) + mediaSession1!!.pause() + + sessionRule.waitForResult(completedStep3) + mediaSession1!!.play() + + sessionRule.waitForResult(completedStep4) + sessionRule.waitForResult(completedStep5) + mediaSession1!!.pause() + mediaSession1!!.nextTrack() + mediaSession1!!.play() + + sessionRule.waitForResult(completedStep6) + mediaSession1!!.pause() + mediaSession1!!.nextTrack() + mediaSession1!!.play() + + sessionRule.waitForResult(completedStep7) + mediaSession1!!.pause() + + sessionRule.waitForResult(completedStep8a) + mediaSession1!!.previousTrack() + mediaSession1!!.play() + + sessionRule.waitForResult(completedStep8b) + sessionRule.waitForResult(completedStep9) + } + + @Test + fun defaultMetadataPlayback() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + val onActivatedCalled = arrayOf(GeckoResult()) + val onPlayCalled = arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ) + val onPauseCalled = arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ) + + // Test: + // 1. Load Media Session page which contains 1 audio track. + // 2. Track 1 is played on page load. + // a. Ensure onActivated is called. + // b. Ensure onPlay (1) is called. + val completedStep2 = GeckoResult.allOf( + onActivatedCalled[0], + onPlayCalled[0], + ) + + // 3. Pause playback of track 1. + // a. Ensure onPause (1) is called. + val completedStep3 = GeckoResult.allOf( + onPauseCalled[0], + ) + + // 4. Resume playback (1). + // b. Ensure onPlay (1) is called. + val completedStep4 = GeckoResult.allOf( + onPlayCalled[1], + ) + + // 5. Wait for track 1 end. + // a. Ensure onPause (1) is called. + val completedStep5 = GeckoResult.allOf( + onPauseCalled[1], + ) + + val path = MEDIA_SESSION_DEFAULT1_PATH + val session1 = sessionRule.createOpenSession() + + var mediaSession1: MediaSession? = null + // 1. + session1.loadTestPath(path) + + session1.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1, order = [1]) + override fun onActivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onActivatedCalled[0].complete(null) + mediaSession1 = mediaSession + } + + @AssertCalled(count = 2, order = [2]) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + + @AssertCalled(count = 2) + override fun onPause( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPauseCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + }) + + sessionRule.waitForResult(completedStep2) + mediaSession1!!.pause() + + sessionRule.waitForResult(completedStep3) + mediaSession1!!.play() + + sessionRule.waitForResult(completedStep4) + sessionRule.waitForResult(completedStep5) + } + + @Test + fun domMultiSessions() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + val onActivatedCalled = arrayOf( + arrayOf(GeckoResult()), + arrayOf(GeckoResult()), + ) + val onMetadataCalled = arrayOf( + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + ) + val onPlayCalled = arrayOf( + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + ) + val onPauseCalled = arrayOf( + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + arrayOf( + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + GeckoResult(), + ), + ) + + // Test: + // 1. Session1: Load DOM Media Session page with 3 audio tracks. + // 2. Session1: Track 1 is played on page load. + // a. Session1: Ensure onActivated is called. + // b. Session1: Ensure onMetadata (1) is called. + // c. Session1: Ensure onPlay (1) is called. + // d. Session1: Verify isActive. + val completedStep2 = GeckoResult.allOf( + onActivatedCalled[0][0], + onMetadataCalled[0][0], + onPlayCalled[0][0], + ) + + // 3. Session1: Pause playback of track 1. + // a. Session1: Ensure onPause (1) is called. + val completedStep3 = GeckoResult.allOf( + onPauseCalled[0][0], + ) + + // 4. Session2: Load DOM Media Session page with 3 audio tracks. + // 5. Session2: Track 1 is played on page load. + // a. Session2: Ensure onActivated is called. + // b. Session2: Ensure onMetadata (1) is called. + // c. Session2: Ensure onPlay (1) is called. + // d. Session2: Verify isActive. + val completedStep5 = GeckoResult.allOf( + onActivatedCalled[1][0], + onMetadataCalled[1][0], + onPlayCalled[1][0], + ) + + // 6. Session2: Pause playback of track 1. + // a. Session2: Ensure onPause (1) is called. + val completedStep6 = GeckoResult.allOf( + onPauseCalled[1][0], + ) + + // 7. Session1: Play next track (2). + // a. Session1: Ensure onMetadata (2) is called. + // b. Session1: Ensure onPlay (2) is called. + val completedStep7 = GeckoResult.allOf( + onMetadataCalled[0][1], + onPlayCalled[0][1], + ) + + // 8. Session1: wait for track 1 end. + // a. Ensure onPause (1) is called. + val completedStep8 = GeckoResult.allOf( + onPauseCalled[0][1], + ) + + val path = MEDIA_SESSION_DOM1_PATH + val session1 = sessionRule.createOpenSession() + val session2 = sessionRule.createOpenSession() + var mediaSession1: MediaSession? = null + var mediaSession2: MediaSession? = null + + session1.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1) + override fun onActivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onActivatedCalled[0][sessionRule.currentCall.counter - 1] + .complete(null) + mediaSession1 = mediaSession + + assertThat( + "Should be active", + mediaSession1?.isActive, + equalTo(true), + ) + } + + @AssertCalled + override fun onPositionState( + session: GeckoSession, + mediaSession: MediaSession, + state: MediaSession.PositionState, + ) { + assertThat( + "Duration should match", + state.duration, + closeTo(TEST_DURATION1, 0.01), + ) + + assertThat( + "Playback rate should match", + state.playbackRate, + closeTo(1.0, 0.01), + ) + + assertThat( + "Position should be >= 0", + state.position, + greaterThanOrEqualTo(0.0), + ) + } + + @AssertCalled + override fun onFeatures( + session: GeckoSession, + mediaSession: MediaSession, + features: Long, + ) { + val play = (features and MediaSession.Feature.PLAY) != 0L + val pause = (features and MediaSession.Feature.PAUSE) != 0L + val stop = (features and MediaSession.Feature.PAUSE) != 0L + val next = (features and MediaSession.Feature.PAUSE) != 0L + val prev = (features and MediaSession.Feature.PAUSE) != 0L + + assertThat( + "Playback constrols should be supported", + play && pause && stop && next && prev, + equalTo(true), + ) + } + + @AssertCalled + override fun onMetadata( + session: GeckoSession, + mediaSession: MediaSession, + meta: MediaSession.Metadata, + ) { + val count = sessionRule.currentCall.counter + if (count < 3) { + // Ignore redundant calls. + onMetadataCalled[0][count - 1].complete(null) + } + + assertThat( + "Title should match", + meta.title, + equalTo( + forEachCall( + DOM_META[0].title, + DOM_META[1].title, + ), + ), + ) + assertThat( + "Artist should match", + meta.artist, + equalTo( + forEachCall( + DOM_META[0].artist, + DOM_META[1].artist, + ), + ), + ) + assertThat( + "Album should match", + meta.album, + equalTo( + forEachCall( + DOM_META[0].album, + DOM_META[1].album, + ), + ), + ) + assertThat( + "Artwork image should be non-null", + meta.artwork!!.getBitmap(200), + notNullValue(), + ) + } + + @AssertCalled(count = 2) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled[0][sessionRule.currentCall.counter - 1] + .complete(null) + } + + @AssertCalled(count = 2) + override fun onPause( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPauseCalled[0][sessionRule.currentCall.counter - 1] + .complete(null) + } + }) + + session2.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1) + override fun onActivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onActivatedCalled[1][sessionRule.currentCall.counter - 1] + .complete(null) + mediaSession2 = mediaSession + + assertThat( + "Should be active", + mediaSession1!!.isActive, + equalTo(true), + ) + assertThat( + "Should be active", + mediaSession2!!.isActive, + equalTo(true), + ) + } + + @AssertCalled + override fun onMetadata( + session: GeckoSession, + mediaSession: MediaSession, + meta: MediaSession.Metadata, + ) { + val count = sessionRule.currentCall.counter + if (count < 2) { + // Ignore redundant calls. + onMetadataCalled[1][0].complete(null) + } + + assertThat( + "Title should match", + meta.title, + equalTo( + forEachCall( + DOM_META[0].title, + ), + ), + ) + assertThat( + "Artist should match", + meta.artist, + equalTo( + forEachCall( + DOM_META[0].artist, + ), + ), + ) + assertThat( + "Album should match", + meta.album, + equalTo( + forEachCall( + DOM_META[0].album, + ), + ), + ) + } + + @AssertCalled(count = 1) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled[1][sessionRule.currentCall.counter - 1] + .complete(null) + } + + @AssertCalled(count = 1) + override fun onPause( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPauseCalled[1][sessionRule.currentCall.counter - 1] + .complete(null) + } + }) + + session1.loadTestPath(path) + sessionRule.waitForResult(completedStep2) + + mediaSession1!!.pause() + sessionRule.waitForResult(completedStep3) + + session2.loadTestPath(path) + sessionRule.waitForResult(completedStep5) + + mediaSession2!!.pause() + sessionRule.waitForResult(completedStep6) + + mediaSession1!!.pause() + mediaSession1!!.nextTrack() + mediaSession1!!.play() + sessionRule.waitForResult(completedStep7) + sessionRule.waitForResult(completedStep8) + } + + @Test + fun fullscreenVideoElementMetadata() { + // TODO: bug 1810736 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "media.autoplay.default" to 0, + "full-screen-api.allow-trusted-requests-only" to false, + ), + ) + + val onActivatedCalled = GeckoResult() + val onPlayCalled = GeckoResult() + val onPauseCalled = GeckoResult() + val onFullscreenCalled = arrayOf( + GeckoResult(), + GeckoResult(), + ) + + // Test: + // 1. Load video test page which contains 1 video element. + // a. Ensure page has loaded. + // 2. Play video element. + // a. Ensure onActivated is called. + // b. Ensure onPlay is called. + val completedStep2 = GeckoResult.allOf( + onActivatedCalled, + onPlayCalled, + ) + + // 3. Enter fullscreen of the video. + // a. Ensure onFullscreen is called. + val completedStep3 = GeckoResult.allOf( + onFullscreenCalled[0], + ) + + // 4. Exit fullscreen of the video. + // a. Ensure onFullscreen is called. + val completedStep4 = GeckoResult.allOf( + onFullscreenCalled[1], + ) + + // 5. Pause the video. + // a. Ensure onPause is called. + val completedStep5 = GeckoResult.allOf( + onPauseCalled, + ) + + var mediaSession1: MediaSession? = null + + val path = VIDEO_WEBM_PATH + val session1 = sessionRule.createOpenSession() + + session1.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1, order = [1]) + override fun onActivated( + session: GeckoSession, + mediaSession: MediaSession, + ) { + mediaSession1 = mediaSession + + onActivatedCalled.complete(null) + + assertThat( + "Should be active", + mediaSession.isActive, + equalTo(true), + ) + } + + @AssertCalled(count = 1, order = [2]) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled.complete(null) + } + + @AssertCalled(count = 1) + override fun onPause( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPauseCalled.complete(null) + } + + @AssertCalled(count = 2) + override fun onFullscreen( + session: GeckoSession, + mediaSession: MediaSession, + enabled: Boolean, + meta: MediaSession.ElementMetadata?, + ) { + if (sessionRule.currentCall.counter == 1) { + assertThat( + "Fullscreen should be enabled", + enabled, + equalTo(true), + ) + assertThat( + "Element metadata should exist", + meta, + notNullValue(), + ) + assertThat( + "Duration should match", + meta!!.duration, + closeTo(WEBM_TEST_DURATION, 0.01), + ) + assertThat( + "Width should match", + meta.width, + equalTo(WEBM_TEST_WIDTH), + ) + assertThat( + "Height should match", + meta.height, + equalTo(WEBM_TEST_HEIGHT), + ) + assertThat( + "Audio track count should match", + meta.audioTrackCount, + equalTo(1), + ) + assertThat( + "Video track count should match", + meta.videoTrackCount, + equalTo(1), + ) + } else { + assertThat( + "Fullscreen should be disabled", + enabled, + equalTo(false), + ) + } + + onFullscreenCalled[sessionRule.currentCall.counter - 1] + .complete(null) + } + }) + + // 1. + session1.loadTestPath(path) + sessionRule.waitForPageStop() + + // 2. + session1.evaluateJS("document.querySelector('video').play()") + sessionRule.waitForResult(completedStep2) + + // 3. + session1.evaluateJS( + "document.querySelector('video').requestFullscreen()", + ) + sessionRule.waitForResult(completedStep3) + + // 4. + session1.evaluateJS("document.exitFullscreen()") + sessionRule.waitForResult(completedStep4) + + // 5. + mediaSession1!!.pause() + sessionRule.waitForResult(completedStep5) + } + + @Test + fun fullscreenVideoWithActivated() { + // TODO: bug 1810736 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "media.autoplay.default" to 0, + "full-screen-api.allow-trusted-requests-only" to false, + ), + ) + + val path = VIDEO_WEBM_PATH + val session = sessionRule.createOpenSession() + val resultFullscreen = GeckoResult() + session.loadTestPath(path) + sessionRule.waitForPageStop() + + session.delegateDuringNextWait(object : MediaSession.Delegate { + override fun onFullscreen( + session: GeckoSession, + mediaSession: MediaSession, + enabled: Boolean, + meta: MediaSession.ElementMetadata?, + ) { + assertThat( + "Fullscreen should be enabled", + enabled, + equalTo(true), + ) + assertThat( + "Element metadata should exist", + meta, + notNullValue(), + ) + resultFullscreen.complete(null) + } + }) + + session.evaluateJS("document.querySelector('video').requestFullscreen()") + sessionRule.waitForResult(resultFullscreen) + } + + @Test + fun switchingProcess() { + // TODO: bug 1810736 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "media.autoplay.default" to 0, + ), + ) + + mainSession.loadUri("about:blank") + sessionRule.waitForPageStop() + + mainSession.loadTestPath(VIDEO_WEBM_PATH) + sessionRule.waitForPageStop() + + val onPlayCalled = GeckoResult() + mainSession.delegateUntilTestEnd(object : MediaSession.Delegate { + @AssertCalled(count = 1) + override fun onPlay( + session: GeckoSession, + mediaSession: MediaSession, + ) { + onPlayCalled.complete(null) + } + }) + + mainSession.evaluateJS("document.querySelector('video').play()") + sessionRule.waitForResult(onPlayCalled) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java new file mode 100644 index 0000000000..b218cf9838 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java @@ -0,0 +1,213 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.MediumTest; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mozilla.gecko.MultiMap; + +@RunWith(AndroidJUnit4.class) +@MediumTest +public class MultiMapTest { + @Test + public void emptyMap() { + final MultiMap map = new MultiMap<>(); + + assertThat(map.get("not-present").isEmpty(), is(true)); + assertThat(map.containsKey("not-present"), is(false)); + assertThat(map.containsEntry("not-present", "nope"), is(false)); + assertThat(map.size(), is(0)); + assertThat(map.asMap().size(), is(0)); + assertThat(map.remove("not-present"), nullValue()); + assertThat(map.remove("not-present", "nope"), is(false)); + assertThat(map.keySet().size(), is(0)); + + map.clear(); + } + + @Test + public void emptyMapWithCapacity() { + final MultiMap map = new MultiMap<>(10); + + assertThat(map.get("not-present").isEmpty(), is(true)); + assertThat(map.containsKey("not-present"), is(false)); + assertThat(map.containsEntry("not-present", "nope"), is(false)); + assertThat(map.size(), is(0)); + assertThat(map.asMap().size(), is(0)); + assertThat(map.remove("not-present"), nullValue()); + assertThat(map.remove("not-present", "nope"), is(false)); + assertThat(map.keySet().size(), is(0)); + + map.clear(); + } + + @Test + public void addMultipleValues() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + assertThat(map.containsEntry("test", "value1"), is(true)); + assertThat(map.containsEntry("test", "value2"), is(true)); + assertThat(map.containsEntry("test2", "value3"), is(true)); + + assertThat(map.containsEntry("test3", "value1"), is(false)); + assertThat(map.containsEntry("test", "value3"), is(false)); + + final List values = map.get("test"); + assertThat(values.contains("value1"), is(true)); + assertThat(values.contains("value2"), is(true)); + assertThat(values.contains("value3"), is(false)); + assertThat(values.size(), is(2)); + + final List values2 = map.get("test2"); + assertThat(values2.contains("value1"), is(false)); + assertThat(values2.contains("value2"), is(false)); + assertThat(values2.contains("value3"), is(true)); + assertThat(values2.size(), is(1)); + + assertThat(map.size(), is(2)); + } + + @Test + public void remove() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + assertThat(map.size(), is(2)); + + final List values = map.remove("test"); + + assertThat(values.size(), is(2)); + assertThat(values.contains("value1"), is(true)); + assertThat(values.contains("value2"), is(true)); + + assertThat(map.size(), is(1)); + + assertThat(map.containsKey("test"), is(false)); + assertThat(map.containsEntry("test", "value1"), is(false)); + assertThat(map.containsEntry("test", "value2"), is(false)); + assertThat(map.get("test").size(), is(0)); + + assertThat(map.get("test2").size(), is(1)); + assertThat(map.get("test2").contains("value3"), is(true)); + assertThat(map.containsEntry("test2", "value3"), is(true)); + } + + @Test + public void removeAllValuesRemovesKey() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + assertThat(map.remove("test", "value1"), is(true)); + assertThat(map.containsEntry("test", "value1"), is(false)); + assertThat(map.containsEntry("test", "value2"), is(true)); + assertThat(map.get("test").size(), is(1)); + assertThat(map.get("test").contains("value2"), is(true)); + + assertThat(map.remove("test", "value2"), is(true)); + + assertThat(map.remove("test", "value3"), is(false)); + assertThat(map.remove("test2", "value4"), is(false)); + + assertThat(map.containsKey("test"), is(false)); + assertThat(map.containsKey("test2"), is(true)); + } + + @Test + public void keySet() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + final Set keys = map.keySet(); + + assertThat(keys.size(), is(2)); + assertThat(keys.contains("test"), is(true)); + assertThat(keys.contains("test2"), is(true)); + } + + @Test + public void clear() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + assertThat(map.size(), is(2)); + + map.clear(); + + assertThat(map.size(), is(0)); + assertThat(map.containsKey("test"), is(false)); + assertThat(map.containsKey("test2"), is(false)); + assertThat(map.containsEntry("test", "value1"), is(false)); + assertThat(map.containsEntry("test", "value2"), is(false)); + assertThat(map.containsEntry("test2", "value3"), is(false)); + } + + @Test + public void asMap() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + map.add("test", "value2"); + map.add("test2", "value3"); + + final Map> asMap = map.asMap(); + + assertThat(asMap.size(), is(2)); + + assertThat(asMap.get("test").size(), is(2)); + assertThat(asMap.get("test").contains("value1"), is(true)); + assertThat(asMap.get("test").contains("value2"), is(true)); + + assertThat(asMap.get("test2").size(), is(1)); + assertThat(asMap.get("test2").contains("value3"), is(true)); + } + + @Test + public void addAll() { + final MultiMap map = new MultiMap<>(); + map.add("test", "value1"); + + assertThat(map.get("test").size(), is(1)); + + // Existing key test + final List values = map.addAll("test", Arrays.asList("value2", "value3")); + + assertThat(values.size(), is(3)); + assertThat(values.contains("value1"), is(true)); + assertThat(values.contains("value2"), is(true)); + assertThat(values.contains("value3"), is(true)); + + assertThat(map.containsEntry("test", "value1"), is(true)); + assertThat(map.containsEntry("test", "value2"), is(true)); + assertThat(map.containsEntry("test", "value3"), is(true)); + + // New key test + final List values2 = map.addAll("test2", Arrays.asList("value4", "value5")); + assertThat(values2.size(), is(2)); + assertThat(values2.contains("value4"), is(true)); + assertThat(values2.contains("value5"), is(true)); + + assertThat(map.containsEntry("test2", "value4"), is(true)); + assertThat(map.containsEntry("test2", "value5"), is(true)); + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt new file mode 100644 index 0000000000..f688b498f5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt @@ -0,0 +1,3126 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.Bitmap +import android.os.Looper +import android.os.SystemClock +import android.util.Base64 +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONObject +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.HistoryDelegate +import org.mozilla.geckoview.GeckoSession.Loader +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.GeckoSession.TextInputDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.test.util.UiThreadUtils +import java.io.ByteArrayOutputStream +import java.util.concurrent.ThreadLocalRandom +import kotlin.concurrent.thread + +@RunWith(AndroidJUnit4::class) +@MediumTest +class NavigationDelegateTest : BaseSessionTest() { + + // Provides getters for Loader + class TestLoader : Loader() { + var mUri: String? = null + override fun uri(uri: String): TestLoader { + mUri = uri + super.uri(uri) + return this + } + fun getUri(): String? { + return mUri + } + override fun flags(f: Int): TestLoader { + super.flags(f) + return this + } + } + + fun testLoadErrorWithErrorPage( + testLoader: TestLoader, + expectedCategory: Int, + expectedError: Int, + errorPageUrl: String?, + ) { + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "URI should be " + testLoader.getUri(), + request.uri, + equalTo(testLoader.getUri()), + ) + assertThat( + "App requested this load", + request.isDirectNavigation, + equalTo(true), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat( + "URI should be " + testLoader.getUri(), + url, + equalTo(testLoader.getUri()), + ) + } + + @AssertCalled(count = 1, order = [3]) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + assertThat( + "Error category should match", + error.category, + equalTo(expectedCategory), + ) + assertThat( + "Error code should match", + error.code, + equalTo(expectedError), + ) + return GeckoResult.fromValue(errorPageUrl) + } + + @AssertCalled(count = 1, order = [4]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(false)) + } + }, + ) + + mainSession.load(testLoader) + sessionRule.waitForPageStop() + + if (errorPageUrl != null) { + sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, equalTo(testLoader.getUri())) + } + + @AssertCalled(count = 1, order = [2]) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should not be empty", title, not(isEmptyOrNullString())) + } + }) + } + } + + fun testLoadExpectError( + testUri: String, + expectedCategory: Int, + expectedError: Int, + ) { + testLoadExpectError(TestLoader().uri(testUri), expectedCategory, expectedError) + } + + fun testLoadExpectError( + testLoader: TestLoader, + expectedCategory: Int, + expectedError: Int, + ) { + testLoadErrorWithErrorPage( + testLoader, + expectedCategory, + expectedError, + createTestUrl(HELLO_HTML_PATH), + ) + testLoadErrorWithErrorPage( + testLoader, + expectedCategory, + expectedError, + null, + ) + } + + fun testLoadEarlyErrorWithErrorPage( + testUri: String, + expectedCategory: Int, + expectedError: Int, + errorPageUrl: String?, + ) { + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + + @AssertCalled(false) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URI should be " + testUri, url, equalTo(testUri)) + } + + @AssertCalled(count = 1, order = [1]) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + assertThat( + "Error category should match", + error.category, + equalTo(expectedCategory), + ) + assertThat( + "Error code should match", + error.code, + equalTo(expectedError), + ) + return GeckoResult.fromValue(errorPageUrl) + } + + @AssertCalled(false) + override fun onPageStop(session: GeckoSession, success: Boolean) { + } + }, + ) + + mainSession.loadUri(testUri) + sessionRule.waitUntilCalled(NavigationDelegate::class, "onLoadError") + + if (errorPageUrl != null) { + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should not be empty", title, not(isEmptyOrNullString())) + } + }) + } + } + + fun testLoadEarlyError( + testUri: String, + expectedCategory: Int, + expectedError: Int, + ) { + testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, createTestUrl(HELLO_HTML_PATH)) + testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, null) + } + + @Test fun loadFileNotFound() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + testLoadExpectError( + "file:///test.mozilla", + WebRequestError.ERROR_CATEGORY_URI, + WebRequestError.ERROR_FILE_NOT_FOUND, + ) + + val promise = mainSession.evaluatePromiseJS("document.addCertException(false)") + var exceptionCaught = false + try { + val result = promise.value as Boolean + assertThat("Promise should not resolve", result, equalTo(false)) + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + exceptionCaught = true + } + assertThat("document.addCertException failed with exception", exceptionCaught, equalTo(true)) + } + + @Test fun loadUnknownHost() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + testLoadExpectError( + UNKNOWN_HOST_URI, + WebRequestError.ERROR_CATEGORY_URI, + WebRequestError.ERROR_UNKNOWN_HOST, + ) + } + + // External loads should not have access to privileged protocols + @Test fun loadExternalDenied() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + testLoadExpectError( + TestLoader() + .uri("file:///") + .flags(GeckoSession.LOAD_FLAGS_EXTERNAL), + WebRequestError.ERROR_CATEGORY_UNKNOWN, + WebRequestError.ERROR_UNKNOWN, + ) + testLoadExpectError( + TestLoader() + .uri("resource://gre/") + .flags(GeckoSession.LOAD_FLAGS_EXTERNAL), + WebRequestError.ERROR_CATEGORY_UNKNOWN, + WebRequestError.ERROR_UNKNOWN, + ) + testLoadExpectError( + TestLoader() + .uri("about:about") + .flags(GeckoSession.LOAD_FLAGS_EXTERNAL), + WebRequestError.ERROR_CATEGORY_UNKNOWN, + WebRequestError.ERROR_UNKNOWN, + ) + testLoadExpectError( + TestLoader() + .uri("resource://android/assets/web_extensions/") + .flags(GeckoSession.LOAD_FLAGS_EXTERNAL), + WebRequestError.ERROR_CATEGORY_UNKNOWN, + WebRequestError.ERROR_UNKNOWN, + ) + } + + @Test fun loadInvalidUri() { + testLoadEarlyError( + INVALID_URI, + WebRequestError.ERROR_CATEGORY_URI, + WebRequestError.ERROR_MALFORMED_URI, + ) + } + + @Test fun loadBadPort() { + testLoadEarlyError( + "http://localhost:1/", + WebRequestError.ERROR_CATEGORY_NETWORK, + WebRequestError.ERROR_PORT_BLOCKED, + ) + } + + @Test fun loadUntrusted() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val host = if (sessionRule.env.isAutomation) { + "expired.example.com" + } else { + "expired.badssl.com" + } + val uri = "https://$host/" + testLoadExpectError( + uri, + WebRequestError.ERROR_CATEGORY_SECURITY, + WebRequestError.ERROR_SECURITY_BAD_CERT, + ) + + mainSession.waitForJS("document.addCertException(false)") + mainSession.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URI should be " + uri, url, equalTo(uri)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: ProgressDelegate.SecurityInformation, + ) { + assertThat("Should be exception", securityInfo.isException, equalTo(true)) + assertThat("Should not be secure", securityInfo.isSecure, equalTo(false)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + sessionRule.removeAllCertOverrides() + } + }, + ) + mainSession.evaluateJS("location.reload()") + mainSession.waitForPageStop() + } + + @Test fun loadWithHTTPSOnlyMode() { + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY) + + val httpsFirstPref = "dom.security.https_first" + val httpsFirstPrefValue = (sessionRule.getPrefs(httpsFirstPref)[0] as Boolean) + + val httpsFirstPBMPref = "dom.security.https_first_pbm" + val httpsFirstPBMPrefValue = (sessionRule.getPrefs(httpsFirstPBMPref)[0] as Boolean) + + val insecureUri = if (sessionRule.env.isAutomation) { + "http://nocert.example.com/" + } else { + "http://neverssl.com" + } + + val secureUri = if (sessionRule.env.isAutomation) { + "http://example.com/" + } else { + "http://neverssl.com" + } + + mainSession.loadUri(insecureUri) + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK)) + assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_HTTPS_ONLY)) + return null + } + }) + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL) + + mainSession.loadUri(secureUri) + mainSession.waitForPageStop() + + var onLoadCalledCounter = 0 + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 0) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + return null + } + + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + onLoadCalledCounter++ + return null + } + }) + + if (httpsFirstPrefValue) { + // if https-first is enabled we get two calls to onLoadRequest + // (1) http://example.com/ and (2) https://example.com/ + assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(2)) + } else { + assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(1)) + } + + val privateSession = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build(), + ) + + privateSession.loadUri(secureUri) + privateSession.waitForPageStop() + + onLoadCalledCounter = 0 + privateSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 0) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + return null + } + + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + onLoadCalledCounter++ + return null + } + }) + + if (httpsFirstPBMPrefValue) { + // if https-first is enabled we get two calls to onLoadRequest + // (1) http://example.com/ and (2) https://example.com/ + assertThat("Assert count privateSession.onLoadRequest", onLoadCalledCounter, equalTo(2)) + } else { + assertThat("Assert count privateSession.onLoadRequest", onLoadCalledCounter, equalTo(1)) + } + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY_PRIVATE) + + privateSession.loadUri(insecureUri) + privateSession.waitForPageStop() + + privateSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK)) + assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_HTTPS_ONLY)) + return null + } + }) + + mainSession.loadUri(secureUri) + mainSession.waitForPageStop() + + onLoadCalledCounter = 0 + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 0) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + return null + } + + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + onLoadCalledCounter++ + return null + } + }) + + if (httpsFirstPrefValue) { + // if https-first is enabled we get two calls to onLoadRequest + // (1) http://example.com/ and (2) https://example.com/ + assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(2)) + } else { + assertThat("Assert count mainSession.onLoadRequest", onLoadCalledCounter, equalTo(1)) + } + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL) + } + + // Due to Bug 1692578 we currently cannot test bypassing of the error + // the URI loading process takes the desktop path for iframes + @Test fun loadHTTPSOnlyInSubframe() { + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY) + + val uri = "http://example.org/tests/junit/iframe_http_only.html" + val httpsUri = "https://example.org/tests/junit/iframe_http_only.html" + val iFrameUri = "http://expired.example.com/" + val iFrameHttpsUri = "https://expired.example.com/" + + val testLoader = TestLoader().uri(uri) + + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri))) + return null + } + + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat( + "URI should be " + uri, + url, + equalTo(uri), + ) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(true)) + } + + @AssertCalled(count = 2) + override fun onSubframeLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat("URI should match", request.uri, equalTo(forEachCall(iFrameUri, iFrameHttpsUri))) + return GeckoResult.allow() + } + }, + ) + + mainSession.load(testLoader) + sessionRule.waitForPageStop() + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL) + } + + @Test fun bypassHTTPSOnlyError() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.HTTPS_ONLY) + + val host = if (sessionRule.env.isAutomation) { + "expired.example.com" + } else { + "expired.badssl.com" + } + + val uri = "http://$host/" + val httpsUri = "https://$host/" + + val testLoader = TestLoader().uri(uri) + + // The two loads below follow testLoadExpectError(TestLoader, Int, Int) flow + + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri))) + return null + } + + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat( + "URI should be " + uri, + url, + equalTo(uri), + ) + } + + @AssertCalled(count = 1) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + assertThat( + "Error code should match", + error.code, + equalTo(WebRequestError.ERROR_HTTPS_ONLY), + ) + return GeckoResult.fromValue(createTestUrl(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(false)) + } + }, + ) + + mainSession.load(testLoader) + sessionRule.waitForPageStop() + + sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, equalTo(httpsUri)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should not be empty", title, not(isEmptyOrNullString())) + } + }) + + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 2, order = [1, 3]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("The URLs must match", request.uri, equalTo(forEachCall(uri, httpsUri))) + return null + } + + @AssertCalled(count = 1, order = [4]) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + assertThat( + "Error code should match", + error.code, + equalTo(WebRequestError.ERROR_HTTPS_ONLY), + ) + return GeckoResult.fromValue(null) + } + + @AssertCalled(count = 1, order = [5]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(false)) + } + }, + ) + + mainSession.load(testLoader) + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait( + object : ProgressDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 1) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + // We set http scheme only in case it's not iFrame + assertThat("The URLs must match", request.uri, equalTo(uri)) + return null + } + + @AssertCalled(count = 0) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + + mainSession.waitForJS("document.reloadWithHttpsOnlyException()") + mainSession.waitForPageStop() + + sessionRule.runtime.settings.setAllowInsecureConnections(GeckoRuntimeSettings.ALLOW_ALL) + } + + @Test fun loadHSTSBadCert() { + val httpsFirstPref = "dom.security.https_first" + assertThat("https pref should be false", sessionRule.getPrefs(httpsFirstPref)[0] as Boolean, equalTo(false)) + + // load secure url with hsts header + val uri = "https://example.com/tests/junit/hsts_header.sjs" + mainSession.loadUri(uri) + mainSession.waitForPageStop() + + // load insecure subdomain url to see if it gets upgraded to https + val http_uri = "http://test1.example.com/" + val https_uri = "https://test1.example.com/" + + mainSession.loadUri(http_uri) + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "URI should be HTTP then redirected to HTTPS", + request.uri, + equalTo(forEachCall(http_uri, https_uri)), + ) + return null + } + }) + + // load subdomain that will trigger the cert error + val no_cert_uri = "https://nocert.example.com/" + mainSession.loadUri(no_cert_uri) + mainSession.waitForPageStop() + + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + assertThat("categories should match", error.category, equalTo(WebRequestError.ERROR_CATEGORY_NETWORK)) + assertThat("codes should match", error.code, equalTo(WebRequestError.ERROR_BAD_HSTS_CERT)) + return null + } + }) + sessionRule.clearHSTSState() + } + + @Ignore // Disabled for bug 1619344. + @Test + fun loadUnknownProtocol() { + testLoadEarlyError( + UNKNOWN_PROTOCOL_URI, + WebRequestError.ERROR_CATEGORY_URI, + WebRequestError.ERROR_UNKNOWN_PROTOCOL, + ) + } + + // Due to Bug 1692578 we currently cannot test displaying the error + // the URI loading process takes the desktop path for iframes + @Test fun loadUnknownProtocolIframe() { + // Should match iframe URI from IFRAME_UNKNOWN_PROTOCOL + val iframeUri = "foo://bar" + mainSession.loadTestPath(IFRAME_UNKNOWN_PROTOCOL) + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat("URI should match", request.uri, endsWith(IFRAME_UNKNOWN_PROTOCOL)) + return null + } + + @AssertCalled(count = 1) + override fun onSubframeLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat("URI should match", request.uri, endsWith(iframeUri)) + return null + } + }) + } + + @Setting(key = Setting.Key.USE_TRACKING_PROTECTION, value = "true") + @Ignore + // TODO: Bug 1564373 + @Test + fun trackingProtection() { + val category = ContentBlocking.AntiTracking.TEST + sessionRule.runtime.settings.contentBlocking.setAntiTracking(category) + mainSession.loadTestPath(TRACKERS_PATH) + + sessionRule.waitUntilCalled( + object : ContentBlocking.Delegate { + @AssertCalled(count = 3) + override fun onContentBlocked( + session: GeckoSession, + event: ContentBlocking.BlockEvent, + ) { + assertThat( + "Category should be set", + event.antiTrackingCategory, + equalTo(category), + ) + assertThat("URI should not be null", event.uri, notNullValue()) + assertThat("URI should match", event.uri, endsWith("tracker.js")) + } + + @AssertCalled(false) + override fun onContentLoaded(session: GeckoSession, event: ContentBlocking.BlockEvent) { + } + }, + ) + + mainSession.settings.useTrackingProtection = false + + mainSession.reload() + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : ContentBlocking.Delegate { + @AssertCalled(false) + override fun onContentBlocked( + session: GeckoSession, + event: ContentBlocking.BlockEvent, + ) { + } + + @AssertCalled(count = 3) + override fun onContentLoaded(session: GeckoSession, event: ContentBlocking.BlockEvent) { + assertThat( + "Category should be set", + event.antiTrackingCategory, + equalTo(category), + ) + assertThat("URI should not be null", event.uri, notNullValue()) + assertThat("URI should match", event.uri, endsWith("tracker.js")) + } + }, + ) + } + + @Test fun redirectLoad() { + val redirectUri = if (sessionRule.env.isAutomation) { + "https://example.org/tests/junit/hello.html" + } else { + "https://jigsaw.w3.org/HTTP/300/Overview.html" + } + val uri = if (sessionRule.env.isAutomation) { + "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri" + } else { + "https://jigsaw.w3.org/HTTP/300/301.html" + } + + mainSession.loadUri(uri) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 2, order = [1, 2]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat( + "URL should match", + request.uri, + equalTo(forEachCall(request.uri, redirectUri)), + ) + assertThat( + "Trigger URL should be null", + request.triggerUri, + nullValue(), + ) + assertThat( + "From app should be correct", + request.isDirectNavigation, + equalTo(forEachCall(true, false)), + ) + assertThat("Target should not be null", request.target, notNullValue()) + assertThat( + "Target should match", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_CURRENT), + ) + assertThat( + "Redirect flag is set", + request.isRedirect, + equalTo(forEachCall(false, true)), + ) + return null + } + }) + } + + @Test fun redirectLoadIframe() { + val path = if (sessionRule.env.isAutomation) { + IFRAME_REDIRECT_AUTOMATION + } else { + IFRAME_REDIRECT_LOCAL + } + + mainSession.loadTestPath(path) + sessionRule.waitForPageStop() + + // We shouldn't be firing onLoadRequest for iframes, including redirects. + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("App requested this load", request.isDirectNavigation, equalTo(true)) + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat("URI should match", request.uri, endsWith(path)) + assertThat("isRedirect should match", request.isRedirect, equalTo(false)) + return null + } + + @AssertCalled(count = 2) + override fun onSubframeLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("App did not request this load", request.isDirectNavigation, equalTo(false)) + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat( + "isRedirect should match", + request.isRedirect, + equalTo(forEachCall(false, true)), + ) + return null + } + }) + } + + @Test fun redirectDenyLoad() { + val redirectUri = if (sessionRule.env.isAutomation) { + "https://example.org/tests/junit/hello.html" + } else { + "https://jigsaw.w3.org/HTTP/300/Overview.html" + } + val uri = if (sessionRule.env.isAutomation) { + "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri" + } else { + "https://jigsaw.w3.org/HTTP/300/301.html" + } + + sessionRule.delegateDuringNextWait( + object : NavigationDelegate { + @AssertCalled(count = 2, order = [1, 2]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat( + "URL should match", + request.uri, + equalTo(forEachCall(request.uri, redirectUri)), + ) + assertThat( + "Trigger URL should be null", + request.triggerUri, + nullValue(), + ) + assertThat( + "From app should be correct", + request.isDirectNavigation, + equalTo(forEachCall(true, false)), + ) + assertThat("Target should not be null", request.target, notNullValue()) + assertThat( + "Target should match", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_CURRENT), + ) + assertThat( + "Redirect flag is set", + request.isRedirect, + equalTo(forEachCall(false, true)), + ) + + return forEachCall(GeckoResult.allow(), GeckoResult.deny()) + } + }, + ) + + mainSession.loadUri(uri) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, equalTo(uri)) + } + }, + ) + } + + @Test fun redirectIntentLoad() { + assumeThat(sessionRule.env.isAutomation, equalTo(true)) + + val redirectUri = "intent://test" + val uri = "https://example.org/tests/junit/simple_redirect.sjs?$redirectUri" + + mainSession.loadUri(uri) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 2, order = [1, 2]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("URL should match", request.uri, equalTo(forEachCall(uri, redirectUri))) + assertThat( + "From app should be correct", + request.isDirectNavigation, + equalTo(forEachCall(true, false)), + ) + return null + } + }) + } + + @Test fun bypassClassifier() { + val phishingUri = "https://www.itisatrap.org/firefox/its-a-trap.html" + val category = ContentBlocking.SafeBrowsing.PHISHING + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category) + + mainSession.load( + Loader() + .uri(phishingUri + "?bypass=true") + .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER), + ) + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + } + + @Test fun safebrowsingPhishing() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val phishingUri = "https://www.itisatrap.org/firefox/its-a-trap.html" + val category = ContentBlocking.SafeBrowsing.PHISHING + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category) + + // Add query string to avoid bypassing classifier check because of cache. + testLoadExpectError( + phishingUri + "?block=true", + WebRequestError.ERROR_CATEGORY_SAFEBROWSING, + WebRequestError.ERROR_SAFEBROWSING_PHISHING_URI, + ) + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE) + + mainSession.loadUri(phishingUri + "?block=false") + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + } + + @Test fun safebrowsingMalware() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val malwareUri = "https://www.itisatrap.org/firefox/its-an-attack.html" + val category = ContentBlocking.SafeBrowsing.MALWARE + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category) + + testLoadExpectError( + malwareUri + "?block=true", + WebRequestError.ERROR_CATEGORY_SAFEBROWSING, + WebRequestError.ERROR_SAFEBROWSING_MALWARE_URI, + ) + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE) + + mainSession.loadUri(malwareUri + "?block=false") + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + } + + @Test fun safebrowsingUnwanted() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val unwantedUri = "https://www.itisatrap.org/firefox/unwanted.html" + val category = ContentBlocking.SafeBrowsing.UNWANTED + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category) + + testLoadExpectError( + unwantedUri + "?block=true", + WebRequestError.ERROR_CATEGORY_SAFEBROWSING, + WebRequestError.ERROR_SAFEBROWSING_UNWANTED_URI, + ) + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE) + + mainSession.loadUri(unwantedUri + "?block=false") + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + } + + @Test fun safebrowsingHarmful() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val harmfulUri = "https://www.itisatrap.org/firefox/harmful.html" + val category = ContentBlocking.SafeBrowsing.HARMFUL + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(category) + + testLoadExpectError( + harmfulUri + "?block=true", + WebRequestError.ERROR_CATEGORY_SAFEBROWSING, + WebRequestError.ERROR_SAFEBROWSING_HARMFUL_URI, + ) + + sessionRule.runtime.settings.contentBlocking.setSafeBrowsing(ContentBlocking.SafeBrowsing.NONE) + + mainSession.loadUri(harmfulUri + "?block=false") + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }, + ) + } + + // Checks that the User Agent matches the user agent built in + // nsHttpHandler::BuildUserAgent + @Test fun defaultUserAgentMatchesActualUserAgent() { + var userAgent = sessionRule.waitForResult(mainSession.userAgent) + assertThat( + "Mobile user agent should match the default user agent", + userAgent, + equalTo(GeckoSession.getDefaultUserAgent()), + ) + } + + @Test fun desktopMode() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + val mobileSubStr = "Mobile" + val desktopSubStr = "X11" + + assertThat( + "User agent should be set to mobile", + getUserAgent(), + containsString(mobileSubStr), + ) + + var userAgent = sessionRule.waitForResult(mainSession.userAgent) + assertThat( + "User agent should be reported as mobile", + userAgent, + containsString(mobileSubStr), + ) + + mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_DESKTOP + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should be set to desktop", + getUserAgent(), + containsString(desktopSubStr), + ) + + userAgent = sessionRule.waitForResult(mainSession.userAgent) + assertThat( + "User agent should be reported as desktop", + userAgent, + containsString(desktopSubStr), + ) + + mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_MOBILE + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should be set to mobile", + getUserAgent(), + containsString(mobileSubStr), + ) + + userAgent = sessionRule.waitForResult(mainSession.userAgent) + assertThat( + "User agent should be reported as mobile", + userAgent, + containsString(mobileSubStr), + ) + + val vrSubStr = "Mobile VR" + mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should be set to VR", + getUserAgent(), + containsString(vrSubStr), + ) + + userAgent = sessionRule.waitForResult(mainSession.userAgent) + assertThat( + "User agent should be reported as VR", + userAgent, + containsString(vrSubStr), + ) + } + + private fun getUserAgent(session: GeckoSession = mainSession): String { + return session.evaluateJS("window.navigator.userAgent") as String + } + + @Test fun uaOverrideNewSession() { + val newSession = sessionRule.createClosedSession() + newSession.settings.userAgentOverride = "Test user agent override" + + newSession.open() + newSession.loadUri("https://example.com") + newSession.waitForPageStop() + + assertThat( + "User agent should match override", + getUserAgent(newSession), + equalTo("Test user agent override"), + ) + } + + @Test fun uaOverride() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + val mobileSubStr = "Mobile" + val vrSubStr = "Mobile VR" + val overrideUserAgent = "This is the override user agent" + + assertThat( + "User agent should be reported as mobile", + getUserAgent(), + containsString(mobileSubStr), + ) + + mainSession.settings.userAgentOverride = overrideUserAgent + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should be reported as override", + getUserAgent(), + equalTo(overrideUserAgent), + ) + + mainSession.settings.userAgentMode = GeckoSessionSettings.USER_AGENT_MODE_VR + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should still be reported as override even when USER_AGENT_MODE is set", + getUserAgent(), + equalTo(overrideUserAgent), + ) + + mainSession.settings.userAgentOverride = null + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should now be reported as VR", + getUserAgent(), + containsString(vrSubStr), + ) + + sessionRule.delegateDuringNextWait(object : NavigationDelegate { + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + mainSession.settings.userAgentOverride = overrideUserAgent + return null + } + }) + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should be reported as override after being set in onLoadRequest", + getUserAgent(), + equalTo(overrideUserAgent), + ) + + sessionRule.delegateDuringNextWait(object : NavigationDelegate { + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + mainSession.settings.userAgentOverride = null + return null + } + }) + + mainSession.reload() + mainSession.waitForPageStop() + + assertThat( + "User agent should again be reported as VR after disabling override in onLoadRequest", + getUserAgent(), + containsString(vrSubStr), + ) + } + + @WithDisplay(width = 600, height = 200) + @Test + fun viewportMode() { + mainSession.loadTestPath(VIEWPORT_PATH) + sessionRule.waitForPageStop() + + val desktopInnerWidth = 980.0 + val physicalWidth = 600.0 + val pixelRatio = mainSession.evaluateJS("window.devicePixelRatio") as Double + val mobileInnerWidth = physicalWidth / pixelRatio + val innerWidthJs = "window.innerWidth" + + var innerWidth = mainSession.evaluateJS(innerWidthJs) as Double + assertThat( + "innerWidth should be equal to $mobileInnerWidth", + innerWidth, + closeTo(mobileInnerWidth, 0.1), + ) + + mainSession.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_DESKTOP + + mainSession.reload() + mainSession.waitForPageStop() + + innerWidth = mainSession.evaluateJS(innerWidthJs) as Double + assertThat( + "innerWidth should be equal to $desktopInnerWidth", + innerWidth, + closeTo(desktopInnerWidth, 0.1), + ) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + innerWidth = mainSession.evaluateJS(innerWidthJs) as Double + assertThat( + "after navigation innerWidth should be equal to $desktopInnerWidth", + innerWidth, + closeTo(desktopInnerWidth, 0.1), + ) + + mainSession.loadTestPath(VIEWPORT_PATH) + sessionRule.waitForPageStop() + + innerWidth = mainSession.evaluateJS(innerWidthJs) as Double + assertThat( + "after navigting back innerWidth should be equal to $desktopInnerWidth", + innerWidth, + closeTo(desktopInnerWidth, 0.1), + ) + + mainSession.settings.viewportMode = GeckoSessionSettings.VIEWPORT_MODE_MOBILE + + mainSession.reload() + mainSession.waitForPageStop() + + innerWidth = mainSession.evaluateJS(innerWidthJs) as Double + assertThat( + "innerWidth should be equal to $mobileInnerWidth again", + innerWidth, + closeTo(mobileInnerWidth, 0.1), + ) + } + + @Test fun load() { + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URI should not be null", request.uri, notNullValue()) + assertThat("URI should match", request.uri, endsWith(HELLO_HTML_PATH)) + assertThat( + "Trigger URL should be null", + request.triggerUri, + nullValue(), + ) + assertThat( + "App requested this load", + request.isDirectNavigation, + equalTo(true), + ) + assertThat("Target should not be null", request.target, notNullValue()) + assertThat( + "Target should match", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_CURRENT), + ) + assertThat("Redirect flag is not set", request.isRedirect, equalTo(false)) + assertThat("Should not have a user gesture", request.hasUserGesture, equalTo(false)) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", url, notNullValue()) + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go back", canGoBack, equalTo(false)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + + @AssertCalled(false) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + } + + @Test fun load_dataUri() { + val dataUrl = "data:,Hello%2C%20World!" + mainSession.loadUri(dataUrl) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match the provided data URL", url, equalTo(dataUrl)) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + @NullDelegate(NavigationDelegate::class) + @Test + fun load_withoutNavigationDelegate() { + // Test that when navigation delegate is disabled, we can still perform loads. + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.reload() + mainSession.waitForPageStop() + } + + @NullDelegate(NavigationDelegate::class) + @Test + fun load_canUnsetNavigationDelegate() { + // Test that if we unset the navigation delegate during a load, the load still proceeds. + var onLocationCount = 0 + mainSession.navigationDelegate = object : NavigationDelegate { + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + onLocationCount++ + } + } + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + assertThat( + "Should get callback for first load", + onLocationCount, + equalTo(1), + ) + + mainSession.reload() + mainSession.navigationDelegate = null + mainSession.waitForPageStop() + + assertThat( + "Should not get callback for second load", + onLocationCount, + equalTo(1), + ) + } + + @Test fun loadString() { + val dataString = "TheTitleTheBody" + val mimeType = "text/html" + mainSession.load(Loader().data(dataString, mimeType)) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate, ContentDelegate { + @AssertCalled + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should match", title, equalTo("TheTitle")) + } + + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat( + "URL should be a data URL", + url, + equalTo(createDataUri(dataString, mimeType)), + ) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + @Test fun loadString_noMimeType() { + mainSession.load(Loader().data("Hello, World!", null)) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should be a data URL", url, startsWith("data:")) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + @Test fun loadData_html() { + val bytes = getTestBytes(HELLO_HTML_PATH) + assertThat("test html should have data", bytes.size, greaterThan(0)) + + mainSession.load(Loader().data(bytes, "text/html")) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate, ContentDelegate { + @AssertCalled(count = 1) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should match", title, equalTo("Hello, world!")) + } + + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, equalTo(createDataUri(bytes, "text/html"))) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + private fun createDataUri( + data: String, + mimeType: String?, + ): String { + return String.format("data:%s,%s", mimeType ?: "", data) + } + + private fun createDataUri( + bytes: ByteArray, + mimeType: String?, + ): String { + return String.format( + "data:%s;base64,%s", + mimeType ?: "", + Base64.encodeToString(bytes, Base64.NO_WRAP), + ) + } + + fun loadDataHelper(assetPath: String, mimeType: String? = null) { + val bytes = getTestBytes(assetPath) + assertThat("test data should have bytes", bytes.size, greaterThan(0)) + + mainSession.load(Loader().data(bytes, mimeType)) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate, ProgressDelegate { + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, equalTo(createDataUri(bytes, mimeType))) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page should load successfully", success, equalTo(true)) + } + }) + } + + @Test fun loadData() { + loadDataHelper("/assets/www/images/test.gif", "image/gif") + } + + @Test fun loadData_noMimeType() { + loadDataHelper("/assets/www/images/test.gif") + } + + @Test fun reload() { + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + sessionRule.waitForPageStop() + + mainSession.reload() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("URI should match", request.uri, endsWith(HELLO_HTML_PATH)) + assertThat( + "Trigger URL should be null", + request.triggerUri, + nullValue(), + ) + assertThat( + "Target should match", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_CURRENT), + ) + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Cannot go back", canGoBack, equalTo(false)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + + @AssertCalled(false) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + } + + @Test fun goBackAndForward() { + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + sessionRule.waitForPageStop() + + mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH") + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH)) + } + }) + + mainSession.goBack() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 0, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Cannot go back", canGoBack, equalTo(false)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Can go forward", canGoForward, equalTo(true)) + } + + @AssertCalled(false) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + + mainSession.goForward() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 0, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Can go back", canGoBack, equalTo(true)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + + @AssertCalled(false) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + } + + @Test fun onLoadUri_returnTrueCancelsLoad() { + sessionRule.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + if (request.uri.endsWith(HELLO_HTML_PATH)) { + return GeckoResult.deny() + } else { + return GeckoResult.allow() + } + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.loadTestPath(HELLO2_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + } + }) + } + + @Test fun onNewSession_calledForWindowOpen() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("window.open('newSession_child.html', '_blank')") + + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("URI should be correct", request.uri, endsWith(NEW_SESSION_CHILD_HTML_PATH)) + assertThat( + "Trigger URL should match", + request.triggerUri, + endsWith(NEW_SESSION_HTML_PATH), + ) + assertThat( + "Target should be correct", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_NEW), + ) + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + assertThat("URI should be correct", uri, endsWith(NEW_SESSION_CHILD_HTML_PATH)) + return null + } + }) + } + + @Test(expected = GeckoSessionTestRule.RejectedPromiseException::class) + fun onNewSession_rejectLocal() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("window.open('file:///data/local/tmp', '_blank')") + } + + @Test fun onNewSession_calledForTargetBlankLink() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()") + + mainSession.waitUntilCalled(object : NavigationDelegate { + // We get two onLoadRequest calls for the link click, + // one when loading the URL and one when opening a new window. + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat("URI should be correct", request.uri, endsWith(NEW_SESSION_CHILD_HTML_PATH)) + assertThat( + "Trigger URL should be null", + request.triggerUri, + endsWith(NEW_SESSION_HTML_PATH), + ) + assertThat( + "Target should be correct", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_NEW), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + assertThat("URI should be correct", uri, endsWith(NEW_SESSION_CHILD_HTML_PATH)) + return null + } + }) + } + + private fun delegateNewSession(settings: GeckoSessionSettings = mainSession.settings): GeckoSession { + val newSession = sessionRule.createClosedSession(settings) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult { + return GeckoResult.fromValue(newSession) + } + }) + + return newSession + } + + @Test fun onNewSession_childShouldLoad() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + val newSession = delegateNewSession() + mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()") + // Initial about:blank + newSession.waitForPageStop() + // NEW_SESSION_CHILD_HTML_PATH + newSession.waitForPageStop() + + newSession.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, endsWith(NEW_SESSION_CHILD_HTML_PATH)) + } + + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + } + }) + } + + @Test fun onNewSession_setWindowOpener() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + val newSession = delegateNewSession() + mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()") + newSession.waitForPageStop() + + assertThat( + "window.opener should be set", + newSession.evaluateJS("window.opener.location.pathname") as String, + equalTo(NEW_SESSION_HTML_PATH), + ) + } + + @Test fun onNewSession_supportNoOpener() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + val newSession = delegateNewSession() + mainSession.evaluateJS("document.querySelector('#noOpenerLink').click()") + newSession.waitForPageStop() + + assertThat( + "window.opener should not be set", + newSession.evaluateJS("window.opener"), + equalTo(JSONObject.NULL), + ) + } + + @Test fun onNewSession_notCalledForHandledLoads() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + // Pretend we handled the target="_blank" link click. + if (request.uri.endsWith(NEW_SESSION_CHILD_HTML_PATH)) { + return GeckoResult.deny() + } else { + return GeckoResult.allow() + } + } + }) + + mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()") + + mainSession.reload() + mainSession.waitForPageStop() + + // Assert that onNewSession was not called for the link click. + mainSession.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "URI must match", + request.uri, + endsWith(forEachCall(NEW_SESSION_CHILD_HTML_PATH, NEW_SESSION_HTML_PATH)), + ) + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 0) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + } + + @Test fun onNewSession_submitFormWithTargetBlank() { + mainSession.loadTestPath(FORM_BLANK_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + document.querySelector('input[type=text]').focus() + """, + ) + mainSession.waitUntilCalled( + TextInputDelegate::class, + "restartInput", + ) + + val time = SystemClock.uptimeMillis() + val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER, 0) + mainSession.textInput.onKeyDown(KeyEvent.KEYCODE_ENTER, keyEvent) + mainSession.textInput.onKeyUp( + KeyEvent.KEYCODE_ENTER, + KeyEvent.changeAction( + keyEvent, + KeyEvent.ACTION_UP, + ), + ) + + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): + GeckoResult? { + assertThat( + "URL should be correct", + request.uri, + endsWith("form_blank.html?"), + ) + assertThat( + "Trigger URL should match", + request.triggerUri, + endsWith("form_blank.html"), + ) + assertThat( + "Target should be correct", + request.target, + equalTo(NavigationDelegate.TARGET_WINDOW_NEW), + ) + return null + } + + @AssertCalled(count = 1, order = [2]) + override fun onNewSession(session: GeckoSession, uri: String): + GeckoResult? { + assertThat("URL should be correct", uri, endsWith("form_blank.html?")) + return null + } + }) + } + + @Test fun loadUriReferrer() { + val uri = "https://example.com" + val referrer = "https://foo.org/" + + mainSession.load( + Loader() + .uri(uri) + .referrer(referrer) + .flags(GeckoSession.LOAD_FLAGS_NONE), + ) + mainSession.waitForPageStop() + + assertThat( + "Referrer should match", + mainSession.evaluateJS("document.referrer") as String, + equalTo(referrer), + ) + } + + @Test fun loadUriReferrerSession() { + val uri = "https://example.com/bar" + val referrer = "https://example.org/" + + mainSession.loadUri(referrer) + mainSession.waitForPageStop() + + val newSession = sessionRule.createOpenSession() + newSession.load( + Loader() + .uri(uri) + .referrer(mainSession) + .flags(GeckoSession.LOAD_FLAGS_NONE), + ) + newSession.waitForPageStop() + + assertThat( + "Referrer should match", + newSession.evaluateJS("document.referrer") as String, + equalTo(referrer), + ) + } + + @Test fun loadUriReferrerSessionFileUrl() { + val uri = "file:///system/etc/fonts.xml" + val referrer = "https://example.org" + + mainSession.loadUri(referrer) + mainSession.waitForPageStop() + + val newSession = sessionRule.createOpenSession() + newSession.load( + Loader() + .uri(uri) + .referrer(mainSession) + .flags(GeckoSession.LOAD_FLAGS_NONE), + ) + newSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + return null + } + }) + } + + private fun loadUriHeaderTest( + headers: Map, + additional: Map, + filter: Int = GeckoSession.HEADER_FILTER_CORS_SAFELISTED, + ) { + // First collect default headers with no override + mainSession.loadUri("$TEST_ENDPOINT/anything") + mainSession.waitForPageStop() + + val defaultContent = mainSession.evaluateJS("document.body.children[0].innerHTML") as String + val defaultBody = JSONObject(defaultContent) + val defaultHeaders = defaultBody.getJSONObject("headers").asMap() + + val expected = HashMap(additional) + for (key in defaultHeaders.keys) { + expected[key] = defaultHeaders[key] + if (additional.containsKey(key)) { + // TODO: Bug 1671294, headers should be replaced, not appended + expected[key] += ", " + additional[key] + } + } + + // Now load the page with the header override + mainSession.load( + Loader() + .uri("$TEST_ENDPOINT/anything") + .additionalHeaders(headers) + .headerFilter(filter), + ) + mainSession.waitForPageStop() + + val content = mainSession.evaluateJS("document.body.children[0].innerHTML") as String + val body = JSONObject(content) + val actualHeaders = body.getJSONObject("headers").asMap() + + assertThat( + "Headers should match", + expected as Map, + equalTo(actualHeaders), + ) + } + + private fun testLoaderEquals(a: Loader, b: Loader, shouldBeEqual: Boolean) { + assertThat("Equal test", a == b, equalTo(shouldBeEqual)) + assertThat( + "HashCode test", + a.hashCode() == b.hashCode(), + equalTo(shouldBeEqual), + ) + } + + @Test fun loaderEquals() { + testLoaderEquals( + Loader().uri("http://test-uri-equals.com"), + Loader().uri("http://test-uri-equals.com"), + true, + ) + testLoaderEquals( + Loader().uri("http://test-uri-equals.com"), + Loader().uri("http://test-uri-equalsx.com"), + false, + ) + + testLoaderEquals( + Loader().uri("http://test-uri-equals.com") + .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER) + .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE) + .referrer("test-referrer"), + Loader().uri("http://test-uri-equals.com") + .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER) + .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE) + .referrer("test-referrer"), + true, + ) + testLoaderEquals( + Loader().uri("http://test-uri-equals.com") + .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER) + .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE) + .referrer(mainSession), + Loader().uri("http://test-uri-equals.com") + .flags(GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER) + .headerFilter(GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE) + .referrer("test-referrer"), + false, + ) + + testLoaderEquals( + Loader().referrer(mainSession) + .data("testtest", "text/plain"), + Loader().referrer(mainSession) + .data("testtest", "text/plain"), + true, + ) + testLoaderEquals( + Loader().referrer(mainSession) + .data("testtest", "text/plain"), + Loader().referrer("test-referrer") + .data("testtest", "text/plain"), + false, + ) + } + + @Test fun loadUriHeader() { + // Basic test + loadUriHeaderTest( + mapOf("Header1" to "Value", "Header2" to "Value1, Value2"), + mapOf(), + ) + loadUriHeaderTest( + mapOf("Header1" to "Value", "Header2" to "Value1, Value2"), + mapOf("Header1" to "Value", "Header2" to "Value1, Value2"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + // Empty value headers are ignored + loadUriHeaderTest( + mapOf("ValueLess1" to "", "ValueLess2" to null), + mapOf(), + ) + + // Null key or special headers are ignored + loadUriHeaderTest( + mapOf( + null to "BadNull", + "Connection" to "BadConnection", + "Host" to "BadHost", + ), + mapOf(), + ) + + // Key or value cannot contain '\r\n' + loadUriHeaderTest( + mapOf( + "Header1" to "Value", + "Header2" to "Value1, Value2", + "this\r\nis invalid" to "test value", + "test key" to "this\r\n is a no-no", + "what" to "what\r\nhost:amazon.com", + "Header3" to "Value1, Value2, Value3", + ), + mapOf(), + ) + loadUriHeaderTest( + mapOf( + "Header1" to "Value", + "Header2" to "Value1, Value2", + "this\r\nis invalid" to "test value", + "test key" to "this\r\n is a no-no", + "what" to "what\r\nhost:amazon.com", + "Header3" to "Value1, Value2, Value3", + ), + mapOf( + "Header1" to "Value", + "Header2" to "Value1, Value2", + "Header3" to "Value1, Value2, Value3", + ), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + loadUriHeaderTest( + mapOf( + "Header1" to "Value", + "Header2" to "Value1, Value2", + "what" to "what\r\nhost:amazon.com", + ), + mapOf(), + ) + loadUriHeaderTest( + mapOf( + "Header1" to "Value", + "Header2" to "Value1, Value2", + "what" to "what\r\nhost:amazon.com", + ), + mapOf("Header1" to "Value", "Header2" to "Value1, Value2"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + loadUriHeaderTest( + mapOf("what" to "what\r\nhost:amazon.com"), + mapOf(), + ) + + loadUriHeaderTest( + mapOf("this\r\n" to "yes"), + mapOf(), + ) + + // Connection and Host cannot be overriden, no matter the case spelling + loadUriHeaderTest( + mapOf("Header1" to "Value1", "ConnEction" to "test", "connection" to "test2"), + mapOf(), + ) + loadUriHeaderTest( + mapOf("Header1" to "Value1", "ConnEction" to "test", "connection" to "test2"), + mapOf("Header1" to "Value1"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + loadUriHeaderTest( + mapOf("Header1" to "Value1", "connection" to "test2"), + mapOf(), + ) + loadUriHeaderTest( + mapOf("Header1" to "Value1", "connection" to "test2"), + mapOf("Header1" to "Value1"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + loadUriHeaderTest( + mapOf("Header1 " to "Value1", "host" to "test2"), + mapOf(), + ) + loadUriHeaderTest( + mapOf("Header1 " to "Value1", "host" to "test2"), + mapOf("Header1" to "Value1"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + loadUriHeaderTest( + mapOf("Header1" to "Value1", "host" to "test2"), + mapOf(), + ) + loadUriHeaderTest( + mapOf("Header1" to "Value1", "host" to "test2"), + mapOf("Header1" to "Value1"), + GeckoSession.HEADER_FILTER_UNRESTRICTED_UNSAFE, + ) + + // Adding white space at the end of a forbidden header still prevents override + loadUriHeaderTest( + mapOf( + "host" to "amazon.com", + "host " to "amazon.com", + "host\r" to "amazon.com", + "host\r\n" to "amazon.com", + ), + mapOf(), + ) + + // '\r' or '\n' are forbidden character even when not following each other + loadUriHeaderTest( + mapOf("abc\ra\n" to "amazon.com"), + mapOf(), + ) + + // CORS Safelist test + loadUriHeaderTest( + mapOf( + "Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + "Accept" to "text/html", + "Content-Language" to "de-DE, en-CA", + "Content-Type" to "multipart/form-data; boundary=something", + ), + mapOf( + "Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + "Accept" to "text/html", + "Content-Language" to "de-DE, en-CA", + "Content-Type" to "multipart/form-data; boundary=something", + ), + GeckoSession.HEADER_FILTER_CORS_SAFELISTED, + ) + + // CORS safelist doesn't allow Content-type image/svg + loadUriHeaderTest( + mapOf( + "Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + "Accept" to "text/html", + "Content-Language" to "de-DE, en-CA", + "Content-Type" to "image/svg; boundary=something", + ), + mapOf( + "Accept-Language" to "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + "Accept" to "text/html", + "Content-Language" to "de-DE, en-CA", + ), + GeckoSession.HEADER_FILTER_CORS_SAFELISTED, + ) + } + + @Test(expected = GeckoResult.UncaughtException::class) + fun onNewSession_doesNotAllowOpened() { + // Disable popup blocker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(NEW_SESSION_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult { + return GeckoResult.fromValue(sessionRule.createOpenSession()) + } + }) + + mainSession.evaluateJS("document.querySelector('#targetBlankLink').click()") + + mainSession.waitUntilCalled( + NavigationDelegate::class, + "onNewSession", + ) + UiThreadUtils.loopUntilIdle(sessionRule.env.defaultTimeoutMillis) + } + + @Test + fun extensionProcessSwitching() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val controller = sessionRule.runtime.webExtensionController + + sessionRule.delegateUntilTestEnd(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val extension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/page-history.xpi"), + ) + + assertThat( + "baseUrl should be a valid extension URL", + extension.metaData.baseUrl, + startsWith("moz-extension://"), + ) + + val url = extension.metaData.baseUrl + "page.html" + processSwitchingTest(url) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + @Test + fun mainProcessSwitching() { + processSwitchingTest("about:config") + } + + private fun processSwitchingTest(url: String) { + val settings = sessionRule.runtime.settings + val aboutConfigEnabled = settings.aboutConfigEnabled + settings.aboutConfigEnabled = true + + var currentUrl: String? = null + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + currentUrl = url + } + + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + assertThat("Should not get here", false, equalTo(true)) + return null + } + }) + + // This will load a page in the child + mainSession.loadTestPath(HELLO2_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat( + "docShell should start out active", + mainSession.active, + equalTo(true), + ) + + // This loads in the parent process + mainSession.loadUri(url) + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, equalTo(url)) + + // This will load a page in the child + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, endsWith(HELLO_HTML_PATH)) + assertThat( + "docShell should be active after switching process", + mainSession.active, + equalTo(true), + ) + + mainSession.loadUri(url) + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, equalTo(url)) + + mainSession.goBack() + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, endsWith(HELLO_HTML_PATH)) + assertThat( + "docShell should be active after switching process", + mainSession.active, + equalTo(true), + ) + + mainSession.goBack() + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, equalTo(url)) + + mainSession.goBack() + sessionRule.waitForPageStop() + + assertThat("URL should match", currentUrl!!, endsWith(HELLO2_HTML_PATH)) + assertThat( + "docShell should be active after switching process", + mainSession.active, + equalTo(true), + ) + + settings.aboutConfigEnabled = aboutConfigEnabled + } + + @Test fun setLocationHash() { + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + sessionRule.waitForPageStop() + + mainSession.evaluateJS("location.hash = 'test1';") + + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled(count = 0) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return null + } + + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URI should match", url, endsWith("#test1")) + } + }) + + mainSession.evaluateJS("location.hash = 'test2';") + + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled(count = 0) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): + GeckoResult? { + return null + } + + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URI should match", url, endsWith("#test2")) + } + }) + } + + @Test fun purgeHistory() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + sessionRule.waitUntilCalled(object : HistoryDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go back", canGoBack, equalTo(false)) + } + + @AssertCalled(count = 1) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) { + assertThat("History should have one entry", state.size, equalTo(1)) + } + }) + mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH") + sessionRule.waitUntilCalled(object : HistoryDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go back", canGoBack, equalTo(true)) + } + + @AssertCalled(count = 1) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) { + assertThat("History should have two entries", state.size, equalTo(2)) + } + }) + mainSession.purgeHistory() + sessionRule.waitUntilCalled(object : HistoryDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) { + assertThat("History should have one entry", state.size, equalTo(1)) + } + + @AssertCalled(count = 1) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go back", canGoBack, equalTo(false)) + } + + @AssertCalled(count = 1) + override fun onCanGoForward(session: GeckoSession, canGoForward: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Cannot go forward", canGoForward, equalTo(false)) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun userGesture() { + mainSession.loadUri("$TEST_ENDPOINT$CLICK_TO_RELOAD_HTML_PATH") + mainSession.waitForPageStop() + + mainSession.synthesizeTap(50, 50) + + sessionRule.waitUntilCalled(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + assertThat("Should have a user gesture", request.hasUserGesture, equalTo(true)) + assertThat( + "Load should not be direct", + request.isDirectNavigation, + equalTo(false), + ) + return GeckoResult.allow() + } + }) + } + + @Test fun loadAfterLoad() { + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + assertThat("URLs should match", request.uri, endsWith(forEachCall(HELLO_HTML_PATH, HELLO2_HTML_PATH))) + return GeckoResult.allow() + } + }) + + mainSession.loadUri("$TEST_ENDPOINT$HELLO_HTML_PATH") + mainSession.loadUri("$TEST_ENDPOINT$HELLO2_HTML_PATH") + mainSession.waitForPageStop() + } + + @Test + fun loadLongDataUriToplevelDirect() { + val dataBytes = ByteArray(3 * 1024 * 1024) + val expectedUri = createDataUri(dataBytes, "*/*") + val loader = Loader().data(dataBytes, "*/*") + + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + assertThat("URLs should match", request.uri, equalTo(expectedUri)) + return GeckoResult.allow() + } + + @AssertCalled(count = 1, order = [2]) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + assertThat( + "Error category should match", + error.category, + equalTo(WebRequestError.ERROR_CATEGORY_URI), + ) + assertThat( + "Error code should match", + error.code, + equalTo(WebRequestError.ERROR_DATA_URI_TOO_LONG), + ) + assertThat("URLs should match", uri, equalTo(expectedUri)) + return null + } + }) + + mainSession.load(loader) + sessionRule.waitUntilCalled(NavigationDelegate::class, "onLoadError") + } + + @Test + fun loadLongDataUriToplevelIndirect() { + val dataBytes = ByteArray(3 * 1024 * 1024) + val dataUri = createDataUri(dataBytes, "*/*") + + mainSession.loadTestPath(DATA_URI_PATH) + mainSession.waitForPageStop() + + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return GeckoResult.deny() + } + }) + + mainSession.evaluateJS("document.querySelector('#largeLink').href = \"$dataUri\"") + mainSession.evaluateJS("document.querySelector('#largeLink').click()") + mainSession.waitForPageStop() + } + + @Test + @NullDelegate(NavigationDelegate::class) + fun loadOnBackgroundThreadNullNavigationDelegate() { + thread { + // Make sure we're running in a thread without a Looper. + assertThat( + "We should not have a looper.", + Looper.myLooper(), + equalTo(null), + ) + mainSession.loadTestPath(HELLO_HTML_PATH) + } + + mainSession.waitUntilCalled(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page loaded successfully", success, equalTo(true)) + } + }) + } + + @Test + fun invalidScheme() { + val invalidUri = "tel:#12345678" + mainSession.loadUri(invalidUri) + mainSession.waitUntilCalled(object : NavigationDelegate { + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult? { + assertThat("Uri should match", uri, equalTo(invalidUri)) + assertThat( + "error should match", + error.code, + equalTo(WebRequestError.ERROR_MALFORMED_URI), + ) + assertThat( + "error should match", + error.category, + equalTo(WebRequestError.ERROR_CATEGORY_URI), + ) + return null + } + }) + } + + @Test + fun loadOnBackgroundThread() { + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return GeckoResult.allow() + } + }) + + thread { + // Make sure we're running in a thread without a Looper. + assertThat( + "We should not have a looper.", + Looper.myLooper(), + equalTo(null), + ) + mainSession.loadTestPath(HELLO_HTML_PATH) + } + + mainSession.waitUntilCalled(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page loaded successfully", success, equalTo(true)) + } + }) + } + + @Test + fun loadShortDataUriToplevelIndirect() { + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + @AssertCalled(count = 2) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return GeckoResult.allow() + } + + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }) + + val dataBytes = this.getTestBytes("/assets/www/images/test.gif") + val uri = createDataUri(dataBytes, "image/*") + + mainSession.loadTestPath(DATA_URI_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('#smallLink').href = \"$uri\"") + mainSession.evaluateJS("document.querySelector('#smallLink').click()") + mainSession.waitForPageStop() + } + + fun createLargeHighEntropyImageDataUri(): String { + val desiredMinSize = (2 * 1024 * 1024) + 1 + + val width = 768 + val height = 768 + + val bitmap = Bitmap.createBitmap( + ThreadLocalRandom.current().ints(width.toLong() * height.toLong()).toArray(), + width, + height, + Bitmap.Config.ARGB_8888, + ) + + val stream = ByteArrayOutputStream() + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 0, stream)) { + throw Exception("Error compressing PNG") + } + + val uri = createDataUri(stream.toByteArray(), "image/png") + + if (uri.length < desiredMinSize) { + throw Exception("Test uri is too small, want at least " + desiredMinSize + ", got " + uri.length) + } + + return uri + } + + @Test + fun loadLongDataUriNonToplevel() { + val dataUri = createLargeHighEntropyImageDataUri() + + mainSession.delegateUntilTestEnd(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return GeckoResult.allow() + } + + @AssertCalled(false) + override fun onLoadError( + session: GeckoSession, + uri: String?, + error: WebRequestError, + ): GeckoResult? { + return null + } + }) + + mainSession.loadTestPath(DATA_URI_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('#image').onload = () => { imageLoaded = true; }") + mainSession.evaluateJS("document.querySelector('#image').src = \"$dataUri\"") + UiThreadUtils.waitForCondition({ + mainSession.evaluateJS("document.querySelector('#image').complete") as Boolean + }, sessionRule.env.defaultTimeoutMillis) + mainSession.evaluateJS("if (!imageLoaded) throw imageLoaded") + } + + @Test + fun bypassLoadUriDelegate() { + val testUri = "https://www.mozilla.org" + + mainSession.load( + Loader() + .uri(testUri) + .flags(GeckoSession.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE), + ) + mainSession.waitForPageStop() + + sessionRule.forCallbacksDuringWait( + object : NavigationDelegate { + @AssertCalled(false) + override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { + return null + } + }, + ) + } + + @Test fun goBackFromHistory() { + // TODO: Bug 1673954 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + mainSession.loadTestPath(HELLO_HTML_PATH) + + mainSession.waitUntilCalled(object : HistoryDelegate, ContentDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) { + assertThat("History should have one entry", state.size, equalTo(1)) + } + + @AssertCalled(count = 1) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should match", title, equalTo("Hello, world!")) + } + }) + + mainSession.loadTestPath(HELLO2_HTML_PATH) + + mainSession.waitUntilCalled(object : HistoryDelegate, NavigationDelegate, ContentDelegate { + @AssertCalled(count = 1) + override fun onHistoryStateChange(session: GeckoSession, state: HistoryDelegate.HistoryList) { + assertThat("History should have two entry", state.size, equalTo(2)) + } + + @AssertCalled(count = 1) + override fun onCanGoBack(session: GeckoSession, canGoBack: Boolean) { + assertThat("Can go back", canGoBack, equalTo(true)) + } + + @AssertCalled(count = 1) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should match", title, equalTo("Hello, world! Again!")) + } + }) + + // goBack will be navigated from history. + + var lastTitle: String? = "" + sessionRule.delegateDuringNextWait(object : NavigationDelegate, ContentDelegate { + @AssertCalled(count = 1) + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled + override fun onTitleChange(session: GeckoSession, title: String?) { + lastTitle = title + } + }) + + mainSession.goBack() + sessionRule.waitForPageStop() + assertThat("Title should match", lastTitle, equalTo("Hello, world!")) + } + + @Test + fun loadAndroidAssets() { + val assetUri = "resource://android/assets/web_extensions/" + mainSession.loadUri(assetUri) + + mainSession.waitUntilCalled(object : ProgressDelegate { + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Page loaded successfully", success, equalTo(true)) + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt new file mode 100644 index 0000000000..4fdac68d93 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt @@ -0,0 +1,35 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.equalTo +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate + +@RunWith(AndroidJUnit4::class) +@MediumTest +class NimbusTest : BaseSessionTest() { + + @Test + fun withPdfJS() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + + sessionRule.waitUntilCalled(object : ContentDelegate { + override fun onGetNimbusFeature(session: GeckoSession, featureId: String): JSONObject? { + assertThat( + "Feature id should match", + featureId, + equalTo("pdfjs"), + ) + return null + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt new file mode 100644 index 0000000000..335535bbb4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt @@ -0,0 +1,145 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.not +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.gecko.util.ThreadUtils +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import org.mozilla.geckoview.test.util.UiThreadUtils + +@RunWith(AndroidJUnit4::class) +@MediumTest +class OpenWindowTest : BaseSessionTest() { + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + + // Grant "desktop notification" permission + mainSession.delegateUntilTestEnd(object : PermissionDelegate { + override fun onContentPermissionRequest(session: GeckoSession, perm: PermissionDelegate.ContentPermission): GeckoResult? { + assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION)) + return GeckoResult.fromValue(PermissionDelegate.ContentPermission.VALUE_ALLOW) + } + }) + } + + private fun openPageClickNotification() { + mainSession.loadTestPath(OPEN_WINDOW_PATH) + sessionRule.waitForPageStop() + val result = mainSession.waitForJS("Notification.requestPermission()") + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val notificationResult = GeckoResult() + var notificationShown: WebNotification? = null + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationShown = notification + notificationResult.complete(null) + } + }) + mainSession.evaluateJS("showNotification()") + sessionRule.waitForResult(notificationResult) + notificationShown!!.click() + } + + @Test + @NullDelegate(ServiceWorkerDelegate::class) + fun openWindowNullDelegate() { + sessionRule.delegateUntilTestEnd(object : ContentDelegate, NavigationDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + // we should not open the target url + assertThat("URL should notmatch", url, not(createTestUrl(OPEN_WINDOW_TARGET_PATH))) + } + }) + openPageClickNotification() + UiThreadUtils.loopUntilIdle(sessionRule.env.defaultTimeoutMillis) + } + + @Test + fun openWindowNullResult() { + sessionRule.delegateUntilTestEnd(object : ContentDelegate, NavigationDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + // we should not open the target url + assertThat("URL should notmatch", url, not(createTestUrl(OPEN_WINDOW_TARGET_PATH))) + } + }) + openPageClickNotification() + sessionRule.waitUntilCalled(object : ServiceWorkerDelegate { + @AssertCalled(count = 1) + override fun onOpenWindow(url: String): GeckoResult { + ThreadUtils.assertOnUiThread() + return GeckoResult.fromValue(null) + } + }) + } + + @Test + fun openWindowSameSession() { + sessionRule.delegateUntilTestEnd(object : ServiceWorkerDelegate { + @AssertCalled(count = 1) + override fun onOpenWindow(url: String): GeckoResult { + ThreadUtils.assertOnUiThread() + return GeckoResult.fromValue(mainSession) + } + }) + openPageClickNotification() + sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat("Should be on the main session", session, equalTo(mainSession)) + assertThat("URL should match", url, equalTo(createTestUrl(OPEN_WINDOW_TARGET_PATH))) + } + + @AssertCalled(count = 1, order = [2]) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Should be on the main session", session, equalTo(mainSession)) + assertThat("Title should be correct", title, equalTo("Open Window test target")) + } + }) + } + + @Test + fun openWindowNewSession() { + var targetSession: GeckoSession? = null + sessionRule.delegateUntilTestEnd(object : ServiceWorkerDelegate { + @AssertCalled(count = 1) + override fun onOpenWindow(url: String): GeckoResult { + ThreadUtils.assertOnUiThread() + targetSession = sessionRule.createOpenSession() + return GeckoResult.fromValue(targetSession) + } + }) + openPageClickNotification() + sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat("Should be on the target session", session, equalTo(targetSession)) + assertThat("URL should match", url, equalTo(createTestUrl(OPEN_WINDOW_TARGET_PATH))) + } + + @AssertCalled(count = 1, order = [2]) + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Should be on the target session", session, equalTo(targetSession)) + assertThat("Title should be correct", title, equalTo("Open Window test target")) + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt new file mode 100644 index 0000000000..26ff365659 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt @@ -0,0 +1,311 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.OrientationController +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@RunWith(AndroidJUnit4::class) +@MediumTest +class OrientationDelegateTest : BaseSessionTest() { + val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.screenorientation.allow-lock" to true)) + } + + private fun goFullscreen() { + sessionRule.setPrefsUntilTestEnd(mapOf("full-screen-api.allow-trusted-requests-only" to false)) + mainSession.loadTestPath(FULLSCREEN_PATH) + mainSession.waitForPageStop() + val promise = mainSession.evaluatePromiseJS("document.querySelector('#fullscreen').requestFullscreen()") + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { + assertThat("Div went fullscreen", fullScreen, equalTo(true)) + } + }) + promise.value + } + + private fun lockPortrait() { + val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('portrait-primary')") + sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationLock(aOrientation: Int): GeckoResult { + assertThat( + "The orientation should be portrait", + aOrientation, + equalTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), + ) + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = aOrientation + } + return GeckoResult.allow() + } + }) + sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_PORTRAIT) + promise.value + // Remove previous delegate + mainSession.waitForRoundTrip() + } + + private fun lockLandscape() { + val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('landscape-primary')") + sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationLock(aOrientation: Int): GeckoResult { + assertThat( + "The orientation should be landscape", + aOrientation, + equalTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE), + ) + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = aOrientation + } + return GeckoResult.allow() + } + }) + sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_LANDSCAPE) + promise.value + // Remove previous delegate + mainSession.waitForRoundTrip() + } + + @Test fun orientationLock() { + goFullscreen() + activityRule.scenario.onActivity { activity -> + // If the orientation is landscape, lock to portrait and wait for delegate. If portrait, lock to landscape instead. + if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { + lockPortrait() + } else if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { + lockLandscape() + } + } + } + + @Test fun orientationUnlock() { + goFullscreen() + mainSession.evaluateJS("screen.orientation.unlock()") + sessionRule.waitUntilCalled(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationUnlock() { + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }) + } + + @Test fun orientationLockedAlready() { + goFullscreen() + // Lock to landscape twice to verify successful locking with existing lock + lockLandscape() + lockLandscape() + } + + @Test fun orientationLockedExistingOrientation() { + goFullscreen() + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + if (screen.orientation.type == "landscape-primary") { + resolve(); + } + screen.orientation.addEventListener("change", e => { + if (screen.orientation.type == "landscape-primary") { + resolve(); + } + }, { once: true }); + }) + """.trimIndent(), + ) + + // Lock to landscape twice to verify successful locking to existing orientation + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + // Wait for orientation change by activity.requestedOrientation. + promise.value + lockLandscape() + } + + @Test(expected = GeckoSessionTestRule.RejectedPromiseException::class) + fun orientationLockNoFullscreen() { + // Verify if fullscreen pre-lock conditions are not met, a rejected promise is returned. + mainSession.loadTestPath(FULLSCREEN_PATH) + mainSession.waitForPageStop() + mainSession.evaluateJS("screen.orientation.lock('landscape-primary')") + } + + @Test fun orientationLockUnlock() { + goFullscreen() + + val promise = mainSession.evaluatePromiseJS("screen.orientation.lock('landscape-primary')") + sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationLock(aOrientation: Int): GeckoResult { + assertThat( + "The orientation value is as expected", + aOrientation, + equalTo(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE), + ) + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = aOrientation + } + return GeckoResult.allow() + } + }) + sessionRule.runtime.orientationChanged(Configuration.ORIENTATION_LANDSCAPE) + promise.value + // Remove previous delegate + mainSession.waitForRoundTrip() + + // after locking to orientation landscape, unlock to default + mainSession.evaluateJS("screen.orientation.unlock()") + sessionRule.waitUntilCalled(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationUnlock() { + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }) + } + + @Test fun orientationLockUnsupported() { + // If no delegate, orientation.lock must throws NotSupportedError + goFullscreen() + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(r => { + screen.orientation.lock('landscape-primary') + .then(() => r("successful")) + .catch(e => r(e.name)) + }) + """.trimIndent(), + ) + + assertThat( + "The operation must throw NotSupportedError", + promise.value, + equalTo("NotSupportedError"), + ) + + val promise2 = mainSession.evaluatePromiseJS( + """ + new Promise(r => { + screen.orientation.lock(screen.orientation.type) + .then(() => r("successful")) + .catch(e => r(e.name)) + }) + """.trimIndent(), + ) + + assertThat( + "The operation must throw NotSupportedError even if same orientation", + promise2.value, + equalTo("NotSupportedError"), + ) + } + + @WithDisplay(width = 300, height = 200) + @Test + fun orientationUnlockByExitFullscreen() { + goFullscreen() + activityRule.scenario.onActivity { activity -> + // If the orientation is landscape, lock to portrait and wait for delegate. If portrait, lock to landscape instead. + if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE) { + lockPortrait() + } else if (activity.resources.configuration.orientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) { + lockLandscape() + } + } + + val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()") + sessionRule.waitUntilCalled(object : ContentDelegate, OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { + assertThat("Exited fullscreen", fullScreen, equalTo(false)) + } + + @AssertCalled(count = 1) + override fun onOrientationUnlock() { + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + }) + promise.value + } + + @WithDisplay(width = 200, height = 300) + @Test + fun orientationNatural() { + goFullscreen() + + // Set orientation to landscape since natural is portrait. + var promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + if (screen.orientation.type == "landscape-primary") { + resolve(); + } + screen.orientation.addEventListener("change", e => { + if (screen.orientation.type == "landscape-primary") { + resolve(); + } + }, { once: true }); + }) + """.trimIndent(), + ) + + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE + } + // Wait for orientation change by activity.requestedOrientation. + promise.value + + sessionRule.delegateDuringNextWait(object : OrientationController.OrientationDelegate { + @AssertCalled(count = 1) + override fun onOrientationLock(aOrientation: Int): GeckoResult { + assertThat( + "The orientation should be portrait", + aOrientation, + equalTo(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT), + ) + activityRule.scenario.onActivity { activity -> + activity.requestedOrientation = aOrientation + } + return GeckoResult.allow() + } + }) + promise = mainSession.evaluatePromiseJS("screen.orientation.lock('natural')") + promise.value + // Remove previous delegate + mainSession.waitForRoundTrip() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt new file mode 100644 index 0000000000..e40d047558 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt @@ -0,0 +1,613 @@ +package org.mozilla.geckoview.test + +import android.os.SystemClock +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.PanZoomController +import org.mozilla.geckoview.ScreenLength +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import kotlin.math.roundToInt + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PanZoomControllerTest : BaseSessionTest() { + private val errorEpsilon = 3.0 + private val scrollWaitTimeout = 10000.0 // 10 seconds + + private fun setupDocument(documentPath: String) { + mainSession.loadTestPath(documentPath) + mainSession.waitForPageStop() + mainSession.promiseAllPaintsDone() + mainSession.flushApzRepaints() + } + + private fun setupScroll() { + setupDocument(SCROLL_TEST_PATH) + } + + private fun waitForVisualScroll(offset: Double, timeout: Double, param: String) { + mainSession.evaluateJS( + """ + new Promise((resolve, reject) => { + const start = Date.now(); + function step() { + if (window.visualViewport.$param >= ($offset - $errorEpsilon)) { + resolve(); + } else if ($timeout < (Date.now() - start)) { + reject(); + } else { + window.requestAnimationFrame(step); + } + } + window.requestAnimationFrame(step); + }); + """.trimIndent(), + ) + } + + private fun waitForHorizontalScroll(offset: Double, timeout: Double) { + waitForVisualScroll(offset, timeout, "pageLeft") + } + + private fun waitForVerticalScroll(offset: Double, timeout: Double) { + waitForVisualScroll(offset, timeout, "pageTop") + } + + private fun scrollByVertical(mode: Int) { + setupScroll() + val vh = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height is not zero", vh, greaterThan(0.0)) + mainSession.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh, scrollWaitTimeout) + val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double + assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon)) + } + + private fun scrollByHorizontal(mode: Int) { + setupScroll() + val vw = mainSession.evaluateJS("window.visualViewport.width") as Double + assertThat("Visual viewport width is not zero", vw, greaterThan(0.0)) + mainSession.panZoomController.scrollBy(ScreenLength.fromVisualViewportWidth(1.0), ScreenLength.zero(), mode) + waitForHorizontalScroll(vw, scrollWaitTimeout) + val scrollX = mainSession.evaluateJS("window.visualViewport.pageLeft") as Double + assertThat("scrollBy should have scrolled along x axis one viewport", scrollX, closeTo(vw, errorEpsilon)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByHorizontalSmooth() { + scrollByHorizontal(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByHorizontalAuto() { + scrollByHorizontal(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByVerticalSmooth() { + scrollByVertical(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByVerticalAuto() { + scrollByVertical(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + private fun scrollByVerticalTwice(mode: Int) { + setupScroll() + val vh = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height is not zero", vh, greaterThan(0.0)) + mainSession.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh, scrollWaitTimeout) + mainSession.panZoomController.scrollBy(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh * 2.0, scrollWaitTimeout) + val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double + assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh * 2.0, errorEpsilon)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByVerticalTwiceSmooth() { + scrollByVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollByVerticalTwiceAuto() { + scrollByVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + private fun scrollToVertical(mode: Int) { + setupScroll() + val vh = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height is not zero", vh, greaterThan(0.0)) + mainSession.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh, scrollWaitTimeout) + val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double + assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon)) + } + + private fun scrollToHorizontal(mode: Int) { + setupScroll() + val vw = mainSession.evaluateJS("window.visualViewport.width") as Double + assertThat("Visual viewport width is not zero", vw, greaterThan(0.0)) + mainSession.panZoomController.scrollTo(ScreenLength.fromVisualViewportWidth(1.0), ScreenLength.zero(), mode) + waitForHorizontalScroll(vw, scrollWaitTimeout) + val scrollX = mainSession.evaluateJS("window.visualViewport.pageLeft") as Double + assertThat("scrollBy should have scrolled along x axis one viewport", scrollX, closeTo(vw, errorEpsilon)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToHorizontalSmooth() { + scrollToHorizontal(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToHorizontalAuto() { + scrollToHorizontal(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalSmooth() { + scrollToVertical(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalAuto() { + scrollToVertical(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + private fun scrollToVerticalOnZoomedContent(mode: Int) { + setupScroll() + + val originalVH = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height is not zero", originalVH, greaterThan(0.0)) + + val innerHeight = mainSession.evaluateJS("window.innerHeight") as Double + // Need to round due to dom.InnerSize.rounded=true + assertThat( + "Visual viewport height equals to window.innerHeight", + originalVH.roundToInt(), + equalTo(innerHeight.roundToInt()), + ) + + val originalScale = mainSession.evaluateJS("visualViewport.scale") as Double + assertThat("Visual viewport scale is the initial scale", originalScale, closeTo(0.5, 0.01)) + + // Change the resolution so that the visual viewport will be different from the layout viewport. + mainSession.setResolutionAndScaleTo(2.0f) + + val scale = mainSession.evaluateJS("visualViewport.scale") as Double + assertThat("Visual viewport scale is now greater than the initial scale", scale, greaterThan(originalScale)) + + val vh = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height has been changed", vh, lessThan(originalVH)) + + mainSession.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + + waitForVerticalScroll(vh, scrollWaitTimeout) + val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double + assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalOnZoomedContentSmooth() { + scrollToVerticalOnZoomedContent(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalOnZoomedContentAuto() { + scrollToVerticalOnZoomedContent(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + private fun scrollToVerticalTwice(mode: Int) { + setupScroll() + val vh = mainSession.evaluateJS("window.visualViewport.height") as Double + assertThat("Visual viewport height is not zero", vh, greaterThan(0.0)) + mainSession.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh, scrollWaitTimeout) + mainSession.panZoomController.scrollTo(ScreenLength.zero(), ScreenLength.fromVisualViewportHeight(1.0), mode) + waitForVerticalScroll(vh, scrollWaitTimeout) + val scrollY = mainSession.evaluateJS("window.visualViewport.pageTop") as Double + assertThat("scrollBy should have scrolled along y axis one viewport", scrollY, closeTo(vh, errorEpsilon)) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalTwiceSmooth() { + scrollToVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_SMOOTH) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun scrollToVerticalTwiceAuto() { + scrollToVerticalTwice(PanZoomController.SCROLL_BEHAVIOR_AUTO) + } + + private fun setupTouch() { + setupDocument(TOUCH_HTML_PATH) + } + + private fun sendDownEvent(x: Float, y: Float): GeckoResult { + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + x, + y, + 0, + ) + + val result = mainSession.panZoomController.onTouchEventForDetailResult(down) + .map { value -> value!!.handledResult() } + val up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + x, + y, + 0, + ) + + mainSession.panZoomController.onTouchEvent(up) + + return result + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchEventForResultWithStaticToolbar() { + setupTouch() + + // Non-scrollable page: value is always INPUT_RESULT_UNHANDLED + + // No touch handler + var value = sessionRule.waitForResult(sendDownEvent(50f, 15f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED)) + + // Touch handler with preventDefault + value = sessionRule.waitForResult(sendDownEvent(50f, 45f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT)) + + // Touch handler without preventDefault + value = sessionRule.waitForResult(sendDownEvent(50f, 75f)) + // Nothing should have done in the event handler and the content is not scrollable, + // thus the input result should be UNHANDLED, i.e. the dynamic toolbar should NOT + // move in response to the event. + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_UNHANDLED)) + + // Scrollable page: value depends on the presence and type of touch handler + setupScroll() + + // No touch handler + value = sessionRule.waitForResult(sendDownEvent(50f, 15f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED)) + + // Touch handler with preventDefault + value = sessionRule.waitForResult(sendDownEvent(50f, 45f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT)) + + // Touch handler without preventDefault + value = sessionRule.waitForResult(sendDownEvent(50f, 75f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED)) + } + + private fun setupTouchEventDocument(documentPath: String, withEventHandler: Boolean) { + setupDocument(documentPath + if (withEventHandler) "?event" else "") + } + + private fun waitForScroll(timeout: Double) { + mainSession.evaluateJS( + """ + const targetWindow = document.querySelector('iframe') ? + document.querySelector('iframe').contentWindow : window; + new Promise((resolve, reject) => { + const start = Date.now(); + function step() { + if (targetWindow.scrollY == targetWindow.scrollMaxY) { + resolve(); + } else if ($timeout < (Date.now() - start)) { + reject(); + } else { + window.requestAnimationFrame(step); + } + } + window.requestAnimationFrame(step); + }); + """.trimIndent(), + ) + } + + private fun testTouchEventForResult(withEventHandler: Boolean) { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + // The content height is not greater than "screen height - the dynamic toolbar height". + setupTouchEventDocument(ROOT_100_PERCENT_HEIGHT_HTML_PATH, withEventHandler) + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be UNHANDLED in root_100_percent.html", + value, + equalTo(PanZoomController.INPUT_RESULT_UNHANDLED), + ) + + // There is a 100% height iframe which is not scrollable. + setupTouchEventDocument(IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // The input result should NOT be handled in the iframe content, + // should NOT be handled in the root either. + assertThat( + "The input result should be UNHANDLED in iframe_100_percent_height_no_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_UNHANDLED), + ) + + // There is a 100% height iframe which is scrollable. + setupTouchEventDocument(IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // The input result should be handled in the iframe content. + assertThat( + "The input result should be HANDLED_CONTENT in iframe_100_percent_height_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // Scroll to the bottom of the iframe + mainSession.evaluateJS( + """ + const iframe = document.querySelector('iframe'); + iframe.contentWindow.scrollTo({ + left: 0, + top: iframe.contentWindow.scrollMaxY, + behavior: 'instant' + }); + """.trimIndent(), + ) + waitForScroll(scrollWaitTimeout) + mainSession.flushApzRepaints() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // The input result should still be handled in the iframe content. + assertThat( + "The input result should be HANDLED_CONTENT in iframe_100_percent_height_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // The content height is greater than "screen height - the dynamic toolbar height". + setupTouchEventDocument(ROOT_98VH_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be HANDLED in root_98vh.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + + // The content height is equal to "screen height". + setupTouchEventDocument(ROOT_100VH_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be HANDLED in root_100vh.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + + // There is a 98vh iframe which is not scrollable. + setupTouchEventDocument(IFRAME_98VH_NO_SCROLLABLE_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // The input result should NOT be handled in the iframe content. + assertThat( + "The input result should be HANDLED in iframe_98vh_no_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + + // There is a 98vh iframe which is scrollable. + setupTouchEventDocument(IFRAME_98VH_SCROLLABLE_HTML_PATH, withEventHandler) + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // The input result should be handled in the iframe content initially. + assertThat( + "The input result should be HANDLED_CONTENT initially in iframe_98vh_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // Scroll to the bottom of the iframe + mainSession.evaluateJS( + """ + const iframe = document.querySelector('iframe'); + iframe.contentWindow.scrollTo({ + left: 0, + top: iframe.contentWindow.scrollMaxY, + behavior: 'instant' + }); + """.trimIndent(), + ) + waitForScroll(scrollWaitTimeout) + mainSession.flushApzRepaints() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + // Now the input result should be handled in the root APZC. + assertThat( + "The input result should be HANDLED in iframe_98vh_scrollable.html", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchEventForResultWithEventHandler() { + testTouchEventForResult(true) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchEventForResultWithoutEventHandler() { + testTouchEventForResult(false) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchEventForResultWithPreventDefault() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + // Entries are pairs of (filename, pageIsPannable) + // Note: "pageIsPannable" means "pannable" in the sense used in + // AsyncPanZoomController::ArePointerEventsConsumable(). + // For example, in iframe_98vh_no_scrollable.html, even though + // the page does not have a scroll range, the page is "pannable" + // because the dynamic toolbar can be hidden. + var files = arrayOf( + ROOT_100_PERCENT_HEIGHT_HTML_PATH, + ROOT_98VH_HTML_PATH, + ROOT_100VH_HTML_PATH, + IFRAME_100_PERCENT_HEIGHT_NO_SCROLLABLE_HTML_PATH, + IFRAME_100_PERCENT_HEIGHT_SCROLLABLE_HTML_PATH, + IFRAME_98VH_SCROLLABLE_HTML_PATH, + IFRAME_98VH_NO_SCROLLABLE_HTML_PATH, + ) + for (file in files) { + setupDocument(file + "?event-prevent") + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be HANDLED_CONTENT in " + file, + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // Scroll to the bottom edge if it's possible. + mainSession.evaluateJS( + """ + const targetWindow = document.querySelector('iframe') ? + document.querySelector('iframe').contentWindow : window; + targetWindow.scrollTo({ + left: 0, + top: targetWindow.scrollMaxY, + behavior: 'instant' + }); + """.trimIndent(), + ) + waitForScroll(scrollWaitTimeout) + mainSession.flushApzRepaints() + + value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be HANDLED_CONTENT in " + file, + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + } + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchActionWithWheelListener() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + setupDocument(TOUCH_ACTION_WHEEL_LISTENER_HTML_PATH) + var value = sessionRule.waitForResult(sendDownEvent(50f, 50f)) + assertThat( + "The input result should be HANDLED_CONTENT", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + } + + private fun fling(): GeckoResult { + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 50f, + 90f, + 0, + ) + + val result = mainSession.panZoomController.onTouchEventForDetailResult(down) + .map { value -> value!!.handledResult() } + var move = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + 50f, + 70f, + 0, + ) + mainSession.panZoomController.onTouchEvent(move) + move = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + 50f, + 30f, + 0, + ) + mainSession.panZoomController.onTouchEvent(move) + + val up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + 50f, + 10f, + 0, + ) + mainSession.panZoomController.onTouchEvent(up) + return result + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dontCrashDuringFastFling() { + setupDocument(TOUCHSTART_HTML_PATH) + + fling() + fling() + } + + @WithDisplay(width = 100, height = 100) + @Test + fun inputResultForFastFling() { + setupDocument(TOUCHSTART_HTML_PATH) + + var value = sessionRule.waitForResult(fling()) + assertThat( + "The initial input result should be HANDLED", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + // Trigger the next fling during the initial scrolling. + value = sessionRule.waitForResult(fling()) + assertThat( + "The input result should be IGNORED during the fast fling", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun touchEventWithXOrigin() { + setupDocument(TOUCH_XORIGIN_HTML_PATH) + + // Touch handler with preventDefault + val value = sessionRule.waitForResult(sendDownEvent(50f, 45f)) + assertThat("Value should match", value, equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT)) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt new file mode 100644 index 0000000000..9693139d9c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt @@ -0,0 +1,159 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Color.rgb +import android.graphics.pdf.PdfRenderer +import android.os.ParcelFileDescriptor +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.hamcrest.Matchers.equalTo +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoViewPrintDocumentAdapter +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import java.io.File +import java.io.InputStream +import kotlin.math.roundToInt + +@RunWith(AndroidJUnit4::class) +@LargeTest +class PdfCreationTest : BaseSessionTest() { + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + var deviceHeight = 0 + var deviceWidth = 0 + var scaledHeight = 0 + var scaledWidth = 12 + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + activityRule.scenario.onActivity { + it.view.setSession(mainSession) + deviceHeight = it.resources.displayMetrics.heightPixels + deviceWidth = it.resources.displayMetrics.widthPixels + scaledHeight = (scaledWidth * (deviceHeight / deviceWidth.toDouble())).roundToInt() + } + } + + @After + fun cleanup() { + activityRule.scenario.onActivity { + it.view.releaseSession() + } + } + + private fun createFileDescriptor(pdfInputStream: InputStream): ParcelFileDescriptor { + val file = File.createTempFile("temp", null) + pdfInputStream.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + private fun pdfToBitmap(pdfInputStream: InputStream): ArrayList? { + val bitmaps: ArrayList = ArrayList() + try { + val pdfRenderer = PdfRenderer(createFileDescriptor(pdfInputStream)) + for (pageNo in 0 until pdfRenderer.pageCount) { + val page = pdfRenderer.openPage(pageNo) + var bitmap = Bitmap.createBitmap(deviceWidth, deviceHeight, Bitmap.Config.ARGB_8888) + page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY) + bitmaps.add(bitmap) + page.close() + } + pdfRenderer.close() + } catch (e: Exception) { + e.printStackTrace() + } + return bitmaps + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun singleColorPdf() { + activityRule.scenario.onActivity { + mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH) + mainSession.waitForPageStop() + val pdfInputStream = mainSession.saveAsPdf() + sessionRule.waitForResult(pdfInputStream).let { + val bitmap = pdfToBitmap(it)!![0] + val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false) + val centerPixel = scaled.getPixel(scaledWidth / 2, scaledHeight / 2) + val orange = rgb(255, 113, 57) + assertTrue("The PDF orange color matches.", centerPixel == orange) + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun rgbColorsPdf() { + activityRule.scenario.onActivity { + mainSession.loadTestPath(COLOR_GRID_HTML_PATH) + mainSession.waitForPageStop() + val pdfInputStream = mainSession.saveAsPdf() + sessionRule.waitForResult(pdfInputStream).let { + val bitmap = pdfToBitmap(it)!![0] + val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false) + val redPixel = scaled.getPixel(2, scaledHeight / 2) + assertTrue("The PDF red color matches.", redPixel == Color.RED) + val greenPixel = scaled.getPixel(scaledWidth / 2, scaledHeight / 2) + assertTrue("The PDF green color matches.", greenPixel == Color.GREEN) + val bluePixel = scaled.getPixel(scaledWidth - 2, scaledHeight / 2) + assertTrue("The PDF blue color matches.", bluePixel == Color.BLUE) + val doPixelsMatch = ( + redPixel == Color.RED && + greenPixel == Color.GREEN && + bluePixel == Color.BLUE + ) + assertTrue("The PDF generated RGB colors.", doPixelsMatch) + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun makeTempPdfFileTest() { + activityRule.scenario.onActivity { activity -> + mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH) + mainSession.waitForPageStop() + val pdfInputStream = mainSession.saveAsPdf() + sessionRule.waitForResult(pdfInputStream).let { stream -> + val file = GeckoViewPrintDocumentAdapter.makeTempPdfFile(stream, activity)!! + assertTrue("PDF File exists.", file.exists()) + assertTrue("PDF File is not empty.", file.length() > 0L) + file.delete() + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun saveAPdfDocument() { + activityRule.scenario.onActivity { + mainSession.loadTestPath(HELLO_PDF_WORLD_PDF_PATH) + mainSession.waitForPageStop() + val pdfInputStream = mainSession.saveAsPdf() + val originalBytes = getTestBytes(HELLO_PDF_WORLD_PDF_PATH) + sessionRule.waitForResult(pdfInputStream).let { + assertThat("The PDF File must the same as the original one.", it!!.readBytes(), equalTo(originalBytes)) + } + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt new file mode 100644 index 0000000000..e0211dd07c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt @@ -0,0 +1,30 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PdfSaveTest : BaseSessionTest() { + + @Test fun savePdf() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + mainSession.waitForPageStop() + + val response = sessionRule.waitForResult(mainSession.pdfFileSaver.save()) + val originalBytes = getTestBytes(TRACEMONKEY_PDF_PATH) + val filename = TRACEMONKEY_PDF_PATH.substringAfterLast("/") + + assertThat("Check the response uri.", response.uri.substringAfterLast("/"), equalTo(filename)) + assertThat("Check the response content-type.", response.headers.get("content-type"), equalTo("application/pdf")) + assertThat("Check the response filename.", response.headers.get("Content-disposition"), equalTo("attachment; filename=\"" + filename + "\"")) + assertThat("Check that bytes arrays are the same.", response.body?.readBytes(), equalTo(originalBytes)) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt new file mode 100644 index 0000000000..9ab2d2515f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt @@ -0,0 +1,1132 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONArray +import org.junit.Assert.fail +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaCallback +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.StorageController.ClearFlags +import org.mozilla.geckoview.test.TrackingPermissionService.TrackingPermissionInstance +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PermissionDelegateTest : BaseSessionTest() { + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun hasPermission(permission: String): Boolean { + if (Build.VERSION.SDK_INT < 23) { + return true + } + return PackageManager.PERMISSION_GRANTED == + InstrumentationRegistry.getInstrumentation().targetContext.checkSelfPermission(permission) + } + + private fun isEmulator(): Boolean { + return "generic" == Build.DEVICE || Build.DEVICE.startsWith("generic_") + } + + private val storageController + get() = sessionRule.runtime.storageController + + @Test fun media() { + // TODO: needs bug 1700243 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + assertInAutomationThat( + "Should have camera permission", + hasPermission(Manifest.permission.CAMERA), + equalTo(true), + ) + + assertInAutomationThat( + "Should have microphone permission", + hasPermission(Manifest.permission.RECORD_AUDIO), + equalTo(true), + ) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val devices = mainSession.evaluateJS( + "window.navigator.mediaDevices.enumerateDevices()", + ) as JSONArray + + var hasVideo = false + var hasAudio = false + for (i in 0 until devices.length()) { + if (devices.getJSONObject(i).getString("kind") == "videoinput") { + hasVideo = true + } + if (devices.getJSONObject(i).getString("kind") == "audioinput") { + hasAudio = true + } + } + + assertThat( + "Device list should contain camera device", + hasVideo, + equalTo(true), + ) + assertThat( + "Device list should contain microphone device", + hasAudio, + equalTo(true), + ) + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array?, + audio: Array?, + callback: MediaCallback, + ) { + assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH)) + assertThat("Video source should be valid", video, not(emptyArray())) + + if (isEmulator()) { + callback.grant(video!![0], null) + } else { + assertThat("Audio source should be valid", audio, not(emptyArray())) + callback.grant(video!![0], audio!![0]) + } + } + }) + + // Start a video stream, with audio if on a real device. + val code = if (isEmulator()) { + """this.stream = window.navigator.mediaDevices.getUserMedia({ + video: { width: 320, height: 240, frameRate: 10 }, + });""" + } else { + """this.stream = window.navigator.mediaDevices.getUserMedia({ + video: { width: 320, height: 240, frameRate: 10 }, + audio: true + });""" + } + + // Stop the stream and check active flag and id + val isActive = mainSession.waitForJS( + """$code + this.stream.then(stream => { + if (!stream.active || stream.id == '') { + return false; + } + + stream.getTracks().forEach(track => track.stop()); + return true; + }) + """.trimMargin(), + ) as Boolean + + assertThat("Stream should be active and id should not be empty.", isActive, equalTo(true)) + + // Now test rejecting the request. + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onMediaPermissionRequest( + session: GeckoSession, + uri: String, + video: Array?, + audio: Array?, + callback: MediaCallback, + ) { + callback.reject() + } + }) + + try { + if (isEmulator()) { + mainSession.waitForJS( + """ + window.navigator.mediaDevices.getUserMedia({ video: true })""", + ) + } else { + mainSession.waitForJS( + """ + window.navigator.mediaDevices.getUserMedia({ audio: true, video: true })""", + ) + } + fail("Request should have failed") + } catch (e: RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("NotAllowedError"), + ) + } + } + + @Test fun geolocation() { + assertInAutomationThat( + "Should have location permission", + hasPermission(Manifest.permission.ACCESS_FINE_LOCATION), + equalTo(true), + ) + + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + // Set location for test + sessionRule.setPrefsUntilTestEnd(mapOf("geo.provider.testing" to false)) + var context = InstrumentationRegistry.getInstrumentation().targetContext + var locManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + var locProvider = sessionRule.MockLocationProvider( + locManager, + "permissionsLocationProvider", + 1.1111, + 2.2222, + false, + ) + locProvider.postLocation() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + // Ensure the content permission is asked first, before the Android permission. + @AssertCalled(count = 1, order = [1]) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_GEOLOCATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + + @AssertCalled(count = 1, order = [2]) + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: PermissionDelegate.Callback, + ) { + assertThat( + "Permissions list should be correct", + listOf(*permissions!!), + hasItems(Manifest.permission.ACCESS_FINE_LOCATION), + ) + callback.grant() + } + }) + + try { + val hasPosition = mainSession.waitForJS( + """new Promise((resolve, reject) => + window.navigator.geolocation.getCurrentPosition( + position => resolve( + position.coords.latitude !== undefined && + position.coords.longitude !== undefined), + error => reject(error.code)))""", + ) as Boolean + + assertThat("Request should succeed", hasPosition, equalTo(true)) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error should not because the permission was denied.", + ex.reason as String, + not("1"), + ) + } + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound = true + } + } + + assertThat("Geolocation permission should be set to allow", permFound, equalTo(true)) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION && + perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound2 = true + } + } + assertThat("Geolocation permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + locProvider.removeMockLocationProvider() + } + + @Test fun geolocation_reject() { + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + return GeckoResult.fromValue(ContentPermission.VALUE_DENY) + } + + @AssertCalled(count = 0) + override fun onAndroidPermissionsRequest( + session: GeckoSession, + permissions: Array?, + callback: PermissionDelegate.Callback, + ) { + } + }) + + val errorCode = mainSession.waitForJS( + """new Promise((resolve, reject) => + window.navigator.geolocation.getCurrentPosition(reject, + error => resolve(error.code) + ))""", + ) + + // Error code 1 means permission denied. + assertThat("Request should fail", errorCode as Double, equalTo(1.0)) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY + ) { + permFound = true + } + } + + assertThat("Geolocation permission should be set to allow", permFound, equalTo(true)) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_GEOLOCATION && + perm.value == ContentPermission.VALUE_DENY + ) { + permFound2 = true + } + } + assertThat("Geolocation permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + } + + @ClosedSessionAtStart + @Test + fun trackingProtection() { + // Tests that we get a tracking protection permission for every load, we + // can set the value of the permission and that the permission persists + // across sessions + trackingProtection(privateBrowsing = false, permanent = true) + } + + @ClosedSessionAtStart + @Test + fun trackingProtectionPrivateBrowsing() { + // Tests that we get a tracking protection permission for every load, we + // can set the value of the permission in private browsing and that the + // permission does not persists across private sessions + trackingProtection(privateBrowsing = true, permanent = false) + } + + @ClosedSessionAtStart + @Test + fun trackingProtectionPrivateBrowsingPermanent() { + // Tests that we get a tracking protection permission for every load, we + // can set the value of the permission permanently in private browsing + // and that the permanent permission _does_ persists across private sessions + trackingProtection(privateBrowsing = true, permanent = true) + } + + private fun trackingProtection(privateBrowsing: Boolean, permanent: Boolean) { + // Make sure we start with a clean slate + storageController.clearDataFromHost(TEST_HOST, ClearFlags.PERMISSIONS) + + assertThat( + "Non-permanent only makes sense with private browsing " + + "(because non-private browsing exceptions are always permanent", + permanent || privateBrowsing, + equalTo(true), + ) + + val runtime0 = TrackingPermissionInstance.start( + targetContext, + temporaryProfile.get(), + privateBrowsing, + ) + + sessionRule.waitForResult(runtime0.loadTestPath(TRACKERS_PATH)) + var permission = sessionRule.waitForResult(runtime0.trackingPermission) + + assertThat( + "Permission value should start at DENY", + permission, + equalTo(ContentPermission.VALUE_DENY), + ) + + if (privateBrowsing && permanent) { + runtime0.setPrivateBrowsingPermanentTrackingPermission( + ContentPermission.VALUE_ALLOW, + ) + } else { + runtime0.setTrackingPermission(ContentPermission.VALUE_ALLOW) + } + + sessionRule.waitForResult(runtime0.reload()) + + permission = sessionRule.waitForResult(runtime0.trackingPermission) + assertThat( + "Permission value should be ALLOW after setting", + permission, + equalTo(ContentPermission.VALUE_ALLOW), + ) + + sessionRule.waitForResult(runtime0.quit()) + + // Restart the runtime and verifies that the value is still stored + val runtime1 = TrackingPermissionInstance.start( + targetContext, + temporaryProfile.get(), + privateBrowsing, + ) + + sessionRule.waitForResult(runtime1.loadTestPath(TRACKERS_PATH)) + + val trackingPermission = sessionRule.waitForResult(runtime1.trackingPermission) + assertThat( + "Tracking permissions should persist only if permanent", + trackingPermission, + equalTo( + when { + permanent -> ContentPermission.VALUE_ALLOW + else -> ContentPermission.VALUE_DENY + }, + ), + ) + + sessionRule.waitForResult(runtime1.quit()) + } + + private fun assertTrackingProtectionPermission(value: Int?) { + var found = false + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) { + if (value != null) { + assertThat( + "Value should match", + perm.value, + equalTo(value), + ) + } + found = true + } + } + } + }) + + assertThat( + "Permission should have been found if expected", + found, + equalTo(value != null), + ) + } + + // Tests that all pages have a PERMISSION_TRACKING permission, + // except for pages that belong to Gecko like about:blank or about:config. + @Test fun trackingProtectionPermissionOnAllPages() { + val settings = sessionRule.runtime.settings + val aboutConfigEnabled = settings.aboutConfigEnabled + settings.aboutConfigEnabled = true + + mainSession.loadUri("about:config") + assertTrackingProtectionPermission(null) + + settings.aboutConfigEnabled = aboutConfigEnabled + + mainSession.loadUri("about:blank") + assertTrackingProtectionPermission(null) + + mainSession.loadTestPath(HELLO_HTML_PATH) + assertTrackingProtectionPermission(ContentPermission.VALUE_DENY) + } + + @Test fun notification() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + + val result2 = mainSession.waitForJS("Notification.permission") + + assertThat( + "Permission should be granted", + result2 as String, + equalTo("granted"), + ) + } + + @Ignore("disable test for frequently failing Bug 1542525") + @Test + fun notification_reject() { + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + return GeckoResult.fromValue(ContentPermission.VALUE_DENY) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should not be granted", + result as String, + equalTo("denied"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY + ) { + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_DENY + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + } + + @Test + fun autoplayReject() { + // Bug 1810736 + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + + // The profile used in automation sets this to false, so we need to hack it back to true here. + sessionRule.setPrefsUntilTestEnd( + mapOf( + "media.geckoview.autoplay.request" to true, + ), + ) + + mainSession.loadTestPath(AUTOPLAY_PATH) + + mainSession.waitUntilCalled(object : PermissionDelegate { + @AssertCalled(count = 2) + override fun onContentPermissionRequest(session: GeckoSession, perm: ContentPermission): + GeckoResult { + val expectedType = if (sessionRule.currentCall.counter == 1) PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE else PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE + assertThat("Type should match", perm.permission, equalTo(expectedType)) + return GeckoResult.fromValue(ContentPermission.VALUE_DENY) + } + }) + } + + @Test + fun contextId() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + assertThat("Context ID should match", perm.contextId, equalTo(mainSession.settings.contextId)) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url, false)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + + val session2 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder() + .contextId("foo") + .build(), + ) + + session2.loadUri(url) + session2.waitForPageStop() + + session2.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + assertThat( + "Context ID should match", + perm.contextId, + equalTo(session2.settings.contextId), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result2 = session2.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result2 as String, + equalTo("granted"), + ) + + val perms2 = sessionRule.waitForResult(storageController.getPermissions(url, false)) + + assertThat("Permissions should not be null", perms, notNullValue()) + permFound = false + for (perm in perms2) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + session2.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_ALLOW && + perm.contextId == session2.settings.contextId + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + session2.reload() + session2.waitForPageStop() + } + + @Test fun setPermissionAllow() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_DENY) + } + }) + mainSession.waitForJS("Notification.requestPermission()") + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + var notificationPerm: ContentPermission? = null + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_DENY + ) { + notificationPerm = perm + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + storageController.setPermission( + notificationPerm!!, + ContentPermission.VALUE_ALLOW, + ) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_ALLOW + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + + val result = mainSession.waitForJS("Notification.permission") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + } + + @Test fun setPermissionDeny() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + var notificationPerm: ContentPermission? = null + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + notificationPerm = perm + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + storageController.setPermission( + notificationPerm!!, + ContentPermission.VALUE_DENY, + ) + + mainSession.delegateDuringNextWait(object : NavigationDelegate { + @AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + var permFound2 = false + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + perm.value == ContentPermission.VALUE_DENY + ) { + permFound2 = true + } + } + assertThat("Notification permission must be present on refresh", permFound2, equalTo(true)) + } + }) + mainSession.reload() + mainSession.waitForPageStop() + + val result2 = mainSession.waitForJS("Notification.permission") + + assertThat( + "Permission should be denied", + result2 as String, + equalTo("denied"), + ) + } + + @Test fun setPermissionPrompt() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + var notificationPerm: ContentPermission? = null + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + notificationPerm = perm + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + storageController.setPermission( + notificationPerm!!, + ContentPermission.VALUE_PROMPT, + ) + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT) + } + }) + + val result2 = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be default", + result2 as String, + equalTo("default"), + ) + } + + @Test fun permissionJsonConversion() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + val url = createTestUrl(HELLO_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + mainSession.delegateDuringNextWait(object : PermissionDelegate { + @AssertCalled(count = 1) + override fun onContentPermissionRequest( + session: GeckoSession, + perm: ContentPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, endsWith(url)) + assertThat( + "Type should match", + perm.permission, + equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION), + ) + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + + val perms = sessionRule.waitForResult(storageController.getPermissions(url)) + + assertThat("Permissions should not be null", perms, notNullValue()) + var permFound = false + var notificationPerm: ContentPermission? = null + for (perm in perms) { + if (perm.permission == PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION && + url.startsWith(perm.uri) && perm.value == ContentPermission.VALUE_ALLOW + ) { + notificationPerm = perm + permFound = true + } + } + + assertThat("Notification permission should be set to allow", permFound, equalTo(true)) + + val jsonPerm = notificationPerm?.toJson() + assertThat("JSON export should not be null", jsonPerm, notNullValue()) + + val importedPerm = ContentPermission.fromJson(jsonPerm!!) + assertThat("JSON import should not be null", importedPerm, notNullValue()) + + assertThat("URIs should match", importedPerm?.uri, equalTo(notificationPerm?.uri)) + assertThat("Types should match", importedPerm?.permission, equalTo(notificationPerm?.permission)) + assertThat("Values should match", importedPerm?.value, equalTo(notificationPerm?.value)) + assertThat("Context IDs should match", importedPerm?.contextId, equalTo(notificationPerm?.contextId)) + assertThat("Private mode should match", importedPerm?.privateMode, equalTo(notificationPerm?.privateMode)) + } + + // @Test fun persistentStorage() { + // mainSession.loadTestPath(HELLO_HTML_PATH) + // mainSession.waitForPageStop() + + // // Persistent storage can be rejected + // mainSession.delegateDuringNextWait(object : PermissionDelegate { + // @AssertCalled(count = 1) + // override fun onContentPermissionRequest( + // session: GeckoSession, uri: String?, type: Int, + // callback: PermissionDelegate.Callback) { + // callback.reject() + // } + // }) + + // var success = mainSession.waitForJS("""window.navigator.storage.persist()""") + + // assertThat("Request should fail", + // success as Boolean, equalTo(false)) + + // // Persistent storage can be granted + // mainSession.delegateDuringNextWait(object : PermissionDelegate { + // // Ensure the content permission is asked first, before the Android permission. + // @AssertCalled(count = 1, order = [1]) + // override fun onContentPermissionRequest( + // session: GeckoSession, uri: String?, type: Int, + // callback: PermissionDelegate.Callback) { + // assertThat("URI should match", uri, endsWith(HELLO_HTML_PATH)) + // assertThat("Type should match", type, + // equalTo(PermissionDelegate.PERMISSION_PERSISTENT_STORAGE)) + // callback.grant() + // } + // }) + + // success = mainSession.waitForJS("""window.navigator.storage.persist()""") + + // assertThat("Request should succeed", + // success as Boolean, + // equalTo(true)) + + // // after permission granted further requests will always return true, regardless of response + // mainSession.delegateDuringNextWait(object : PermissionDelegate { + // @AssertCalled(count = 1) + // override fun onContentPermissionRequest( + // session: GeckoSession, uri: String?, type: Int, + // callback: PermissionDelegate.Callback) { + // callback.reject() + // } + // }) + + // success = mainSession.waitForJS("""window.navigator.storage.persist()""") + + // assertThat("Request should succeed", + // success as Boolean, equalTo(true)) + // } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt new file mode 100644 index 0000000000..2e9bc8f135 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt @@ -0,0 +1,255 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.app.UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Color.rgb +import android.os.Handler +import android.os.Looper +import android.view.accessibility.AccessibilityEvent.TYPE_VIEW_SCROLLED +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.CoreMatchers.containsString +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mozilla.geckoview.Autofill +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PrintDelegate +import org.mozilla.geckoview.GeckoView.ActivityContextDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import kotlin.math.roundToInt + +@RunWith(AndroidJUnit4::class) +@LargeTest +class PrintDelegateTest : BaseSessionTest() { + private val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + private var deviceHeight = 0 + private var deviceWidth = 0 + private var scaledHeight = 0 + private var scaledWidth = 12 + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val uiAutomation = instrumentation.getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + @Before + fun setup() { + activityRule.scenario.onActivity { + class PrintTestActivityDelegate : ActivityContextDelegate { + override fun getActivityContext(): Context { + return it + } + } + // An activity delegate is required for printing + it.view.activityContextDelegate = PrintTestActivityDelegate() + deviceHeight = it.resources.displayMetrics.heightPixels + deviceWidth = it.resources.displayMetrics.widthPixels + scaledHeight = (scaledWidth * (deviceHeight / deviceWidth.toDouble())).roundToInt() + } + } + + @After + fun cleanup() { + activityRule.scenario.onActivity { + uiAutomation.setOnAccessibilityEventListener {} + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun printDelegateTest() { + activityRule.scenario.onActivity { + var delegateCalled = 0 + sessionRule.delegateUntilTestEnd(object : PrintDelegate { + @AssertCalled(count = 1) + override fun onPrint(session: GeckoSession) { + delegateCalled++ + } + }) + mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH) + mainSession.waitForPageStop() + mainSession.printPageContent() + assertTrue("Android print delegate called once.", delegateCalled == 1) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun windowDotPrintAvailableTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { + mainSession.loadTestPath(COLOR_ORANGE_BACKGROUND_HTML_PATH) + mainSession.waitForPageStop() + val response = mainSession.waitForJS("window.print();") + assertTrue("Window.print(); is available.", response == null) + } + } + + // Returns the center pixel color of the the print preview's screenshot + private fun printCenterPixelColor(): GeckoResult { + val pixelResult = GeckoResult() + // Listening for Android Print Activity + uiAutomation.setOnAccessibilityEventListener { event -> + if (event.packageName == "com.android.printspooler" && + event.eventType == TYPE_VIEW_SCROLLED + ) { + uiAutomation.setOnAccessibilityEventListener {} + // Delaying the screenshot to give time for preview to load + Handler(Looper.getMainLooper()).postDelayed({ + val bitmap = uiAutomation.takeScreenshot() + val scaled = Bitmap.createScaledBitmap(bitmap, scaledWidth, scaledHeight, false) + pixelResult.complete(scaled.getPixel(scaledWidth / 2, scaledHeight / 2)) + }, 1500) + } + } + return pixelResult + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun printPreviewRendered() { + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(PRINT_CONTENT_CHANGE) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.printPageContent() + val orange = rgb(255, 113, 57) + val centerPixel = printCenterPixelColor() + assertTrue( + "Android print opened and rendered.", + sessionRule.waitForResult(centerPixel) == orange, + ) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun basicWindowDotPrintTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(PRINT_CONTENT_CHANGE) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.evaluateJS("window.print();") + val centerPixel = printCenterPixelColor() + val orange = rgb(255, 113, 57) + assertTrue( + "Android print opened and rendered.", + sessionRule.waitForResult(centerPixel) == orange, + ) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun statusWindowDotPrintTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(PRINT_CONTENT_CHANGE) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.evaluateJS("window.print()") + val centerPixel = printCenterPixelColor() + val orange = rgb(255, 113, 57) + assertTrue( + "Android print opened and rendered.", + sessionRule.waitForResult(centerPixel) == orange, + ) + var didCatch = false + try { + mainSession.evaluateJS("window.print();") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Print status context reported.", + e.message, + containsString("Window.print: No browsing context"), + ) + didCatch = true + } + assertTrue("Did show print status.", didCatch) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun staticContextWindowDotPrintTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + // Print button removes content after printing to test if it froze a static page for printing + mainSession.loadTestPath(PRINT_CONTENT_CHANGE) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.evaluateJS("document.getElementById('print-button').click();") + val centerPixel = printCenterPixelColor() + val orange = rgb(255, 113, 57) + assertTrue( + "Android print opened and rendered static page.", + sessionRule.waitForResult(centerPixel) == orange, + ) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun iframeWindowDotPrintTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { activity -> + // Main frame CSS rules render red on screen and green on print + // iframe CSS rules render blue on screen and orange on print + mainSession.loadTestPath(PRINT_IFRAME) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + // iframe window.print button + mainSession.evaluateJS("document.getElementById('iframe').contentDocument.getElementById('print-button').click();") + val centerPixelIframe = printCenterPixelColor() + val orange = rgb(255, 113, 57) + sessionRule.waitForResult(centerPixelIframe).let { it -> + assertTrue("The iframe should not print green. (Printed containing page instead of iframe.)", it != Color.GREEN) + assertTrue("Printed the iframe correctly.", it == orange) + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun contentIframeWindowDotPrintTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.enable_window_print" to true)) + activityRule.scenario.onActivity { activity -> + // Main frame CSS rules render red on screen and green on print + // iframe CSS rules render blue on screen and orange on print + mainSession.loadTestPath(PRINT_IFRAME) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + // Main page window.print button + mainSession.evaluateJS("document.getElementById('print-button-page').click();") + val centerPixelContent = printCenterPixelColor() + assertTrue("Printed the main content correctly.", sessionRule.waitForResult(centerPixelContent) == Color.GREEN) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt new file mode 100644 index 0000000000..7df55b1ccb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt @@ -0,0 +1,105 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSessionSettings + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PrivateModeTest : BaseSessionTest() { + @Test + fun privateDataNotShared() { + mainSession.loadUri("https://example.com") + mainSession.waitForPageStop() + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'regular'); + """, + ) + + val privateSession = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build(), + ) + privateSession.loadUri("https://example.com") + privateSession.waitForPageStop() + var localStorage = privateSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + // Ensure that the regular session's data hasn't leaked into the private session. + assertThat( + "Private mode local storage value should be empty", + localStorage, + Matchers.equalTo("null"), + ) + + privateSession.evaluateJS( + """ + localStorage.setItem('ctx', 'private'); + """, + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + // Conversely, ensure private data hasn't leaked into the regular session. + assertThat( + "Regular mode storage value should be unchanged", + localStorage, + Matchers.equalTo("regular"), + ) + } + + @Test + fun privateModeStorageShared() { + // Two private mode sessions should share the same storage (bug 1533406). + val privateSession1 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build(), + ) + privateSession1.loadUri("https://example.com") + privateSession1.waitForPageStop() + + privateSession1.evaluateJS( + """ + localStorage.setItem('ctx', 'private'); + """, + ) + + val privateSession2 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build(), + ) + privateSession2.loadUri("https://example.com") + privateSession2.waitForPageStop() + + val localStorage = privateSession2.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Private mode storage value still set", + localStorage, + Matchers.equalTo("private"), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt new file mode 100644 index 0000000000..7c47ade0f7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt @@ -0,0 +1,52 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.CoreMatchers.equalTo +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.test.TestRuntimeService.RuntimeInstance +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ProfileLockedTest : BaseSessionTest() { + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + @ClosedSessionAtStart + fun profileLocked() { + val runtime0 = RuntimeInstance.start( + targetContext, + TestRuntimeService.instance0::class.java, + temporaryProfile.get(), + ) + + // Start the first runtime and wait until it's ready + sessionRule.waitForResult(runtime0.started) + + assertThat("The service should be connected now", runtime0.isConnected, equalTo(true)) + + // Now start a _second_ runtime with the same profile folder, this will kill the first + // runtime + val runtime1 = RuntimeInstance.start( + targetContext, + TestRuntimeService.instance1::class.java, + temporaryProfile.get(), + ) + + // Wait for the first runtime to disconnect + sessionRule.waitForResult(runtime0.disconnected) + + // GeckoRuntime will quit after killing the offending process + sessionRule.waitForResult(runtime1.quitted) + + assertThat( + "The service shouldn't be connected anymore", + runtime0.isConnected, + equalTo(false), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt new file mode 100644 index 0000000000..5d7d60ec6d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt @@ -0,0 +1,45 @@ +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader +import java.util.zip.GZIPInputStream + +@RunWith(AndroidJUnit4::class) +class ProfilerControllerTest : BaseSessionTest() { + + @Test + fun startAndStopProfiler() { + sessionRule.runtime.profilerController.startProfiler(arrayOf(), arrayOf()) + val result = sessionRule.runtime.profilerController.stopProfiler() + val byteArray = sessionRule.waitForResult(result) + val head = (byteArray[0].toInt() and 0xff) or (byteArray[1].toInt() shl 8 and 0xff00) + assertThat( + "Header of byte array should be the same as the GZIP one", + head, + equalTo(GZIPInputStream.GZIP_MAGIC), + ) + + val profileString = StringBuilder() + val gzipInputStream = GZIPInputStream(ByteArrayInputStream(byteArray)) + val bufferedReader = BufferedReader(InputStreamReader(gzipInputStream)) + + var line = bufferedReader.readLine() + while (line != null) { + profileString.append(line) + line = bufferedReader.readLine() + } + + val json = JSONObject(profileString.toString()) + assertThat( + "profile JSON object must not be empty", + json.length(), + greaterThan(0), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt new file mode 100644 index 0000000000..2410db66c5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt @@ -0,0 +1,582 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.* // ktlint-disable no-wildcard-imports + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ProgressDelegateTest : BaseSessionTest() { + + fun testProgress(path: String) { + mainSession.loadTestPath(path) + sessionRule.waitForPageStop() + + var counter = 0 + var lastProgress = -1 + + sessionRule.forCallbacksDuringWait(object : + ProgressDelegate, + NavigationDelegate { + @AssertCalled + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat("LocationChange is called", url, endsWith(path)) + } + + @AssertCalled + override fun onProgressChange(session: GeckoSession, progress: Int) { + assertThat( + "Progress must be strictly increasing", + progress, + greaterThan(lastProgress), + ) + lastProgress = progress + counter++ + } + + @AssertCalled + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("PageStart is called", url, endsWith(path)) + } + + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("PageStop is called", success, equalTo(true)) + } + }) + + assertThat( + "Callback should be called at least twice", + counter, + greaterThanOrEqualTo(2), + ) + assertThat( + "Last progress value should be 100", + lastProgress, + equalTo(100), + ) + } + + @Test fun loadProgress() { + testProgress(HELLO_HTML_PATH) + // Test that loading the same path again still + // results in the right progress events + testProgress(HELLO_HTML_PATH) + // Test that calling a different path works too + testProgress(HELLO2_HTML_PATH) + } + + @Test fun load() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", url, notNullValue()) + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Security info should not be null", securityInfo, notNullValue()) + + assertThat("Should not be secure", securityInfo.isSecure, equalTo(false)) + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Session should not be null", session, notNullValue()) + assertThat("Load should succeed", success, equalTo(true)) + } + }) + } + + @Ignore + @Test + fun multipleLoads() { + mainSession.loadUri(UNKNOWN_HOST_URI) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStops(2) + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 2, order = [1, 3]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat( + "URL should match", + url, + endsWith(forEachCall(UNKNOWN_HOST_URI, HELLO_HTML_PATH)), + ) + } + + @AssertCalled(count = 2, order = [2, 4]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + // The first load is certain to fail because of interruption by the second load + // or by invalid domain name, whereas the second load is certain to succeed. + assertThat( + "Success flag should match", + success, + equalTo(forEachCall(false, true)), + ) + } + }) + } + + @Test fun reload() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.reload() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + } + }) + } + + @Test fun goBackAndForward() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + mainSession.loadTestPath(HELLO2_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.goBack() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, endsWith(HELLO_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + } + }) + + mainSession.goForward() + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("URL should match", url, endsWith(HELLO2_HTML_PATH)) + } + + @AssertCalled(count = 1, order = [2]) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + } + + @AssertCalled(count = 1, order = [3]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should succeed", success, equalTo(true)) + } + }) + } + + @Test fun correctSecurityInfoForValidTLS_automation() { + assumeThat(sessionRule.env.isAutomation, equalTo(true)) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + assertThat( + "Should be secure", + securityInfo.isSecure, + equalTo(true), + ) + assertThat( + "Should not be exception", + securityInfo.isException, + equalTo(false), + ) + assertThat( + "Origin should match", + securityInfo.origin, + equalTo("https://example.com"), + ) + assertThat( + "Host should match", + securityInfo.host, + equalTo("example.com"), + ) + assertThat( + "Subject should match", + securityInfo.certificate?.subjectX500Principal?.name, + equalTo("CN=example.com"), + ) + assertThat( + "Issuer should match", + securityInfo.certificate?.issuerX500Principal?.name, + equalTo("OU=Profile Guided Optimization,O=Mozilla Testing,CN=Temporary Certificate Authority"), + ) + assertThat( + "Security mode should match", + securityInfo.securityMode, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.SECURITY_MODE_IDENTIFIED), + ) + assertThat( + "Active mixed mode should match", + securityInfo.mixedModeActive, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN), + ) + assertThat( + "Passive mixed mode should match", + securityInfo.mixedModePassive, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN), + ) + } + }) + } + + @LargeTest + @Test + fun correctSecurityInfoForValidTLS_local() { + assumeThat(sessionRule.env.isAutomation, equalTo(false)) + + mainSession.loadUri("https://mozilla-modern.badssl.com") + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + assertThat( + "Should be secure", + securityInfo.isSecure, + equalTo(true), + ) + assertThat( + "Should not be exception", + securityInfo.isException, + equalTo(false), + ) + assertThat( + "Origin should match", + securityInfo.origin, + equalTo("https://mozilla-modern.badssl.com"), + ) + assertThat( + "Host should match", + securityInfo.host, + equalTo("mozilla-modern.badssl.com"), + ) + assertThat( + "Subject should match", + securityInfo.certificate?.subjectX500Principal?.name, + equalTo("CN=*.badssl.com,O=Lucas Garron,L=Walnut Creek,ST=California,C=US"), + ) + assertThat( + "Issuer should match", + securityInfo.certificate?.issuerX500Principal?.name, + equalTo("CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US"), + ) + assertThat( + "Security mode should match", + securityInfo.securityMode, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.SECURITY_MODE_IDENTIFIED), + ) + assertThat( + "Active mixed mode should match", + securityInfo.mixedModeActive, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN), + ) + assertThat( + "Passive mixed mode should match", + securityInfo.mixedModePassive, + equalTo(GeckoSession.ProgressDelegate.SecurityInformation.CONTENT_UNKNOWN), + ) + } + }) + } + + @LargeTest + @Test + fun noSecurityInfoForExpiredTLS() { + mainSession.loadUri( + if (sessionRule.env.isAutomation) { + "https://expired.example.com" + } else { + "https://expired.badssl.com" + }, + ) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(false)) + } + + @AssertCalled(false) + override fun onSecurityChange( + session: GeckoSession, + securityInfo: GeckoSession.ProgressDelegate.SecurityInformation, + ) { + } + }) + } + + val errorEpsilon = 0.1 + + private fun waitForScroll(offset: Double, timeout: Double, param: String) { + mainSession.evaluateJS( + """ + new Promise((resolve, reject) => { + const start = Date.now(); + function step() { + if (window.visualViewport.$param >= ($offset - $errorEpsilon)) { + resolve(); + } else if ($timeout < (Date.now() - start)) { + reject(); + } else { + window.requestAnimationFrame(step); + } + } + window.requestAnimationFrame(step); + }); + """.trimIndent(), + ) + } + + private fun waitForVerticalScroll(offset: Double, timeout: Double) { + waitForScroll(offset, timeout, "pageTop") + } + + fun collectState(vararg uris: String): GeckoSession.SessionState { + for (uri in uris) { + mainSession.loadUri(uri) + sessionRule.waitForPageStop() + } + + mainSession.evaluateJS("document.querySelector('#name').value = 'the name';") + mainSession.evaluateJS("document.querySelector('#name').dispatchEvent(new Event('input'));") + + mainSession.evaluateJS("window.scrollBy(0, 100);") + waitForVerticalScroll(100.0, sessionRule.env.defaultTimeoutMillis.toDouble()) + + var savedState: GeckoSession.SessionState? = null + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) { + savedState = state + + val serialized = state.toString() + val deserialized = GeckoSession.SessionState.fromString(serialized) + assertThat("Deserialized session state should match", deserialized, equalTo(state)) + } + }) + + assertThat("State should not be null", savedState, notNullValue()) + return savedState!! + } + + @WithDisplay(width = 400, height = 400) + @Test + fun containsFormData() { + val startUri = createTestUrl(SAVE_STATE_PATH) + mainSession.loadUri(startUri) + sessionRule.waitForPageStop() + + val formData = mainSession.containsFormData() + sessionRule.waitForResult(formData).let { + assertThat("There should be no form data", it, equalTo(false)) + } + + mainSession.evaluateJS("document.querySelector('#name').value = 'the name';") + mainSession.evaluateJS("document.querySelector('#name').dispatchEvent(new Event('input'));") + + val formData2 = mainSession.containsFormData() + sessionRule.waitForResult(formData2).let { + assertThat("There should be form data", it, equalTo(true)) + } + } + + @WithDisplay(width = 400, height = 400) + @Test + fun saveAndRestoreStateNewSession() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val helloUri = createTestUrl(HELLO_HTML_PATH) + val startUri = createTestUrl(SAVE_STATE_PATH) + + val savedState = collectState(helloUri, startUri) + + val session = sessionRule.createOpenSession() + session.addDisplay(400, 400) + + session.restoreState(savedState) + session.waitForPageStop() + + session.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList, + ) { + assertThat("URI should match", url, equalTo(startUri)) + } + }) + + /* TODO: Reenable when we have a workaround for ContentSessionStore not + saving in response to JS-driven formdata changes. + assertThat("'name' field should match", + mainSession.evaluateJS("$('#name').value").toString(), + equalTo("the name"))*/ + + assertThat( + "Scroll position should match", + session.evaluateJS("window.visualViewport.pageTop") as Double, + closeTo(100.0, .5), + ) + + session.goBack() + + session.waitUntilCalled(object : NavigationDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat("History should be preserved", url, equalTo(helloUri)) + } + }) + } + + @WithDisplay(width = 400, height = 400) + @Test + fun saveAndRestoreState() { + // TODO: Bug 1648158 + // Bug 1662035 - disable to reduce intermittent failures + assumeThat(sessionRule.env.isX86, equalTo(false)) + assumeThat(sessionRule.env.isFission, equalTo(false)) + val startUri = createTestUrl(SAVE_STATE_PATH) + val savedState = collectState(startUri) + + mainSession.loadUri("about:blank") + sessionRule.waitForPageStop() + + mainSession.restoreState(savedState) + sessionRule.waitForPageStop() + + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + @AssertCalled + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat("URI should match", url, equalTo(startUri)) + } + }) + + /* TODO: Reenable when we have a workaround for ContentSessionStore not + saving in response to JS-driven formdata changes. + assertThat("'name' field should match", + mainSession.evaluateJS("$('#name').value").toString(), + equalTo("the name"))*/ + + assertThat( + "Scroll position should match", + mainSession.evaluateJS("window.visualViewport.pageTop") as Double, + closeTo(100.0, .5), + ) + } + + @WithDisplay(width = 400, height = 400) + @Test + fun flushSessionState() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val startUri = createTestUrl(SAVE_STATE_PATH) + mainSession.loadUri(startUri) + sessionRule.waitForPageStop() + + var oldState: GeckoSession.SessionState? = null + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) { + oldState = sessionState + } + }) + + assertThat("State should not be null", oldState, notNullValue()) + + mainSession.setActive(false) + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) { + assertThat("Old session state and new should match", sessionState, equalTo(oldState)) + } + }) + } + + @Test fun nullState() { + val stateFromNull: GeckoSession.SessionState? = GeckoSession.SessionState.fromString(null) + val nullState: GeckoSession.SessionState? = null + assertThat("Null string should result in null state", stateFromNull, equalTo(nullState)) + } + + @NullDelegate(GeckoSession.HistoryDelegate::class) + @Test + fun noHistoryDelegateOnSessionStateChange() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, sessionState: GeckoSession.SessionState) { + } + }) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt new file mode 100644 index 0000000000..10ebefb6dd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt @@ -0,0 +1,1084 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assert +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.Autocomplete +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.GeckoSession.PromptDelegate +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PromptDelegateTest : BaseSessionTest() { + @Test fun popupTestAllow() { + // Ensure popup blocking is enabled for this test. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to true)) + + sessionRule.delegateDuringNextWait(object : PromptDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onPopupPrompt(session: GeckoSession, prompt: PromptDelegate.PopupPrompt): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", prompt.targetUri, notNullValue()) + assertThat("URL should match", prompt.targetUri, endsWith(HELLO_HTML_PATH)) + return GeckoResult.fromValue(prompt.confirm(AllowOrDeny.ALLOW)) + } + + @AssertCalled(count = 2) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", request.uri, notNullValue()) + assertThat("URL should match", request.uri, endsWith(forEachCall(POPUP_HTML_PATH, HELLO_HTML_PATH))) + return null + } + + @AssertCalled(count = 1) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + assertThat("URL should not be null", uri, notNullValue()) + assertThat("URL should match", uri, endsWith(HELLO_HTML_PATH)) + return null + } + }) + + mainSession.loadTestPath(POPUP_HTML_PATH) + sessionRule.waitUntilCalled(NavigationDelegate::class, "onNewSession") + } + + @Test fun popupTestBlock() { + // Ensure popup blocking is enabled for this test. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to true)) + + sessionRule.delegateUntilTestEnd(object : PromptDelegate, NavigationDelegate { + @AssertCalled(count = 1) + override fun onPopupPrompt(session: GeckoSession, prompt: PromptDelegate.PopupPrompt): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", prompt.targetUri, notNullValue()) + assertThat("URL should match", prompt.targetUri, endsWith(HELLO_HTML_PATH)) + return GeckoResult.fromValue(prompt.confirm(AllowOrDeny.DENY)) + } + + @AssertCalled(count = 1) + override fun onLoadRequest( + session: GeckoSession, + request: LoadRequest, + ): GeckoResult? { + assertThat("Session should not be null", session, notNullValue()) + assertThat("URL should not be null", request.uri, notNullValue()) + assertThat("URL should match", request.uri, endsWith(POPUP_HTML_PATH)) + return null + } + + @AssertCalled(count = 0) + override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { + return null + } + }) + + mainSession.loadTestPath(POPUP_HTML_PATH) + sessionRule.waitForPageStop() + mainSession.waitForRoundTrip() + } + + @Ignore // TODO: Reenable when 1501574 is fixed. + @Test + fun alertTest() { + mainSession.evaluateJS("alert('Alert!');") + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onAlertPrompt(session: GeckoSession, prompt: PromptDelegate.AlertPrompt): GeckoResult { + assertThat("Message should match", "Alert!", equalTo(prompt.message)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + // This test checks that saved logins are returned to the app when calling onAuthPrompt + @Test fun loginStorageHttpAuthWithPassword() { + mainSession.loadTestPath("/basic-auth/foo/bar") + sessionRule.delegateDuringNextWait(object : Autocomplete.StorageDelegate { + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + return GeckoResult.fromValue( + arrayOf( + Autocomplete.LoginEntry.Builder() + .origin(GeckoSessionTestRule.TEST_ENDPOINT) + .formActionOrigin(GeckoSessionTestRule.TEST_ENDPOINT) + .httpRealm("Fake Realm") + .username("test-username") + .password("test-password") + .formActionOrigin(null) + .guid("test-guid") + .build(), + ), + ) + } + }) + sessionRule.waitUntilCalled(object : PromptDelegate, Autocomplete.StorageDelegate { + @AssertCalled + override fun onAuthPrompt(session: GeckoSession, prompt: AuthPrompt): GeckoResult? { + assertThat( + "Saved login should appear here", + prompt.authOptions.username, + equalTo("test-username"), + ) + assertThat( + "Saved login should appear here", + prompt.authOptions.password, + equalTo("test-password"), + ) + return null + } + }) + } + + // This test checks that we store login information submitted through HTTP basic auth + // This also tests that the login save prompt gets automatically dismissed if + // the login information is incorrect. + @Test fun loginStorageHttpAuth() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "signon.rememberSignons" to true, + ), + ) + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + var prompt: PromptDelegate.BasePrompt? = null + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt) + } + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate, Autocomplete.StorageDelegate { + @AssertCalled + override fun onAuthPrompt(session: GeckoSession, prompt: AuthPrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.confirm("foo", "bar")) + } + + @AssertCalled + override fun onLoginFetch(domain: String): GeckoResult>? { + return GeckoResult.fromValue(arrayOf()) + } + + @AssertCalled + override fun onLoginSave( + session: GeckoSession, + request: PromptDelegate.AutocompleteRequest, + ): GeckoResult? { + val authInfo = request.options[0].value + assertThat("auth matches", authInfo.formActionOrigin, isEmptyOrNullString()) + assertThat("auth matches", authInfo.httpRealm, equalTo("Fake Realm")) + assertThat("auth matches", authInfo.origin, equalTo(GeckoSessionTestRule.TEST_ENDPOINT)) + assertThat("auth matches", authInfo.username, equalTo("foo")) + assertThat("auth matches", authInfo.password, equalTo("bar")) + promptInstanceDelegate.prompt = request + request.setDelegate(promptInstanceDelegate) + return GeckoResult() + } + }) + + mainSession.loadTestPath("/basic-auth/foo/bar") + + // The server we try to hit will always reject the login so we should + // get a request to reauth which should dismiss the prompt + val actualPrompt = sessionRule.waitForResult(result) + + assertThat("Prompt object should match", actualPrompt, equalTo(promptInstanceDelegate.prompt)) + } + + @Test fun dismissAuthTest() { + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 2) + override fun onAuthPrompt(session: GeckoSession, prompt: PromptDelegate.AuthPrompt): GeckoResult? { + // TODO: Figure out some better testing here. + return null + } + }) + + mainSession.loadTestPath("/basic-auth/foo/bar") + mainSession.waitForPageStop() + + mainSession.reload() + mainSession.waitForPageStop() + } + + @Test fun buttonTest() { + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onButtonPrompt(session: GeckoSession, prompt: PromptDelegate.ButtonPrompt): GeckoResult { + assertThat("Message should match", "Confirm?", equalTo(prompt.message)) + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.ButtonPrompt.Type.POSITIVE)) + } + }) + + assertThat( + "Result should match", + mainSession.waitForJS("confirm('Confirm?')") as Boolean, + equalTo(true), + ) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onButtonPrompt(session: GeckoSession, prompt: PromptDelegate.ButtonPrompt): GeckoResult { + assertThat("Message should match", "Confirm?", equalTo(prompt.message)) + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.ButtonPrompt.Type.NEGATIVE)) + } + }) + + assertThat( + "Result should match", + mainSession.waitForJS("confirm('Confirm?')") as Boolean, + equalTo(false), + ) + } + + @Test + fun onFormResubmissionPrompt() { + mainSession.loadTestPath(RESUBMIT_CONFIRM) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + "document.querySelector('#text').value = 'Some text';" + + "document.querySelector('#submit').click();", + ) + + // Submitting the form causes a navigation + sessionRule.waitForPageStop() + + val result = GeckoResult() + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("Only HELLO_HTML_PATH should load", url, endsWith(HELLO_HTML_PATH)) + result.complete(null) + } + }) + + val promptResult = GeckoResult() + val promptResult2 = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 2) + override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult? { + // We have to return something here because otherwise the delegate will be invoked + // before we have a chance to override it in the waitUntilCalled call below + return forEachCall(promptResult, promptResult2) + } + }) + + // This should trigger a confirm resubmit prompt + mainSession.reload() + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult? { + promptResult.complete(prompt.confirm(AllowOrDeny.DENY)) + return promptResult + } + }) + + sessionRule.waitForResult(promptResult) + + // Trigger it again, this time the load should go through + mainSession.reload() + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult? { + promptResult2.complete(prompt.confirm(AllowOrDeny.ALLOW)) + return promptResult2 + } + }) + + sessionRule.waitForResult(promptResult2) + sessionRule.waitForResult(result) + } + + @Test + @WithDisplay(width = 100, height = 100) + fun selectTestSimple() { + mainSession.loadTestPath(SELECT_HTML_PATH) + sessionRule.waitForPageStop() + + val result = GeckoResult() + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult? { + assertThat("Should not be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.SINGLE)) + assertThat("There should be two choices", prompt.choices.size, equalTo(2)) + assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC")) + assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF")) + result.complete(prompt.confirm(prompt.choices[1])) + return result + } + }) + + val promise = mainSession.evaluatePromiseJS( + """new Promise(function(resolve) { + let events = []; + // Record the events for testing purposes. + for (const t of ["change", "input"]) { + document.querySelector("select").addEventListener(t, function(e) { + events.push(e.type + "(composed=" + e.composed + ")"); + if (events.length == 2) { + resolve(events.join(" ")); + } + }); + } + })""", + ) + + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + assertThat( + "Events should be as expected", + promise.value as String, + equalTo("input(composed=true) change(composed=false)"), + ) + } + + @Test + @WithDisplay(width = 100, height = 100) + fun selectTestSize() { + mainSession.loadTestPath(SELECT_LISTBOX_HTML_PATH) + sessionRule.waitForPageStop() + + val result = GeckoResult() + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult? { + assertThat("Should not be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.SINGLE)) + assertThat("There should be three choices", prompt.choices.size, equalTo(3)) + assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC")) + assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF")) + assertThat("Third choice is correct", prompt.choices[2].label, equalTo("GHI")) + result.complete(null) + return null + } + }) + + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + } + + @Test + @WithDisplay(width = 100, height = 100) + fun selectTestMultiple() { + mainSession.loadTestPath(SELECT_MULTIPLE_HTML_PATH) + sessionRule.waitForPageStop() + + val result = GeckoResult() + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult? { + assertThat("Should be multiple", prompt.type, equalTo(PromptDelegate.ChoicePrompt.Type.MULTIPLE)) + assertThat("There should be three choices", prompt.choices.size, equalTo(3)) + assertThat("First choice is correct", prompt.choices[0].label, equalTo("ABC")) + assertThat("Second choice is correct", prompt.choices[1].label, equalTo("DEF")) + assertThat("Third choice is correct", prompt.choices[2].label, equalTo("GHI")) + result.complete(null) + return null + } + }) + + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + } + + @Test + @WithDisplay(width = 100, height = 100) + fun selectTestUpdate() { + mainSession.loadTestPath(SELECT_HTML_PATH) + sessionRule.waitForPageStop() + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptUpdate(prompt: PromptDelegate.BasePrompt) { + val newPrompt: PromptDelegate.ChoicePrompt = prompt as PromptDelegate.ChoicePrompt + assertThat("First choice is correct", newPrompt.choices[0].label, equalTo("foo")) + assertThat("Second choice is correct", newPrompt.choices[1].label, equalTo("bar")) + assertThat("Third choice is correct", newPrompt.choices[2].label, equalTo("baz")) + result.complete(prompt.confirm(newPrompt.choices[2])) + } + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult? { + assertThat("There should be two choices", prompt.choices.size, equalTo(2)) + prompt.setDelegate(promptInstanceDelegate) + return result + } + }) + + mainSession.evaluateJS( + """ + document.querySelector("select").addEventListener("focus", () => { + window.setTimeout(() => { + document.querySelector("select").innerHTML = + ""; + }, 100); + }, { once: true }) + """.trimIndent(), + ) + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + document.querySelector("select").addEventListener("change", e => { + resolve(e.target.value); + }); + }) + """.trimIndent(), + ) + + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + assertThat( + "Selected item should be as expected", + promise.value as String, + equalTo("baz"), + ) + } + + @Test + @WithDisplay(width = 100, height = 100) + fun selectTestDismiss() { + mainSession.loadTestPath(SELECT_HTML_PATH) + sessionRule.waitForPageStop() + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult? { + assertThat("There should be two choices", prompt.choices.size, equalTo(2)) + prompt.setDelegate(promptInstanceDelegate) + mainSession.evaluateJS("document.querySelector('select').blur()") + return result + } + }) + + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + } + + @Test + fun onBeforeUnloadTest() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.require_user_interaction_for_beforeunload" to false, + ), + ) + mainSession.loadTestPath(BEFORE_UNLOAD) + sessionRule.waitForPageStop() + + val result = GeckoResult() + sessionRule.delegateUntilTestEnd(object : ProgressDelegate { + override fun onPageStart(session: GeckoSession, url: String) { + assertThat("Only HELLO2_HTML_PATH should load", url, endsWith(HELLO2_HTML_PATH)) + result.complete(null) + } + }) + + val promptResult = GeckoResult() + val promptResult2 = GeckoResult() + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 2) + override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult? { + // We have to return something here because otherwise the delegate will be invoked + // before we have a chance to override it in the waitUntilCalled call below + return forEachCall(promptResult, promptResult2) + } + }) + + // This will try to load "hello.html" but will be denied, if the request + // goes through anyway the onLoadRequest delegate above will throw an exception + mainSession.evaluateJS("document.querySelector('#navigateAway').click()") + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult? { + promptResult.complete(prompt.confirm(AllowOrDeny.DENY)) + return promptResult + } + }) + + sessionRule.waitForResult(promptResult) + + // Although onBeforeUnloadPrompt is done, nsDocumentViewer might not clear + // mInPermitUnloadPrompt flag at this time yet. We need a wait to finish + // "nsDocumentViewer::PermitUnload" loop. + mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))") + + // This request will go through and end the test. Doing the negative case first will + // ensure that if either of this tests fail the test will fail. + mainSession.evaluateJS("document.querySelector('#navigateAway2').click()") + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult? { + promptResult2.complete(prompt.confirm(AllowOrDeny.ALLOW)) + return promptResult2 + } + }) + + sessionRule.waitForResult(promptResult2) + sessionRule.waitForResult(result) + } + + @Test fun textTest() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onTextPrompt(session: GeckoSession, prompt: PromptDelegate.TextPrompt): GeckoResult { + assertThat("Message should match", "Prompt:", equalTo(prompt.message)) + assertThat("Default should match", "default", equalTo(prompt.defaultValue)) + return GeckoResult.fromValue(prompt.confirm("foo")) + } + }) + + assertThat( + "Result should match", + mainSession.waitForJS("prompt('Prompt:', 'default')") as String, + equalTo("foo"), + ) + } + + @Test fun colorTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onColorPrompt(session: GeckoSession, prompt: PromptDelegate.ColorPrompt): GeckoResult { + assertThat("Value should match", "#ffffff", equalTo(prompt.defaultValue)) + assertThat("Predefined values size", 0, equalTo(prompt.predefinedValues!!.size)) + return GeckoResult.fromValue(prompt.confirm("#123456")) + } + }) + + mainSession.evaluateJS( + """ + this.c = document.getElementById('colorexample'); + """.trimIndent(), + ) + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise((resolve, reject) => { + this.c.addEventListener( + 'change', + event => resolve(event.target.value), + false + ); + }) + """.trimIndent(), + ) + + mainSession.evaluateJS("this.c.click();") + + assertThat( + "Value should match", + promise.value as String, + equalTo("#123456"), + ) + } + + @Test fun colorTestWithDatalist() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onColorPrompt(session: GeckoSession, prompt: PromptDelegate.ColorPrompt): GeckoResult { + assertThat("Value should match", "#ffffff", equalTo(prompt.defaultValue)) + assertThat("Predefined values size", 2, equalTo(prompt.predefinedValues!!.size)) + assertThat("First predefined value", "#000000", equalTo(prompt.predefinedValues?.get(0))) + assertThat("Second predefined value", "#808080", equalTo(prompt.predefinedValues?.get(1))) + return GeckoResult.fromValue(prompt.confirm("#123456")) + } + }) + + mainSession.evaluateJS( + """ + this.c = document.getElementById('colorexample'); + this.c.setAttribute('list', 'colorlist'); + """.trimIndent(), + ) + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise((resolve, reject) => { + this.c.addEventListener( + 'change', + event => resolve(event.target.value), + ); + }) + """.trimIndent(), + ) + mainSession.evaluateJS("this.c.click();") + + assertThat( + "Value should match", + promise.value as String, + equalTo("#123456"), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dateTest() { + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS( + """ + document.body.addEventListener("click", () => { + document.getElementById('dateexample').showPicker(); + }); + """.trimIndent(), + ) + + mainSession.synthesizeTap(1, 1) // Provides user activation. + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dateTestByTap() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + // By removing first element in PROMPT_HTML_PATH, dateexample becomes first element. + // + // TODO: What better calculation of element bounds for synthesizeTap? + mainSession.evaluateJS( + """ + document.getElementById('selectexample').remove(); + document.getElementById('dateexample').getBoundingClientRect(); + """.trimIndent(), + ) + mainSession.synthesizeTap(10, 10) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + assertThat(" is tapped", PromptDelegate.DateTimePrompt.Type.DATE, equalTo(prompt.type)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun monthTestByTap() { + // Gecko doesn't have the widget for . But GeckoView can show the picker. + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + // TODO: What better calculation of element bounds for synthesizeTap? + mainSession.evaluateJS( + """ + document.getElementById('selectexample').remove(); + document.getElementById('dateexample').remove(); + document.getElementById('weekexample').getBoundingClientRect(); + """.trimIndent(), + ) + mainSession.synthesizeTap(10, 10) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + assertThat(" is tapped", PromptDelegate.DateTimePrompt.Type.MONTH, equalTo(prompt.type)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dateTestParameters() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS( + """ + document.getElementById('selectexample').remove(); + document.getElementById('dateexample').min = "2022-01-01"; + document.getElementById('dateexample').max = "2022-12-31"; + document.getElementById('dateexample').step = "10"; + document.getElementById('dateexample').getBoundingClientRect(); + """.trimIndent(), + ) + mainSession.synthesizeTap(10, 10) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + assertThat(" is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.DATE)) + assertThat("min value is exported", prompt.minValue, equalTo("2022-01-01")) + assertThat("max value is exported", prompt.maxValue, equalTo("2022-12-31")) + assertThat("step value is exported", prompt.stepValue, equalTo("10")) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dateTestDismiss() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + assertThat(" is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.DATE)) + prompt.setDelegate(promptInstanceDelegate) + mainSession.evaluateJS("document.getElementById('dateexample').blur()") + return result + } + }) + + mainSession.evaluateJS("document.getElementById('selectexample').remove()") + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun monthTestDismiss() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + val result = GeckoResult() + val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate { + override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) { + result.complete(prompt.dismiss()) + } + } + + sessionRule.delegateUntilTestEnd(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult { + assertThat(" is tapped", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.MONTH)) + prompt.setDelegate(promptInstanceDelegate) + mainSession.evaluateJS("document.getElementById('monthexample').blur()") + return result + } + }) + + mainSession.evaluateJS( + """ + document.getElementById('selectexample').remove(); + document.getElementById('dateexample').remove(); + """.trimIndent(), + ) + mainSession.synthesizeTap(10, 10) + sessionRule.waitForResult(result) + } + + @Test fun fileTest() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) + + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.getElementById('fileexample').click();") + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onFilePrompt(session: GeckoSession, prompt: PromptDelegate.FilePrompt): GeckoResult { + assertThat("Length of mimeTypes should match", 2, equalTo(prompt.mimeTypes!!.size)) + assertThat("First accept attribute should match", "image/*", equalTo(prompt.mimeTypes?.get(0))) + assertThat("Second accept attribute should match", ".pdf", equalTo(prompt.mimeTypes?.get(1))) + assertThat("Capture attribute should match", PromptDelegate.FilePrompt.Capture.USER, equalTo(prompt.capture)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @Test fun shareTextSucceeds() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareText = "Example share text" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + assertThat("Text field is not null", prompt.text, notNullValue()) + assertThat("Title field is null", prompt.title, nullValue()) + assertThat("Url field is null", prompt.uri, nullValue()) + assertThat("Text field contains correct value", prompt.text, equalTo(shareText)) + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS)) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({text: "$shareText"})""") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + Assert.fail("Share must succeed." + e.reason as String) + } + } + + @Test fun shareUrlSucceeds() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareUrl = "https://example.com/" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + assertThat("Text field is null", prompt.text, nullValue()) + assertThat("Title field is null", prompt.title, nullValue()) + assertThat("Url field is not null", prompt.uri, notNullValue()) + assertThat("Text field contains correct value", prompt.uri, equalTo(shareUrl)) + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS)) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + Assert.fail("Share must succeed." + e.reason as String) + } + } + + @Test fun shareTitleSucceeds() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareTitle = "Title!" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + assertThat("Text field is null", prompt.text, nullValue()) + assertThat("Title field is not null", prompt.title, notNullValue()) + assertThat("Url field is null", prompt.uri, nullValue()) + assertThat("Text field contains correct value", prompt.title, equalTo(shareTitle)) + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.SUCCESS)) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({title: "$shareTitle"})""") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + Assert.fail("Share must succeed." + e.reason as String) + } + } + + @Test fun failedShareReturnsDataError() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareUrl = "https://www.example.com" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.FAILURE)) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("DataError"), + ) + } + } + + @Test fun abortedShareReturnsAbortError() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareUrl = "https://www.example.com" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.confirm(PromptDelegate.SharePrompt.Result.ABORT)) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("AbortError"), + ) + } + } + + @Test fun dismissedShareReturnsAbortError() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareUrl = "https://www.example.com" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("AbortError"), + ) + } + } + + @Test fun emptyShareReturnsTypeError() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 0) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("TypeError"), + ) + } + } + + @Test fun invalidShareUrlReturnsTypeError() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to false)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + // Invalid port should cause URL parser to fail. + val shareUrl = "http://www.example.com:123456" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 0) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("TypeError"), + ) + } + } + + @Test fun shareRequiresUserInteraction() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webshare.requireinteraction" to true)) + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val shareUrl = "https://www.example.com" + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 0) + override fun onSharePrompt(session: GeckoSession, prompt: PromptDelegate.SharePrompt): GeckoResult? { + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + try { + mainSession.waitForJS("""window.navigator.share({url: "$shareUrl"})""") + Assert.fail("Request should have failed") + } catch (e: GeckoSessionTestRule.RejectedPromiseException) { + assertThat( + "Error should be correct", + e.reason as String, + containsString("NotAllowedError"), + ) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt new file mode 100644 index 0000000000..038515084f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt @@ -0,0 +1,253 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.provider.Settings +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.WebRequestError +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@RunWith(AndroidJUnit4::class) +@MediumTest +class RuntimeSettingsTest : BaseSessionTest() { + + @Ignore("disable test for frequently failing Bug 1538430") + @Test + fun automaticFontSize() { + val settings = sessionRule.runtime.settings + var initialFontSize = 2.15f + var initialFontInflation = true + settings.fontSizeFactor = initialFontSize + assertThat( + "initial font scale $initialFontSize set", + settings.fontSizeFactor.toDouble(), + closeTo(initialFontSize.toDouble(), 0.05), + ) + settings.fontInflationEnabled = initialFontInflation + assertThat( + "font inflation initially set to $initialFontInflation", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + + settings.automaticFontSizeAdjustment = true + val contentResolver = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver + val expectedFontSizeFactor = Settings.System.getFloat( + contentResolver, + Settings.System.FONT_SCALE, + 1.0f, + ) + assertThat( + "Gecko font scale should match system font scale", + settings.fontSizeFactor.toDouble(), + closeTo(expectedFontSizeFactor.toDouble(), 0.05), + ) + assertThat( + "font inflation enabled", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + + settings.automaticFontSizeAdjustment = false + assertThat( + "Gecko font scale restored to previous value", + settings.fontSizeFactor.toDouble(), + closeTo(initialFontSize.toDouble(), 0.05), + ) + assertThat( + "font inflation restored to previous value", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + + // Now check with that with font inflation initially off, the initial state is still + // restored correctly after switching auto mode back off. + // Also reset font size factor back to its default value of 1.0f. + initialFontSize = 1.0f + initialFontInflation = false + settings.fontSizeFactor = initialFontSize + assertThat( + "initial font scale $initialFontSize set", + settings.fontSizeFactor.toDouble(), + closeTo(initialFontSize.toDouble(), 0.05), + ) + settings.fontInflationEnabled = initialFontInflation + assertThat( + "font inflation initially set to $initialFontInflation", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + + settings.automaticFontSizeAdjustment = true + assertThat( + "Gecko font scale should match system font scale", + settings.fontSizeFactor.toDouble(), + closeTo(expectedFontSizeFactor.toDouble(), 0.05), + ) + assertThat( + "font inflation enabled", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + + settings.automaticFontSizeAdjustment = false + assertThat( + "Gecko font scale restored to previous value", + settings.fontSizeFactor.toDouble(), + closeTo(initialFontSize.toDouble(), 0.05), + ) + assertThat( + "font inflation restored to previous value", + settings.fontInflationEnabled, + `is`(initialFontInflation), + ) + } + + @Ignore // Bug 1546297 disabled test on pgo for frequent failures + @Test + fun fontSize() { + val settings = sessionRule.runtime.settings + settings.fontSizeFactor = 1.0f + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + + val fontSizeJs = "parseFloat(window.getComputedStyle(document.querySelector('p')).fontSize)" + val initialFontSize = mainSession.evaluateJS(fontSizeJs) as Double + + val textSizeFactor = 2.0f + settings.fontSizeFactor = textSizeFactor + mainSession.reload() + sessionRule.waitForPageStop() + var fontSize = mainSession.evaluateJS(fontSizeJs) as Double + val expectedFontSize = initialFontSize * textSizeFactor + assertThat( + "old text size ${initialFontSize}px, new size should be ${expectedFontSize}px", + fontSize, + closeTo(expectedFontSize, 0.1), + ) + + settings.fontSizeFactor = 1.0f + mainSession.reload() + sessionRule.waitForPageStop() + fontSize = mainSession.evaluateJS(fontSizeJs) as Double + assertThat( + "text size should be ${initialFontSize}px again", + fontSize, + closeTo(initialFontSize, 0.1), + ) + } + + @Test fun fontInflation() { + val baseFontInflationMinTwips = 120 + val settings = sessionRule.runtime.settings + + settings.fontInflationEnabled = false + settings.fontSizeFactor = 1.0f + val fontInflationPref = "font.size.inflation.minTwips" + + var prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "Gecko font inflation pref should be turned off", + prefValue, + `is`(0), + ) + + settings.fontInflationEnabled = true + prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "Gecko font inflation pref should be turned on", + prefValue, + `is`(baseFontInflationMinTwips), + ) + + settings.fontSizeFactor = 2.0f + prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "Gecko font inflation pref should scale with increased font size factor", + prefValue, + greaterThan(baseFontInflationMinTwips), + ) + + settings.fontSizeFactor = 0.5f + prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "Gecko font inflation pref should scale with decreased font size factor", + prefValue, + lessThan(baseFontInflationMinTwips), + ) + + settings.fontSizeFactor = 0.0f + prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "setting font size factor to 0 turns off font inflation", + prefValue, + `is`(0), + ) + assertThat( + "GeckoRuntimeSettings returns new font inflation state, too", + settings.fontInflationEnabled, + `is`(false), + ) + + settings.fontSizeFactor = 1.0f + prefValue = (sessionRule.getPrefs(fontInflationPref)[0] as Int) + assertThat( + "Gecko font inflation pref remains turned off", + prefValue, + `is`(0), + ) + assertThat( + "GeckoRuntimeSettings remains turned off", + settings.fontInflationEnabled, + `is`(false), + ) + } + + @Test + fun aboutConfig() { + // This is broken in automation because document channel is enabled by default + assumeThat(sessionRule.env.isAutomation, equalTo(false)) + val settings = sessionRule.runtime.settings + + assertThat( + "about:config should be disabled by default", + settings.aboutConfigEnabled, + equalTo(false), + ) + + mainSession.loadUri("about:config") + mainSession.waitUntilCalled(object : NavigationDelegate { + @AssertCalled + override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): + GeckoResult? { + assertThat("about:config should not load.", uri, equalTo("about:config")) + return null + } + }) + + settings.aboutConfigEnabled = true + + mainSession.delegateDuringNextWait(object : ProgressDelegate { + @AssertCalled + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("about:config load should succeed", success, equalTo(true)) + } + }) + + mainSession.loadUri("about:config") + mainSession.waitForPageStop() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt new file mode 100644 index 0000000000..c6ffaf83fb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt @@ -0,0 +1,433 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.* // ktlint-disable no-wildcard-imports +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.view.Surface +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assert +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoResult.OnExceptionListener +import org.mozilla.geckoview.GeckoResult.fromException +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import java.lang.IllegalStateException +import kotlin.math.absoluteValue +import kotlin.math.max + +private const val SCREEN_HEIGHT = 800 +private const val SCREEN_WIDTH = 800 +private const val BIG_SCREEN_HEIGHT = 999999 +private const val BIG_SCREEN_WIDTH = 999999 + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ScreenshotTest : BaseSessionTest() { + private fun getComparisonScreenshot(width: Int, height: Int): Bitmap { + val screenshotFile = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(screenshotFile) + val paint = Paint() + paint.shader = LinearGradient(0f, 0f, width.toFloat(), height.toFloat(), Color.RED, Color.WHITE, Shader.TileMode.MIRROR) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + return screenshotFile + } + + companion object { + /** + * Compares two Bitmaps and returns the largest color element difference (red, green or blue) + */ + public fun imageElementDifference(b1: Bitmap, b2: Bitmap): Int { + return if (b1.width == b2.width && b1.height == b2.height) { + val pixels1 = IntArray(b1.width * b1.height) + val pixels2 = IntArray(b2.width * b2.height) + b1.getPixels(pixels1, 0, b1.width, 0, 0, b1.width, b1.height) + b2.getPixels(pixels2, 0, b2.width, 0, 0, b2.width, b2.height) + var maxDiff = 0 + for (i in 0 until pixels1.size) { + val redDiff = (Color.red(pixels1[i]) - Color.red(pixels2[i])).absoluteValue + val greenDiff = (Color.green(pixels1[i]) - Color.green(pixels2[i])).absoluteValue + val blueDiff = (Color.blue(pixels1[i]) - Color.blue(pixels2[i])).absoluteValue + maxDiff = max(maxDiff, max(redDiff, max(greenDiff, blueDiff))) + } + maxDiff + } else { + 256 + } + } + } + + private fun assertScreenshotResult(result: GeckoResult, comparisonImage: Bitmap) { + sessionRule.waitForResult(result).let { + assertThat( + "Screenshot is not null", + it, + notNullValue(), + ) + assertThat("Widths are the same", comparisonImage.width, equalTo(it.width)) + assertThat("Heights are the same", comparisonImage.height, equalTo(it.height)) + assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount)) + assertThat("Configs are the same", comparisonImage.config, equalTo(it.config)) + assertThat( + "Images are almost identical", + imageElementDifference(comparisonImage, it), + lessThanOrEqualTo(1), + ) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun capturePixelsSucceeds() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun capturePixelsCanBeCalledMultipleTimes() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + val call1 = it.capturePixels() + val call2 = it.capturePixels() + val call3 = it.capturePixels() + assertScreenshotResult(call1, screenshotFile) + assertScreenshotResult(call2, screenshotFile) + assertScreenshotResult(call3, screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun capturePixelsCompletesCompositorPausedRestarted() { + sessionRule.display?.let { + it.surfaceDestroyed() + val result = it.capturePixels() + val texture = SurfaceTexture(0) + texture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT) + val surface = Surface(texture) + it.surfaceChanged(SurfaceInfo.Builder(surface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build()) + sessionRule.waitForResult(result) + } + } + + // This tests tries to catch problems like Bug 1644561. + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun capturePixelsStressTest() { + val screenshots = mutableListOf>() + sessionRule.display?.let { + for (i in 0..100) { + screenshots.add(it.capturePixels()) + } + + for (i in 0..50) { + sessionRule.waitForResult(screenshots[i]) + } + + it.surfaceDestroyed() + screenshots.add(it.capturePixels()) + it.surfaceDestroyed() + + val texture = SurfaceTexture(0) + texture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT) + val surface = Surface(texture) + it.surfaceChanged(SurfaceInfo.Builder(surface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build()) + + for (i in 0..100) { + screenshots.add(it.capturePixels()) + } + + for (i in 0..100) { + it.surfaceDestroyed() + screenshots.add(it.capturePixels()) + val newTexture = SurfaceTexture(0) + newTexture.setDefaultBufferSize(SCREEN_WIDTH, SCREEN_HEIGHT) + val newSurface = Surface(newTexture) + it.surfaceChanged(SurfaceInfo.Builder(newSurface).size(SCREEN_WIDTH, SCREEN_HEIGHT).build()) + } + + try { + for (result in screenshots) { + sessionRule.waitForResult(result) + } + } catch (ex: RuntimeException) { + // Rejecting the screenshot is fine + } + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test(expected = IllegalStateException::class) + fun capturePixelsFailsCompositorPaused() { + sessionRule.display?.let { + it.surfaceDestroyed() + val result = it.capturePixels() + it.surfaceDestroyed() + + sessionRule.waitForResult(result) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun capturePixelsWhileSessionDeactivated() { + // TODO: Bug 1673955 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + mainSession.setActive(false) + + // Deactivating the session should trigger a flush state change + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange( + session: GeckoSession, + sessionState: GeckoSession.SessionState, + ) {} + }) + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotToBitmap() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.screenshot().capture(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotScaledToSize() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.screenshot().size(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2).capture(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenShotScaledWithScale() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.screenshot().scale(0.5f).capture(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenShotScaledWithAspectPreservingSize() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.screenshot().aspectPreservingSize(SCREEN_WIDTH / 2).capture(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun recycleBitmap() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + val call1 = it.screenshot().capture() + assertScreenshotResult(call1, screenshotFile) + val call2 = it.screenshot().bitmap(call1.poll(1000)).capture() + assertScreenshotResult(call2, screenshotFile) + val call3 = it.screenshot().bitmap(call2.poll(1000)).capture() + assertScreenshotResult(call3, screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotWholeRegion() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH, SCREEN_HEIGHT) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.screenshot().source(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT).capture(), screenshotFile) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotWholeRegionScaled() { + val screenshotFile = getComparisonScreenshot(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult( + it.screenshot() + .source(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT) + .size(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + .capture(), + screenshotFile, + ) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotQuarters() { + val res = InstrumentationRegistry.getInstrumentation().targetContext.resources + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult( + it.screenshot() + .source(0, 0, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + .capture(), + BitmapFactory.decodeResource(res, R.drawable.colors_tl), + ) + assertScreenshotResult( + it.screenshot() + .source(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + .capture(), + BitmapFactory.decodeResource(res, R.drawable.colors_br), + ) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun screenshotQuartersScaled() { + val res = InstrumentationRegistry.getInstrumentation().targetContext.resources + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult( + it.screenshot() + .source(0, 0, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + .size(SCREEN_WIDTH / 4, SCREEN_WIDTH / 4) + .capture(), + BitmapFactory.decodeResource(res, R.drawable.colors_tl_scaled), + ) + assertScreenshotResult( + it.screenshot() + .source(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + .size(SCREEN_WIDTH / 4, SCREEN_WIDTH / 4) + .capture(), + BitmapFactory.decodeResource(res, R.drawable.colors_br_scaled), + ) + } + } + + @WithDisplay(height = BIG_SCREEN_HEIGHT, width = BIG_SCREEN_WIDTH) + @Test + fun giantScreenshot() { + mainSession.loadTestPath(COLORS_HTML_PATH) + sessionRule.display?.screenshot()!!.source(0, 0, BIG_SCREEN_WIDTH, BIG_SCREEN_HEIGHT) + .size(BIG_SCREEN_WIDTH, BIG_SCREEN_HEIGHT) + .capture() + .exceptionally( + OnExceptionListener { error: Throwable -> + Assert.assertTrue(error is OutOfMemoryError) + fromException(error) + }, + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt new file mode 100644 index 0000000000..63222e9732 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt @@ -0,0 +1,913 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Point +import android.graphics.RectF +import android.os.Build +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matcher +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONArray +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameter +import org.junit.runners.Parameterized.Parameters +import org.mozilla.geckoview.AllowOrDeny +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PromptDelegate +import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate +import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@MediumTest +@RunWith(Parameterized::class) +@WithDisplay(width = 400, height = 400) +class SelectionActionDelegateTest : BaseSessionTest() { + val activityRule = ActivityScenarioRule(GeckoViewTestActivity::class.java) + + @get:Rule + override val rules: RuleChain = RuleChain.outerRule(activityRule).around(sessionRule) + + enum class ContentType { + DIV, EDITABLE_ELEMENT, IFRAME, IFRAME_XORIGIN + } + + companion object { + @get:Parameters(name = "{0}") + @JvmStatic + val parameters: List> = listOf( + arrayOf("#text", ContentType.DIV, "lorem", false), + arrayOf("#input", ContentType.EDITABLE_ELEMENT, "ipsum", true), + arrayOf("#textarea", ContentType.EDITABLE_ELEMENT, "dolor", true), + arrayOf("#contenteditable", ContentType.DIV, "sit", true), + arrayOf("#iframe", ContentType.IFRAME, "amet", false), + arrayOf("#designmode", ContentType.IFRAME, "consectetur", true), + arrayOf("#iframe-xorigin", ContentType.IFRAME_XORIGIN, "elit", false), + arrayOf("#x-input", ContentType.EDITABLE_ELEMENT, "adipisci", true), + ) + } + + @field:Parameter(0) + @JvmField + var id: String = "" + + @field:Parameter(1) + @JvmField + var type: ContentType = ContentType.DIV + + @field:Parameter(2) + @JvmField + var initialContent: String = "" + + @field:Parameter(3) + @JvmField + var editable: Boolean = false + + private val selectedContent by lazy { + when (type) { + ContentType.DIV -> SelectedDiv(id, initialContent) + ContentType.EDITABLE_ELEMENT -> SelectedEditableElement(id, initialContent) + ContentType.IFRAME -> SelectedFrame(id, initialContent) + ContentType.IFRAME_XORIGIN -> SelectedFrameXOrigin(id, initialContent) + } + } + + private val collapsedContent by lazy { + when (type) { + ContentType.DIV -> CollapsedDiv(id) + ContentType.EDITABLE_ELEMENT -> CollapsedEditableElement(id) + ContentType.IFRAME -> CollapsedFrame(id) + ContentType.IFRAME_XORIGIN -> CollapsedFrameXOrigin(id) + } + } + + @Before + fun setup() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Writing clipboard requires foreground on Android 10. + activityRule.scenario.onActivity { activity -> + activity.onWindowFocusChanged(true) + } + } + } + + /** Generic tests for each content type. */ + + @Test fun request() { + if (editable) { + withClipboard("text") { + testThat( + selectedContent, + {}, + hasShowActionRequest( + FLAG_IS_EDITABLE, + arrayOf( + ACTION_COLLAPSE_TO_START, + ACTION_COLLAPSE_TO_END, + ACTION_COPY, + ACTION_CUT, + ACTION_DELETE, + ACTION_HIDE, + ACTION_PASTE, + ), + ), + ) + } + } else { + testThat( + selectedContent, + {}, + hasShowActionRequest( + 0, + arrayOf( + ACTION_COPY, + ACTION_HIDE, + ACTION_SELECT_ALL, + ACTION_UNSELECT, + ), + ), + ) + } + } + + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) + @Test + fun request_html() { + if (editable) { + withHtmlClipboard("text", "text") { + if (type != ContentType.EDITABLE_ELEMENT) { + testThat( + selectedContent, + {}, + hasShowActionRequest( + FLAG_IS_EDITABLE, + arrayOf( + ACTION_COLLAPSE_TO_START, + ACTION_COLLAPSE_TO_END, + ACTION_COPY, + ACTION_CUT, + ACTION_DELETE, + ACTION_HIDE, + ACTION_PASTE, + ACTION_PASTE_AS_PLAIN_TEXT, + ), + ), + ) + } else { + testThat( + selectedContent, + {}, + hasShowActionRequest( + FLAG_IS_EDITABLE, + arrayOf( + ACTION_COLLAPSE_TO_START, + ACTION_COLLAPSE_TO_END, + ACTION_COPY, + ACTION_CUT, + ACTION_DELETE, + ACTION_HIDE, + ACTION_PASTE, + ), + ), + ) + } + } + } else { + testThat( + selectedContent, + {}, + hasShowActionRequest( + 0, + arrayOf( + ACTION_COPY, + ACTION_HIDE, + ACTION_SELECT_ALL, + ACTION_UNSELECT, + ), + ), + ) + } + } + + @Test fun request_collapsed() = assumingEditable(true) { + withClipboard("text") { + testThat( + collapsedContent, + {}, + hasShowActionRequest( + FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED, + arrayOf(ACTION_HIDE, ACTION_PASTE, ACTION_SELECT_ALL), + ), + ) + } + } + + @Test fun request_noClipboard() = assumingEditable(true) { + withClipboard("") { + testThat( + collapsedContent, + {}, + hasShowActionRequest( + FLAG_IS_EDITABLE or FLAG_IS_COLLAPSED, + arrayOf(ACTION_HIDE, ACTION_SELECT_ALL), + ), + ) + } + } + + @Test fun hide() = testThat(selectedContent, withResponse(ACTION_HIDE), clearsSelection()) + + @Test fun cut() = assumingEditable(true) { + withClipboard("") { + testThat(selectedContent, withResponse(ACTION_CUT), copiesText(), deletesContent()) + } + } + + @Test fun copy() = withClipboard("") { + testThat(selectedContent, withResponse(ACTION_COPY), copiesText()) + } + + @Test fun paste() = assumingEditable(true) { + withClipboard("pasted") { + testThat(selectedContent, withResponse(ACTION_PASTE), changesContentTo("pasted")) + } + } + + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) + @Test + fun pasteAsPlainText() = assumingEditable(true) { + assumeThat("Paste as plain text works on content editable", type, not(equalTo(ContentType.EDITABLE_ELEMENT))) + + withHtmlClipboard("pasted", "pasted") { + testThat(selectedContent, withResponse(ACTION_PASTE_AS_PLAIN_TEXT), changesContentTo("pasted")) + } + } + + @Test fun delete() = assumingEditable(true) { + testThat(selectedContent, withResponse(ACTION_DELETE), deletesContent()) + } + + @Test fun selectAll() { + if (type == ContentType.DIV && !editable) { + // "Select all" for non-editable div means selecting the whole document. + testThat( + selectedContent, + withResponse(ACTION_SELECT_ALL), + changesSelectionTo( + both(containsString(selectedContent.initialContent)) + .and(not(equalTo(selectedContent.initialContent))), + ), + ) + } else { + testThat( + if (editable) collapsedContent else selectedContent, + withResponse(ACTION_SELECT_ALL), + changesSelectionTo(selectedContent.initialContent), + ) + } + } + + @Test fun unselect() = assumingEditable(false) { + testThat(selectedContent, withResponse(ACTION_UNSELECT), clearsSelection()) + } + + @Test fun multipleActions() = assumingEditable(false) { + withClipboard("") { + testThat( + selectedContent, + withResponse(ACTION_COPY, ACTION_UNSELECT), + copiesText(), + clearsSelection(), + ) + } + } + + @Test fun collapseToStart() = assumingEditable(true) { + testThat(selectedContent, withResponse(ACTION_COLLAPSE_TO_START), hasSelectionAt(0)) + } + + @Test fun collapseToEnd() = assumingEditable(true) { + testThat( + selectedContent, + withResponse(ACTION_COLLAPSE_TO_END), + hasSelectionAt(selectedContent.initialContent.length), + ) + } + + @Test fun pagehide() { + // Navigating to another page should hide selection action. + testThat(selectedContent, { mainSession.loadTestPath(HELLO_HTML_PATH) }, clearsSelection()) + } + + @Test fun deactivate() { + // Blurring the window should hide selection action. + testThat(selectedContent, { mainSession.setFocused(false) }, clearsSelection()) + mainSession.setFocused(true) + } + + @NullDelegate(GeckoSession.SelectionActionDelegate::class) + @Test + fun clearDelegate() { + var counter = 0 + mainSession.selectionActionDelegate = object : SelectionActionDelegate { + override fun onHideAction(session: GeckoSession, reason: Int) { + counter++ + } + } + + mainSession.selectionActionDelegate = null + assertThat( + "Hide action should be called when clearing delegate", + counter, + equalTo(1), + ) + } + + @Test fun compareClientRect() { + val jsCssReset = """(function() { + document.querySelector('$id').style.display = "block"; + document.querySelector('$id').style.border = "0"; + document.querySelector('$id').style.padding = "0"; + document.querySelector('$id').offsetHeight; // flush layout + })()""" + val jsBorder10pxPadding10px = """(function() { + document.querySelector('$id').style.display = "block"; + document.querySelector('$id').style.border = "10px solid"; + document.querySelector('$id').style.padding = "10px"; + document.querySelector('$id').offsetHeight; // flush layout + })()""" + val expectedDiff = RectF(10f, 10f, 10f, 10f) // left, top, right, bottom + testClientRect(selectedContent, jsCssReset, jsBorder10pxPadding10px, expectedDiff) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun clipboardReadAllow() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + // Select allow + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate { + @AssertCalled(count = 1) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, + perm: ClipboardPermission, + ): + GeckoResult { + assertThat("URI should match", perm.uri, startsWith(url)) + assertThat( + "Type should match", + perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ), + ) + assertThat("screenPoint should match", perm.screenPoint, equalTo(Point(50, 50))) + return GeckoResult.allow() + } + + @AssertCalled(count = 1, order = [2]) + override fun onAlertPrompt( + session: GeckoSession, + prompt: PromptDelegate.AlertPrompt, + ): + GeckoResult { + assertThat("Message should match", "allow", equalTo(prompt.message)) + result.complete(null) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun clipboardReadDeny() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + // Select deny + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate { + @AssertCalled(count = 1, order = [1]) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, + perm: ClipboardPermission, + ): + GeckoResult? { + assertThat("URI should match", perm.uri, startsWith(url)) + assertThat( + "Type should match", + perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ), + ) + return GeckoResult.deny() + } + + @AssertCalled(count = 1, order = [2]) + override fun onAlertPrompt( + session: GeckoSession, + prompt: PromptDelegate.AlertPrompt, + ): + GeckoResult { + assertThat("Message should match", "deny", equalTo(prompt.message)) + result.complete(null) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun clipboardReadDeactivate() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + val result = GeckoResult() + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowClipboardPermissionRequest( + session: GeckoSession, + perm: ClipboardPermission, + ): + GeckoResult? { + assertThat( + "Type should match", + perm.type, + equalTo(SelectionActionDelegate.PERMISSION_CLIPBOARD_READ), + ) + result.complete(null) + return GeckoResult() + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled + override fun onDismissClipboardPermissionRequest(session: GeckoSession) { + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForPageStop() + } + + /** Interface that defines behavior for a particular type of content */ + private interface SelectedContent { + fun focus() {} + fun select() {} + val initialContent: String + val content: String + val selectionOffsets: Pair + } + + /** Main method that performs test logic. */ + private fun testThat( + content: SelectedContent, + respondingWith: (Selection) -> Unit, + result: (SelectedContent) -> Unit, + vararg sideEffects: (SelectedContent) -> Unit, + ) { + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + content.focus() + + // Show selection actions for collapsed selections, so we can test them. + // Also, always show accessible carets / selection actions for changes due to JS calls. + sessionRule.setPrefsUntilTestEnd( + mapOf( + "geckoview.selection_action.show_on_focus" to true, + "layout.accessiblecaret.script_change_update_mode" to 2, + ), + ) + + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) { + respondingWith(selection) + } + }) + + content.select() + mainSession.waitUntilCalled(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowActionRequest(session: GeckoSession, selection: Selection) { + assertThat( + "Initial content should match", + selection.text, + equalTo(content.initialContent), + ) + } + }) + + result(content) + sideEffects.forEach { it(content) } + } + + private fun testClientRect( + content: SelectedContent, + initialJsA: String, + initialJsB: String, + expectedDiff: RectF, + ) { + // Show selection actions for collapsed selections, so we can test them. + // Also, always show accessible carets / selection actions for changes due to JS calls. + sessionRule.setPrefsUntilTestEnd( + mapOf( + "geckoview.selection_action.show_on_focus" to true, + "layout.accessiblecaret.script_change_update_mode" to 2, + ), + ) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + val requestClientRect: (String) -> RectF = { + mainSession.reload() + mainSession.waitForPageStop() + + mainSession.evaluateJS(it) + content.focus() + + var screenRect = RectF() + content.select() + mainSession.waitUntilCalled(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowActionRequest(session: GeckoSession, selection: Selection) { + screenRect = selection.screenRect!! + } + }) + + screenRect + } + + val screenRectA = requestClientRect(initialJsA) + val screenRectB = requestClientRect(initialJsB) + + val fuzzyEqual = { a: Float, b: Float, e: Float -> Math.abs(a + e - b) <= 1 } + val result = fuzzyEqual(screenRectA.top, screenRectB.top, expectedDiff.top) && + fuzzyEqual(screenRectA.left, screenRectB.left, expectedDiff.left) && + fuzzyEqual(screenRectA.width(), screenRectB.width(), expectedDiff.width()) && + fuzzyEqual(screenRectA.height(), screenRectB.height(), expectedDiff.height()) + + assertThat( + "Selection rect is not at expected location. a$screenRectA b$screenRectB", + result, + equalTo(true), + ) + } + + /** Helpers. */ + + private val clipboard by lazy { + InstrumentationRegistry.getInstrumentation().targetContext.getSystemService(Context.CLIPBOARD_SERVICE) + as ClipboardManager + } + + private fun withClipboard(content: String = "", lambda: () -> Unit) { + val oldClip = clipboard.primaryClip + try { + if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P && content.isEmpty()) { + clipboard.clearPrimaryClip() + } else { + clipboard.setPrimaryClip(ClipData.newPlainText("", content)) + } + + sessionRule.addExternalDelegateUntilTestEnd( + ClipboardManager.OnPrimaryClipChangedListener::class, + clipboard::addPrimaryClipChangedListener, + clipboard::removePrimaryClipChangedListener, + ClipboardManager.OnPrimaryClipChangedListener {}, + ) + lambda() + } finally { + clipboard.setPrimaryClip(oldClip ?: ClipData.newPlainText("", "")) + } + } + + private fun withHtmlClipboard(plainText: String = "", html: String = "", lambda: () -> Unit) { + val oldClip = clipboard.primaryClip + try { + clipboard.setPrimaryClip(ClipData.newHtmlText("", plainText, html)) + + sessionRule.addExternalDelegateUntilTestEnd( + ClipboardManager.OnPrimaryClipChangedListener::class, + clipboard::addPrimaryClipChangedListener, + clipboard::removePrimaryClipChangedListener, + ClipboardManager.OnPrimaryClipChangedListener {}, + ) + lambda() + } finally { + clipboard.setPrimaryClip(oldClip ?: ClipData.newPlainText("", "")) + } + } + + private fun assumingEditable(editable: Boolean, lambda: (() -> Unit)? = null) { + assumeThat( + "Assuming is ${if (editable) "" else "not "}editable", + this.editable, + equalTo(editable), + ) + lambda?.invoke() + } + + /** Behavior objects for different content types */ + + open inner class SelectedDiv( + val id: String, + override val initialContent: String, + ) : SelectedContent { + protected fun selectTo(to: Int) { + mainSession.evaluateJS( + """document.getSelection().setBaseAndExtent( + document.querySelector('$id').firstChild, 0, + document.querySelector('$id').firstChild, $to)""", + ) + } + + override fun select() = selectTo(initialContent.length) + + override val content: String get() { + return mainSession.evaluateJS("document.querySelector('$id').textContent") as String + } + + override val selectionOffsets: Pair get() { + if (mainSession.evaluateJS( + """ + document.getSelection().anchorNode !== document.querySelector('$id').firstChild || + document.getSelection().focusNode !== document.querySelector('$id').firstChild""", + ) as Boolean + ) { + return Pair(-1, -1) + } + val offsets = mainSession.evaluateJS( + """[ + document.getSelection().anchorOffset, + document.getSelection().focusOffset]""", + ) as JSONArray + return Pair(offsets[0] as Int, offsets[1] as Int) + } + } + + inner class CollapsedDiv(id: String) : SelectedDiv(id, "") { + override fun select() = selectTo(0) + } + + open inner class SelectedEditableElement( + val id: String, + override val initialContent: String, + ) : SelectedContent { + override fun focus() { + mainSession.waitForJS("document.querySelector('$id').focus()") + } + + override fun select() { + mainSession.evaluateJS("document.querySelector('$id').select()") + } + + override val content: String get() { + return mainSession.evaluateJS("document.querySelector('$id').value") as String + } + + override val selectionOffsets: Pair get() { + val offsets = mainSession.evaluateJS( + """[ document.querySelector('$id').selectionStart, + |document.querySelector('$id').selectionEnd ] + """.trimMargin(), + ) as JSONArray + return Pair(offsets[0] as Int, offsets[1] as Int) + } + } + + inner class CollapsedEditableElement(id: String) : SelectedEditableElement(id, "") { + override fun select() { + mainSession.evaluateJS("document.querySelector('$id').setSelectionRange(0, 0)") + } + } + + open inner class SelectedFrame( + val id: String, + override val initialContent: String, + ) : SelectedContent { + override fun focus() { + mainSession.evaluateJS("document.querySelector('$id').contentWindow.focus()") + } + + protected fun selectTo(to: Int) { + mainSession.evaluateJS( + """(function() { + var doc = document.querySelector('$id').contentDocument; + var text = doc.body.firstChild; + doc.getSelection().setBaseAndExtent(text, 0, text, $to); + })()""", + ) + } + + override fun select() = selectTo(initialContent.length) + + override val content: String get() { + return mainSession.evaluateJS("document.querySelector('$id').contentDocument.body.textContent") as String + } + + override val selectionOffsets: Pair get() { + val offsets = mainSession.evaluateJS( + """(function() { + var sel = document.querySelector('$id').contentDocument.getSelection(); + var text = document.querySelector('$id').contentDocument.body.firstChild; + if (sel.anchorNode !== text || sel.focusNode !== text) { + return [-1, -1]; + } + return [sel.anchorOffset, sel.focusOffset]; + })()""", + ) as JSONArray + return Pair(offsets[0] as Int, offsets[1] as Int) + } + } + + inner class CollapsedFrame(id: String) : SelectedFrame(id, "") { + override fun select() = selectTo(0) + } + + open inner class SelectedFrameXOrigin( + val id: String, + override val initialContent: String, + ) : SelectedContent { + override fun focus() { + mainSession.evaluateJS("document.querySelector('$id').contentWindow.postMessage({ type: 'focus' }, '*')") + } + + protected fun selectTo(to: Int) { + mainSession.evaluateJS("document.querySelector('$id').contentWindow.postMessage({ type: 'select', length: $to }, '*')") + } + + override fun select() = selectTo(initialContent.length) + + override val content: String get() { + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('message', e => { + resolve(e.data); + }, { once: true }); + document.querySelector('$id').contentDocument.postMessage({ type: 'content' }, '*'); + }); + """, + ) + return promise.value as String + } + + override val selectionOffsets: Pair get() { + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('message', e => { + resolve(e.data); + }, { once: true }); + document.querySelector('$id').contentDocument.postMessage({ type: 'selectedOffset' }, '*'); + }); + """, + ) + val offsets = promise.value as JSONArray + return Pair(offsets[0] as Int, offsets[1] as Int) + } + } + + inner class CollapsedFrameXOrigin(id: String) : SelectedFrameXOrigin(id, "") { + override fun select() = selectTo(0) + } + + /** Lambda for responding with certain actions. */ + + private fun withResponse(vararg actions: String): (Selection) -> Unit { + var responded = false + return { response -> + if (!responded) { + responded = true + actions.forEach { response.execute(it) } + } + } + } + + /** Lambdas for asserting the results of actions. */ + + private fun hasShowActionRequest( + expectedFlags: Int, + expectedActions: Array, + ) = { it: SelectedContent -> + mainSession.forCallbacksDuringWait(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowActionRequest(session: GeckoSession, selection: GeckoSession.SelectionActionDelegate.Selection) { + assertThat( + "Selection text should be valid", + selection.text, + equalTo(it.initialContent), + ) + assertThat( + "Selection flags should be valid", + selection.flags, + equalTo(expectedFlags), + ) + assertThat( + "Selection rect should be valid", + selection.screenRect!!.isEmpty, + equalTo(false), + ) + assertThat( + "Actions must be valid", + selection.availableActions.toTypedArray(), + arrayContainingInAnyOrder(*expectedActions), + ) + } + }) + } + + private fun copiesText() = { it: SelectedContent -> + sessionRule.waitUntilCalled( + ClipboardManager.OnPrimaryClipChangedListener { + assertThat( + "Clipboard should contain correct text", + clipboard.primaryClip?.getItemAt(0)?.text, + hasToString(it.initialContent), + ) + }, + ) + } + + private fun changesSelectionTo(text: String) = changesSelectionTo(equalTo(text)) + + private fun changesSelectionTo(matcher: Matcher) = { _: SelectedContent -> + sessionRule.waitUntilCalled(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onShowActionRequest(session: GeckoSession, selection: Selection) { + assertThat("New selection text should match", selection.text, matcher) + } + }) + } + + private fun clearsSelection() = { _: SelectedContent -> + sessionRule.waitUntilCalled(object : SelectionActionDelegate { + @AssertCalled(count = 1) + override fun onHideAction(session: GeckoSession, reason: Int) { + assertThat( + "Hide reason should be correct", + reason, + equalTo(HIDE_REASON_NO_SELECTION), + ) + } + }) + } + + private fun hasSelectionAt(offset: Int) = hasSelectionAt(offset, offset) + + private fun hasSelectionAt(start: Int, end: Int) = { it: SelectedContent -> + assertThat( + "Selection offsets should match", + it.selectionOffsets, + equalTo(Pair(start, end)), + ) + } + + private fun deletesContent() = changesContentTo("") + + private fun changesContentTo(content: String) = { it: SelectedContent -> + assertThat("Changed content should match", it.content, equalTo(content)) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt new file mode 100644 index 0000000000..50f64301fd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt @@ -0,0 +1,240 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Bundle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import org.mozilla.geckoview.test.util.UiThreadUtils +import java.lang.ref.ReferenceQueue +import java.lang.ref.WeakReference + +@RunWith(AndroidJUnit4::class) +@MediumTest +class SessionLifecycleTest : BaseSessionTest() { + companion object { + val LOGTAG = "SessionLifecycleTest" + } + + @Test fun open_interleaved() { + val session1 = sessionRule.createOpenSession() + val session2 = sessionRule.createOpenSession() + session1.close() + val session3 = sessionRule.createOpenSession() + session2.close() + session3.close() + + mainSession.reload() + mainSession.waitForPageStop() + } + + @Test fun open_repeated() { + for (i in 1..5) { + mainSession.close() + mainSession.open() + } + mainSession.reload() + mainSession.waitForPageStop() + } + + @Test fun open_allowCallsWhileClosed() { + mainSession.close() + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.reload() + + mainSession.open() + mainSession.waitForPageStops(2) + } + + @Test(expected = IllegalStateException::class) + fun open_throwOnAlreadyOpen() { + // Throw exception if retrying to open again; otherwise we would leak the old open window. + mainSession.open() + } + + @ClosedSessionAtStart + @Test + fun restoreRuntimeSettings_noSession() { + val extrasSetting = Bundle(2) + extrasSetting.putInt("test1", 10) + extrasSetting.putBoolean("test2", true) + + val settings = GeckoRuntimeSettings.Builder() + .javaScriptEnabled(false) + .extras(extrasSetting) + .build() + + settings.toParcel { parcel -> + val newSettings = GeckoRuntimeSettings.Builder().build() + newSettings.readFromParcel(parcel) + + assertThat( + "Parceled settings must match", + newSettings.javaScriptEnabled, + equalTo(settings.javaScriptEnabled), + ) + assertThat( + "Parceled settings must match", + newSettings.extras.getInt("test1"), + equalTo(settings.extras.getInt("test1")), + ) + assertThat( + "Parceled settings must match", + newSettings.extras.getBoolean("test2"), + equalTo(settings.extras.getBoolean("test2")), + ) + } + } + + @Test fun collectClosed() { + // We can't use a normal scoped function like `run` because + // those are inlined, which leaves a local reference. + fun createSession(): QueuedWeakReference { + return QueuedWeakReference(GeckoSession()) + } + + waitUntilCollected(createSession()) + } + + @Test fun collectAfterClose() { + fun createSession(): QueuedWeakReference { + val s = GeckoSession() + s.open(sessionRule.runtime) + s.close() + return QueuedWeakReference(s) + } + + waitUntilCollected(createSession()) + } + + @Test fun collectOpen() { + fun createSession(): QueuedWeakReference { + val s = GeckoSession() + s.open(sessionRule.runtime) + return QueuedWeakReference(s) + } + + waitUntilCollected(createSession()) + } + + // Waits for 4 requestAnimationFrame calls and computes rate + private fun computeRequestAnimationFrameRate(session: GeckoSession): Double { + return session.evaluateJS( + """ + new Promise(resolve => { + let start = 0; + let frames = 0; + const ITERATIONS = 4; + function raf() { + if (frames === 0) { + start = window.performance.now(); + } + if (frames === ITERATIONS) { + resolve((window.performance.now() - start) / ITERATIONS); + } + frames++; + window.requestAnimationFrame(raf); + } + window.requestAnimationFrame(raf); + }); + """, + ) as Double + } + + @WithDisplay(width = 100, height = 100) + @Test + fun asyncScriptsSuspendedWhileInactive() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "privacy.reduceTimerPrecision" to false, + // This makes the throttled frame rate 4 times faster than normal, + // so this test doesn't time out. Should still be significantly slower tha + // the active frame rate so we can measure the effects + "layout.throttled_frame_rate" to 4, + ), + ) + + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + assertThat("docShell should start active", mainSession.active, equalTo(true)) + + // Deactivate the GeckoSession and confirm that rAF/setTimeout/etc callbacks do not run + mainSession.setActive(false) + assertThat( + "docShell shouldn't be active after calling setActive(false)", + mainSession.active, + equalTo(false), + ) + + mainSession.evaluateJS( + """ + function fail() { + document.documentElement.style.backgroundColor = 'green'; + } + setTimeout(fail, 1); + fetch("missing.html").catch(fail); + """, + ) + + var rafRate = computeRequestAnimationFrameRate(mainSession) + assertThat( + "requestAnimationFrame should be called about once a second", + rafRate, + greaterThan(450.0), + ) + assertThat( + "requestAnimationFrame should be called about once a second", + rafRate, + lessThan(10000.0), + ) + + val isNotGreen = mainSession.evaluateJS( + "document.documentElement.style.backgroundColor !== 'green'", + ) as Boolean + assertThat("timeouts have not run yet", isNotGreen, equalTo(true)) + + // Reactivate the GeckoSession and confirm that rAF/setTimeout/etc callbacks now run + mainSession.setActive(true) + assertThat( + "docShell should be active after calling setActive(true)", + mainSession.active, + equalTo(true), + ) + + // At 60fps, once a frame is about 16.6 ms + rafRate = computeRequestAnimationFrameRate(mainSession) + assertThat( + "requestAnimationFrame should be called about once a frame", + rafRate, + lessThan(60.0), + ) + assertThat( + "requestAnimationFrame should be called about once a frame", + rafRate, + greaterThan(5.0), + ) + } + + private fun waitUntilCollected(ref: QueuedWeakReference<*>) { + UiThreadUtils.waitForCondition({ + Runtime.getRuntime().gc() + ref.queue.poll() != null + }, sessionRule.timeoutMillis) + } + + class QueuedWeakReference @JvmOverloads constructor( + obj: T, + var queue: ReferenceQueue = ReferenceQueue(), + ) : WeakReference(obj, queue) +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt new file mode 100644 index 0000000000..592aa442f8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt @@ -0,0 +1,874 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_DISABLED +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT +import org.mozilla.geckoview.ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT +import org.mozilla.geckoview.GeckoSessionSettings +import org.mozilla.geckoview.StorageController + +@RunWith(AndroidJUnit4::class) +@MediumTest +class StorageControllerTest : BaseSessionTest() { + + private val storageController + get() = sessionRule.runtime.storageController + + @Test fun clearData() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'test'); + document.cookie = 'ctx=test'; + """, + ) + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearData( + StorageController.ClearFlags.ALL, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("null"), + ) + } + + @Test fun clearDataFlags() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'test'); + document.cookie = 'ctx=test'; + """, + ) + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearData( + StorageController.ClearFlags.COOKIES, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + // With LSNG disabled, storage is also cleared when cookies are, + // see bug 1592752. + if (sessionRule.getPrefs("dom.storage.enable_unsupported_legacy_implementation")[0] as Boolean == false) { + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + } else { + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + } + + assertThat( + "Cookie value should match", + cookie, + equalTo("null"), + ) + + mainSession.evaluateJS( + """ + document.cookie = 'ctx=test'; + """, + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearData( + StorageController.ClearFlags.DOM_STORAGES, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'test'); + """, + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearData( + StorageController.ClearFlags.SITE_DATA, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("null"), + ) + } + + @Test fun clearDataFromHost() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'test'); + document.cookie = 'ctx=test'; + """, + ) + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearDataFromHost( + "test.com", + StorageController.ClearFlags.ALL, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearDataFromHost( + "example.com", + StorageController.ClearFlags.ALL, + ), + ) + + localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("null"), + ) + } + + @Test fun clearDataFromBaseDomain() { + var domains = arrayOf("example.com", "test1.example.com") + + // Set site data for both root domain and subdomain. + for (domain in domains) { + mainSession.loadUri("https://" + domain) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + localStorage.setItem('ctx', 'test'); + document.cookie = 'ctx=test'; + """, + ) + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + } + + // Clear data for an unrelated domain. The test data should still be + // set. + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearDataFromBaseDomain( + "test.com", + StorageController.ClearFlags.ALL, + ), + ) + + for (domain in domains) { + mainSession.loadUri("https://" + domain) + sessionRule.waitForPageStop() + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("test"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("ctx=test"), + ) + } + + // Finally, clear the test data by base domain. This should clear both, + // the root domain and the subdomain. + sessionRule.waitForResult( + sessionRule.runtime.storageController.clearDataFromBaseDomain( + "example.com", + StorageController.ClearFlags.ALL, + ), + ) + + for (domain in domains) { + mainSession.loadUri("https://" + domain) + sessionRule.waitForPageStop() + + var localStorage = mainSession.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + var cookie = mainSession.evaluateJS( + """ + document.cookie || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + assertThat( + "Cookie value should match", + cookie, + equalTo("null"), + ) + } + } + + private fun testSessionContext(baseSettings: GeckoSessionSettings) { + val session1 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(baseSettings) + .contextId("1") + .build(), + ) + session1.loadUri("https://example.com") + session1.waitForPageStop() + + session1.evaluateJS( + """ + localStorage.setItem('ctx', '1'); + """, + ) + + var localStorage = session1.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("1"), + ) + + session1.reload() + session1.waitForPageStop() + + localStorage = session1.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("1"), + ) + + val session2 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(baseSettings) + .contextId("2") + .build(), + ) + + session2.loadUri("https://example.com") + session2.waitForPageStop() + + localStorage = session2.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should be null", + localStorage, + equalTo("null"), + ) + + session2.evaluateJS( + """ + localStorage.setItem('ctx', '2'); + """, + ) + + localStorage = session2.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("2"), + ) + + session1.loadUri("https://example.com") + session1.waitForPageStop() + + localStorage = session1.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("1"), + ) + + val session3 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(baseSettings) + .contextId("2") + .build(), + ) + + session3.loadUri("https://example.com") + session3.waitForPageStop() + + localStorage = session3.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("2"), + ) + } + + @Test fun sessionContext() { + testSessionContext(mainSession.settings) + } + + @Test fun sessionContextPrivateMode() { + testSessionContext( + GeckoSessionSettings.Builder(mainSession.settings) + .usePrivateMode(true) + .build(), + ) + } + + @Test fun clearDataForSessionContext() { + val session1 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .build(), + ) + session1.loadUri("https://example.com") + session1.waitForPageStop() + + session1.evaluateJS( + """ + localStorage.setItem('ctx', '1'); + """, + ) + + var localStorage = session1.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("1"), + ) + + session1.close() + + val session2 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("2") + .build(), + ) + + session2.loadUri("https://example.com") + session2.waitForPageStop() + + session2.evaluateJS( + """ + localStorage.setItem('ctx', '2'); + """, + ) + + localStorage = session2.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("2"), + ) + + session2.close() + + sessionRule.runtime.storageController.clearDataForSessionContext("1") + + val session3 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .build(), + ) + + session3.loadUri("https://example.com") + session3.waitForPageStop() + + localStorage = session3.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("null"), + ) + + val session4 = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("2") + .build(), + ) + + session4.loadUri("https://example.com") + session4.waitForPageStop() + + localStorage = session4.evaluateJS( + """ + localStorage.getItem('ctx') || 'null' + """, + ) as String + + assertThat( + "Local storage value should match", + localStorage, + equalTo("2"), + ) + } + + @Test fun setCookieBannerModeForDomain() { + val contentBlocking = sessionRule.runtime.settings.contentBlocking + contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_REJECT + + val session = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .build(), + ) + session.loadUri("https://example.com") + session.waitForPageStop() + + var mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + false, + ), + ) + + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT), + ) + + sessionRule.waitForResult( + storageController.setCookieBannerModeForDomain( + "https://example.com", + COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, + false, + ), + ) + + mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + false, + ), + ) + + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT), + ) + } + + @Test + fun setCookieBannerModeAndPersistInPrivateBrowsingForDomain() { + val contentBlocking = sessionRule.runtime.settings.contentBlocking + contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_REJECT + + val session = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .usePrivateMode(true) + .build(), + ) + session.loadUri("https://example.com") + session.waitForPageStop() + + var mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT), + ) + + sessionRule.waitForResult( + storageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain( + "https://example.com", + COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, + ), + ) + + mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT), + ) + + session.close() + + mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT), + ) + } + + @Test + fun getCookieBannerModeForDomain() { + val contentBlocking = sessionRule.runtime.settings.contentBlocking + contentBlocking.cookieBannerMode = COOKIE_BANNER_MODE_DISABLED + + val session = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .build(), + ) + session.loadUri("https://example.com") + session.waitForPageStop() + + try { + val mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + false, + ), + ) + assertThat( + "Cookie banner mode should match", + mode, + equalTo(COOKIE_BANNER_MODE_DISABLED), + ) + } catch (e: Exception) { + assertThat( + "Cookie banner mode should match", + e.message, + containsString("The cookie banner handling service is not available"), + ) + } + } + + @Test fun removeCookieBannerModeForDomain() { + val contentBlocking = sessionRule.runtime.settings.contentBlocking + contentBlocking.cookieBannerModePrivateBrowsing = COOKIE_BANNER_MODE_REJECT + sessionRule.setPrefsUntilTestEnd(mapOf("cookiebanners.service.mode.privateBrowsing" to COOKIE_BANNER_MODE_REJECT)) + + val session = sessionRule.createOpenSession( + GeckoSessionSettings.Builder(mainSession.settings) + .contextId("1") + .build(), + ) + session.loadUri("https://example.com") + session.waitForPageStop() + + sessionRule.waitForResult( + storageController.setCookieBannerModeForDomain( + "https://example.com", + COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, + true, + ), + ) + + var mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + assertThat( + "Cookie banner mode should match $COOKIE_BANNER_MODE_REJECT_OR_ACCEPT but it is $mode", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT_OR_ACCEPT), + ) + + sessionRule.waitForResult( + storageController.removeCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + mode = sessionRule.waitForResult( + storageController.getCookieBannerModeForDomain( + "https://example.com", + true, + ), + ) + + assertThat( + "Cookie banner mode should match $COOKIE_BANNER_MODE_REJECT but it is $mode", + mode, + equalTo(COOKIE_BANNER_MODE_REJECT), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt new file mode 100644 index 0000000000..42286c47a7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.CoreMatchers.equalTo +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.RuntimeTelemetry +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@RunWith(AndroidJUnit4::class) +@MediumTest +class TelemetryTest : BaseSessionTest() { + @Test + fun testOnTelemetryReceived() { + // Let's make sure we batch the telemetry calls. + sessionRule.setPrefsUntilTestEnd( + mapOf("toolkit.telemetry.geckoview.batchDurationMS" to 100000), + ) + + val expectedHistograms = listOf(401, 12, 1, 109, 2000) + val receivedHistograms = mutableListOf() + val histogram = GeckoResult() + val stringScalar = GeckoResult() + val booleanScalar = GeckoResult() + val longScalar = GeckoResult() + + sessionRule.addExternalDelegateUntilTestEnd( + RuntimeTelemetry.Delegate::class, + sessionRule::setTelemetryDelegate, + { sessionRule.setTelemetryDelegate(null) }, + object : RuntimeTelemetry.Delegate { + @AssertCalled + override fun onHistogram(metric: RuntimeTelemetry.Histogram) { + if (metric.name != "TELEMETRY_TEST_STREAMING") { + return + } + + assertThat( + "The histogram should not be categorical", + metric.isCategorical, + equalTo(false), + ) + + receivedHistograms.addAll(metric.value.toList()) + + if (receivedHistograms.size == expectedHistograms.size) { + histogram.complete(null) + } + } + + @AssertCalled + override fun onStringScalar(metric: RuntimeTelemetry.Metric) { + if (metric.name != "telemetry.test.string_kind") { + return + } + + assertThat( + "Metric value should match", + metric.value, + equalTo("test scalar"), + ) + + stringScalar.complete(null) + } + + @AssertCalled + override fun onBooleanScalar(metric: RuntimeTelemetry.Metric) { + if (metric.name != "telemetry.test.boolean_kind") { + return + } + + assertThat( + "Metric value should match", + metric.value, + equalTo(true), + ) + + booleanScalar.complete(null) + } + + @AssertCalled + override fun onLongScalar(metric: RuntimeTelemetry.Metric) { + if (metric.name != "telemetry.test.unsigned_int_kind") { + return + } + + assertThat( + "Metric value should match", + metric.value, + equalTo(1234L), + ) + + longScalar.complete(null) + } + }, + ) + + sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[0]) + sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[1]) + sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[2]) + sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[3]) + + sessionRule.setScalar("telemetry.test.boolean_kind", true) + sessionRule.setScalar("telemetry.test.unsigned_int_kind", 1234) + sessionRule.setScalar("telemetry.test.string_kind", "test scalar") + + // Forces flushing telemetry data at next histogram. + sessionRule.setPrefsUntilTestEnd( + mapOf("toolkit.telemetry.geckoview.batchDurationMS" to 0), + ) + sessionRule.addHistogram("TELEMETRY_TEST_STREAMING", expectedHistograms[4]) + + sessionRule.waitForResult(histogram) + sessionRule.waitForResult(stringScalar) + sessionRule.waitForResult(booleanScalar) + sessionRule.waitForResult(longScalar) + + assertThat( + "Metric values should match", + receivedHistograms, + equalTo(expectedHistograms), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java new file mode 100644 index 0000000000..ee503af732 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java @@ -0,0 +1,35 @@ +package org.mozilla.geckoview.test; + +import java.io.File; +import java.io.IOException; +import org.junit.rules.ExternalResource; +import org.junit.rules.TemporaryFolder; +import org.mozilla.geckoview.test.rule.TestHarnessException; + +/** Lazily provides a temporary profile folder for tests. */ +public class TemporaryProfileRule extends ExternalResource { + TemporaryFolder mTemporaryFolder; + File mProfileFolder; + + @Override + protected void after() { + if (mTemporaryFolder != null) { + mTemporaryFolder.delete(); + mProfileFolder = null; + } + } + + public File get() { + if (mProfileFolder == null) { + mTemporaryFolder = new TemporaryFolder(); + try { + mTemporaryFolder.create(); + mProfileFolder = mTemporaryFolder.newFolder("test-profile"); + } catch (IOException ex) { + throw new TestHarnessException(ex); + } + } + + return mProfileFolder; + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java new file mode 100644 index 0000000000..787448a859 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.os.ParcelFileDescriptor.AutoCloseOutputStream; +import android.util.Log; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** TestContentProvider provides any data via content resolver by content:// */ +public class TestContentProvider extends ContentProvider { + private static final String LOGTAG = "TestContentProvider"; + private static byte[] sTestData; + private static String sMimeType; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public String getType(final Uri uri) { + return sMimeType; + } + + @Override + public Cursor query( + final Uri uri, + final String[] projection, + final String selection, + final String[] selectionArgs, + final String sortOrder) { + return null; + } + + @Override + public Uri insert(final Uri uri, final ContentValues values) { + return null; + } + + @Override + public int delete(final Uri uri, final String selection, final String[] selectionArgs) { + return 0; + } + + @Override + public int update( + final Uri uri, + final ContentValues values, + final String selection, + final String[] selectionArgs) { + return 0; + } + + @Override + public ParcelFileDescriptor openFile(final Uri uri, final String mode) + throws FileNotFoundException { + if (sTestData == null) { + throw new FileNotFoundException("No test data for: " + uri); + } + + ParcelFileDescriptor[] pipe = null; + AutoCloseOutputStream outputStream = null; + + try { + try { + pipe = ParcelFileDescriptor.createPipe(); + outputStream = new AutoCloseOutputStream(pipe[1]); + outputStream.write(sTestData); + outputStream.flush(); + return pipe[0]; + } finally { + if (outputStream != null) { + outputStream.close(); + } + if (pipe != null && pipe[1] != null) { + pipe[1].close(); + } + } + } catch (IOException e) { + Log.e(LOGTAG, "openFile throws an I/O exception: ", e); + } + + throw new FileNotFoundException("Could not open uri for: " + uri); + } + + /** + * Set test data that is used from content resolver. + * + * @param data test data + * @param mimeType A mime type of test data. + */ + public static void setTestData(final byte[] data, final String mimeType) { + sTestData = data; + sMimeType = mimeType; + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java new file mode 100644 index 0000000000..32917ac25b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java @@ -0,0 +1,281 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import java.io.File; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.test.util.UiThreadUtils; + +public class TestCrashHandler extends Service { + private static final int MSG_EVAL_NEXT_CRASH_DUMP = 1; + private static final int MSG_CRASH_DUMP_EVAL_RESULT = 2; + private static final String LOGTAG = "TestCrashHandler"; + + public static final class EvalResult { + private static final String BUNDLE_KEY_RESULT = "TestCrashHandler.EvalResult.mResult"; + private static final String BUNDLE_KEY_MSG = "TestCrashHandler.EvalResult.mMsg"; + + public EvalResult(final boolean result, final String msg) { + mResult = result; + mMsg = msg; + } + + public EvalResult(final Bundle bundle) { + mResult = bundle.getBoolean(BUNDLE_KEY_RESULT, false); + mMsg = bundle.getString(BUNDLE_KEY_MSG); + } + + public Bundle asBundle() { + final Bundle bundle = new Bundle(); + bundle.putBoolean(BUNDLE_KEY_RESULT, mResult); + bundle.putString(BUNDLE_KEY_MSG, mMsg); + return bundle; + } + + public boolean mResult; + public String mMsg; + } + + public static final class Client { + private static final String LOGTAG = "TestCrashHandler.Client"; + + private class Receiver extends Handler { + public Receiver(final Looper looper) { + super(looper); + } + + @Override + public void handleMessage(final Message msg) { + if (msg.what == MSG_CRASH_DUMP_EVAL_RESULT) { + setEvalResult(new EvalResult(msg.getData())); + return; + } + + super.handleMessage(msg); + } + } + + private Receiver mReceiver; + private boolean mDoUnbind = false; + private Messenger mService = null; + private Messenger mMessenger; + private Context mContext; + private HandlerThread mThread; + private EvalResult mResult = null; + + private ServiceConnection mConnection = + new ServiceConnection() { + @Override + public void onServiceConnected(final ComponentName className, final IBinder service) { + mService = new Messenger(service); + } + + @Override + public void onServiceDisconnected(final ComponentName className) { + disconnect(); + } + }; + + public Client(final Context context) { + mContext = context; + mThread = new HandlerThread("TestCrashHandler.Client"); + mThread.start(); + mReceiver = new Receiver(mThread.getLooper()); + mMessenger = new Messenger(mReceiver); + } + + /** + * Tests should call this to notify the crash handler that the next crash it sees is intentional + * and that its intent should be checked for correctness. + * + * @param expectedProcessType The type of process the incoming crash is expected to be for. + */ + public void setEvalNextCrashDump(final String expectedProcessType) { + setEvalResult(null); + mReceiver.post( + new Runnable() { + @Override + public void run() { + final Bundle bundle = new Bundle(); + bundle.putString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, expectedProcessType); + final Message msg = Message.obtain(null, MSG_EVAL_NEXT_CRASH_DUMP, bundle); + msg.replyTo = mMessenger; + + try { + mService.send(msg); + } catch (final RemoteException e) { + throw new RuntimeException(e.getMessage()); + } + } + }); + } + + public boolean connect(final long timeoutMillis) { + final Intent intent = new Intent(mContext, TestCrashHandler.class); + mDoUnbind = + mContext.bindService( + intent, mConnection, Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT); + if (!mDoUnbind) { + return false; + } + + UiThreadUtils.waitForCondition(() -> mService != null, timeoutMillis); + + return mService != null; + } + + public void disconnect() { + if (mDoUnbind) { + mContext.unbindService(mConnection); + mService = null; + mDoUnbind = false; + } + mThread.quitSafely(); + } + + private synchronized void setEvalResult(final EvalResult result) { + mResult = result; + } + + private synchronized EvalResult getEvalResult() { + return mResult; + } + + /** + * Tests should call this method after initiating the intentional crash to wait for the result + * from the crash handler. + * + * @param timeoutMillis timeout in milliseconds + * @return EvalResult containing the boolean result of the test and an error message. + */ + public EvalResult getEvalResult(final long timeoutMillis) { + UiThreadUtils.waitForCondition(() -> getEvalResult() != null, timeoutMillis); + return getEvalResult(); + } + } + + private static final class MessageHandler extends Handler { + private Messenger mReplyToMessenger; + private String mExpectedProcessType; + + MessageHandler() {} + + @Override + public void handleMessage(final Message msg) { + if (msg.what == MSG_EVAL_NEXT_CRASH_DUMP) { + mReplyToMessenger = msg.replyTo; + Bundle bundle = (Bundle) msg.obj; + mExpectedProcessType = bundle.getString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE); + return; + } + + super.handleMessage(msg); + } + + public void reportResult(final EvalResult result) { + if (mReplyToMessenger == null) { + return; + } + + final Message msg = Message.obtain(null, MSG_CRASH_DUMP_EVAL_RESULT); + msg.setData(result.asBundle()); + + try { + mReplyToMessenger.send(msg); + } catch (final RemoteException e) { + throw new RuntimeException(e.getMessage()); + } + + mReplyToMessenger = null; + } + + public String getExpectedProcessType() { + return mExpectedProcessType; + } + } + + private Messenger mMessenger; + private MessageHandler mMsgHandler; + + public TestCrashHandler() {} + + private EvalResult evalCrashInfo(final Intent intent) { + if (!intent.getAction().equals(GeckoRuntime.ACTION_CRASHED)) { + return new EvalResult(false, "Action should match"); + } + + final File dumpFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH)); + final boolean dumpFileExists = dumpFile.exists(); + dumpFile.delete(); + + final File extrasFile = new File(intent.getStringExtra(GeckoRuntime.EXTRA_EXTRAS_PATH)); + final boolean extrasFileExists = extrasFile.exists(); + extrasFile.delete(); + + if (!dumpFileExists) { + return new EvalResult(false, "Dump file should exist"); + } + + if (!extrasFileExists) { + return new EvalResult(false, "Extras file should exist"); + } + + final String expectedProcessType = mMsgHandler.getExpectedProcessType(); + final String processType = intent.getStringExtra(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE); + if (processType == null) { + return new EvalResult(false, "Intent missing process type"); + } + if (!processType.equals(expectedProcessType)) { + return new EvalResult( + false, "Expected process type " + expectedProcessType + ", found " + processType); + } + + return new EvalResult(true, "Crash Dump OK"); + } + + @Override + public synchronized int onStartCommand(final Intent intent, final int flags, final int startId) { + if (mMsgHandler != null) { + mMsgHandler.reportResult(evalCrashInfo(intent)); + // We must manually call stopSelf() here to ensure the Service gets killed once the client + // unbinds. If we don't, then when the next client attempts to bind for a different test, + // onBind() will not be called, and mMsgHandler will not get set. + stopSelf(); + return Service.START_NOT_STICKY; + } + + // We don't want to do anything, this handler only exists + // so we produce a crash dump which is picked up by the + // test harness. + System.exit(0); + return Service.START_NOT_STICKY; + } + + @Override + public synchronized IBinder onBind(final Intent intent) { + mMsgHandler = new MessageHandler(); + mMessenger = new Messenger(mMsgHandler); + return mMessenger.getBinder(); + } + + @Override + public synchronized boolean onUnbind(final Intent intent) { + mMsgHandler = null; + mMessenger = null; + return false; + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java new file mode 100644 index 0000000000..90db5b88f2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java @@ -0,0 +1,404 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule; + +public class TestRuntimeService extends Service + implements GeckoSession.ProgressDelegate, GeckoRuntime.Delegate { + // Used by the client to register themselves + public static final int MESSAGE_REGISTER = 1; + // Sent when the first page load completes + public static final int MESSAGE_INIT_COMPLETE = 2; + // Sent when GeckoRuntime exits + public static final int MESSAGE_QUIT = 3; + // Reload current session + public static final int MESSAGE_RELOAD = 4; + // Load URI in current session + public static final int MESSAGE_LOAD_URI = 5; + // Receive a reply for a message + public static final int MESSAGE_REPLY = 6; + // Execute action on the remote service + public static final int MESSAGE_PAGE_STOP = 7; + + // Used by clients to know the first safe ID that can be used + // for additional message types + public static final int FIRST_SAFE_MESSAGE = MESSAGE_PAGE_STOP + 1; + + // Generic service instances + public static final class instance0 extends TestRuntimeService {} + + public static final class instance1 extends TestRuntimeService {} + + protected GeckoRuntime mRuntime; + protected GeckoSession mSession; + protected GeckoBundle mTestData; + + private Messenger mClient; + + private class TestHandler extends Handler { + @Override + public void handleMessage(@NonNull final Message msg) { + final Bundle msgData = msg.getData(); + final GeckoBundle data = + msgData != null ? GeckoBundle.fromBundle(msgData.getBundle("data")) : null; + final String id = msgData != null ? msgData.getString("id") : null; + + switch (msg.what) { + case MESSAGE_REGISTER: + mClient = msg.replyTo; + return; + case MESSAGE_QUIT: + // Unceremoniously exit + System.exit(0); + return; + case MESSAGE_RELOAD: + mSession.reload(); + break; + case MESSAGE_LOAD_URI: + mSession.loadUri(data.getString("uri")); + break; + default: + { + final GeckoResult result = + TestRuntimeService.this.handleMessage(msg.what, data); + if (result != null) { + result.accept( + bundle -> { + final GeckoBundle reply = new GeckoBundle(); + reply.putString("id", id); + reply.putBundle("data", bundle); + TestRuntimeService.this.sendMessage(MESSAGE_REPLY, reply); + }); + } + return; + } + } + } + } + + final Messenger mMessenger = new Messenger(new TestHandler()); + + @Override + public void onShutdown() { + sendMessage(MESSAGE_QUIT); + } + + protected void sendMessage(final int message) { + sendMessage(message, null); + } + + protected void sendMessage(final int message, final GeckoBundle bundle) { + if (mClient == null) { + throw new IllegalStateException("Service is not connected yet!"); + } + + Message msg = Message.obtain(null, message); + msg.replyTo = mMessenger; + if (bundle != null) { + msg.setData(bundle.toBundle()); + } + + try { + mClient.send(msg); + } catch (RemoteException ex) { + throw new RuntimeException(ex); + } + } + + private boolean mFirstPageStop = true; + + @Override + public void onPageStop(@NonNull final GeckoSession session, final boolean success) { + // Notify the subclass that the session is ready to use + if (success && mFirstPageStop) { + onSessionReady(session); + mFirstPageStop = false; + sendMessage(MESSAGE_INIT_COMPLETE); + } else { + sendMessage(MESSAGE_PAGE_STOP); + } + } + + protected void onSessionReady(final GeckoSession session) {} + + @Override + public void onDestroy() { + // Sometimes the service doesn't die on it's own so we need to kill it here. + System.exit(0); + } + + @Nullable + @Override + public IBinder onBind(final Intent intent) { + // Request to be killed as soon as the client unbinds. + stopSelf(); + + if (mRuntime != null) { + // We only expect one client + throw new RuntimeException("Multiple clients !?"); + } + + mRuntime = createRuntime(getApplicationContext(), intent); + mRuntime.setDelegate(this); + + if (intent.hasExtra("test-data")) { + mTestData = GeckoBundle.fromBundle(intent.getBundleExtra("test-data")); + } + + mSession = createSession(intent); + mSession.setProgressDelegate(this); + mSession.open(mRuntime); + + return mMessenger.getBinder(); + } + + /** Override this to handle custom messages. */ + protected GeckoResult handleMessage(final int messageId, final GeckoBundle data) { + return null; + } + + /** Override this to change the default runtime */ + protected GeckoRuntime createRuntime( + final @NonNull Context context, final @NonNull Intent intent) { + return GeckoRuntime.create( + context, new GeckoRuntimeSettings.Builder().extras(intent.getExtras()).build()); + } + + /** Override this to change the default session */ + protected GeckoSession createSession(final Intent intent) { + return new GeckoSession(); + } + + /** + * Starts GeckoRuntime in the process given in input, and waits for the MESSAGE_INIT_COMPLETE + * event that's fired when the first GeckoSession receives the onPageStop event. + * + *

    We wait for a page load to make sure that everything started up correctly (as opposed to + * quitting during the startup procedure). + */ + public static class RuntimeInstance { + public boolean isConnected = false; + public GeckoResult disconnected = new GeckoResult<>(); + public GeckoResult started = new GeckoResult<>(); + public GeckoResult quitted = new GeckoResult<>(); + public final Context context; + public final Class service; + + private final File mProfileFolder; + private final GeckoBundle mTestData; + private final ClientHandler mClientHandler = new ClientHandler(); + private Messenger mMessenger; + private Messenger mServiceMessenger; + private GeckoResult mPageStop = null; + + private Map> mPendingMessages = new HashMap<>(); + + protected RuntimeInstance( + final Context context, final Class service, final File profileFolder) { + this(context, service, profileFolder, null); + } + + protected RuntimeInstance( + final Context context, + final Class service, + final File profileFolder, + final GeckoBundle testData) { + this.context = context; + this.service = service; + mProfileFolder = profileFolder; + mTestData = testData; + } + + public static RuntimeInstance start( + final Context context, final Class service, final File profileFolder) { + RuntimeInstance instance = new RuntimeInstance<>(context, service, profileFolder); + instance.sendIntent(); + return instance; + } + + class ClientHandler extends Handler implements ServiceConnection { + @Override + public void handleMessage(@NonNull Message msg) { + switch (msg.what) { + case MESSAGE_INIT_COMPLETE: + started.complete(null); + break; + case MESSAGE_QUIT: + quitted.complete(null); + // No reason to keep the service around anymore + context.unbindService(mClientHandler); + break; + case MESSAGE_REPLY: + final String messageId = msg.getData().getString("id"); + final Bundle data = msg.getData().getBundle("data"); + mPendingMessages.remove(messageId).complete(GeckoBundle.fromBundle(data)); + break; + case MESSAGE_PAGE_STOP: + if (mPageStop != null) { + mPageStop.complete(null); + mPageStop = null; + } + break; + default: + RuntimeInstance.this.handleMessage(msg); + break; + } + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + mMessenger = new Messenger(mClientHandler); + mServiceMessenger = new Messenger(binder); + isConnected = true; + + RuntimeInstance.this.sendMessage(MESSAGE_REGISTER); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + isConnected = false; + context.unbindService(this); + disconnected.complete(null); + } + } + + /** Override this to handle additional messages. */ + protected void handleMessage(Message msg) {} + + /** Override to modify the intent sent to the service */ + protected Intent createIntent(final Context context) { + return new Intent(context, service); + } + + private GeckoResult sendMessageInternal( + final int message, final GeckoBundle bundle, final GeckoResult result) { + if (!isConnected) { + throw new IllegalStateException("Service is not connected yet!"); + } + + final String messageId = UUID.randomUUID().toString(); + GeckoBundle data = new GeckoBundle(); + data.putString("id", messageId); + if (bundle != null) { + data.putBundle("data", bundle); + } + + Message msg = Message.obtain(null, message); + msg.replyTo = mMessenger; + msg.setData(data.toBundle()); + + if (result != null) { + mPendingMessages.put(messageId, result); + } + + try { + mServiceMessenger.send(msg); + } catch (RemoteException ex) { + throw new RuntimeException(ex); + } + + return result; + } + + private GeckoResult waitForPageStop() { + if (mPageStop == null) { + mPageStop = new GeckoResult<>(); + } + return mPageStop; + } + + protected GeckoResult query(final int message) { + return query(message, null); + } + + protected GeckoResult query(final int message, final GeckoBundle bundle) { + final GeckoResult result = new GeckoResult<>(); + return sendMessageInternal(message, bundle, result); + } + + protected void sendMessage(final int message) { + sendMessage(message, null); + } + + protected void sendMessage(final int message, final GeckoBundle bundle) { + sendMessageInternal(message, bundle, null); + } + + protected void sendIntent() { + final Intent intent = createIntent(context); + intent.putExtra("args", "-profile " + mProfileFolder.getAbsolutePath()); + if (mTestData != null) { + intent.putExtra("test-data", mTestData.toBundle()); + } + context.bindService(intent, mClientHandler, Context.BIND_AUTO_CREATE); + } + + /** + * Quits the current runtime. + * + * @return a {@link GeckoResult} that is resolved when the service fully disconnects. + */ + public GeckoResult quit() { + sendMessage(MESSAGE_QUIT); + return disconnected; + } + + /** + * Reloads the current session. + * + * @return A {@link GeckoResult} that is resolved when the page is fully reloaded. + */ + public GeckoResult reload() { + sendMessage(MESSAGE_RELOAD); + return waitForPageStop(); + } + + /** + * Load a test path in the current session. + * + * @return A {@link GeckoResult} that is resolved when the page is fully loaded. + */ + public GeckoResult loadTestPath(final String path) { + return loadUri(GeckoSessionTestRule.TEST_ENDPOINT + path); + } + + /** + * Load an arbitrary URI in the current session. + * + * @return A {@link GeckoResult} that is resolved when the page is fully loaded. + */ + public GeckoResult loadUri(final String uri) { + return started.then( + unused -> { + final GeckoBundle data = new GeckoBundle(1); + data.putString("uri", uri); + sendMessage(MESSAGE_LOAD_URI, data); + return waitForPageStop(); + }); + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt new file mode 100644 index 0000000000..99cc30cd38 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt @@ -0,0 +1,1407 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.content.ClipDescription +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.text.InputType +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputContentInfo +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameter +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.TextInputDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@MediumTest +@RunWith(Parameterized::class) +class TextInputDelegateTest : BaseSessionTest() { + // "parameters" needs to be a static field, so it has to be in a companion object. + companion object { + @get:Parameterized.Parameters(name = "{0}") + @JvmStatic + val parameters: List> = listOf( + arrayOf("#input"), + arrayOf("#textarea"), + arrayOf("#contenteditable"), + arrayOf("#designmode"), + ) + } + + @field:Parameter(0) + @JvmField + var id: String = "" + + private var textContent: String + get() = when (id) { + "#contenteditable" -> mainSession.evaluateJS("document.querySelector('$id').textContent") + "#designmode" -> mainSession.evaluateJS("document.querySelector('$id').contentDocument.body.textContent") + else -> mainSession.evaluateJS("document.querySelector('$id').value") + } as String + set(content) { + when (id) { + "#contenteditable" -> mainSession.evaluateJS("document.querySelector('$id').textContent = '$content'") + "#designmode" -> mainSession.evaluateJS( + "document.querySelector('$id').contentDocument.body.textContent = '$content'", + ) + else -> mainSession.evaluateJS("document.querySelector('$id').value = '$content'") + } + } + + private var selectionOffsets: Pair + get() = when (id) { + "#contenteditable" -> mainSession.evaluateJS( + """[ + document.getSelection().anchorOffset, + document.getSelection().focusOffset]""", + ) + "#designmode" -> mainSession.evaluateJS( + """(function() { + var sel = document.querySelector('$id').contentDocument.getSelection(); + var text = document.querySelector('$id').contentDocument.body.firstChild; + return [sel.anchorOffset, sel.focusOffset]; + })()""", + ) + else -> mainSession.evaluateJS( + """(document.querySelector('$id').selectionDirection !== 'backward' + ? [ document.querySelector('$id').selectionStart, document.querySelector('$id').selectionEnd ] + : [ document.querySelector('$id').selectionEnd, document.querySelector('$id').selectionStart ])""", + ) + }.asJsonArray().let { + Pair(it.getInt(0), it.getInt(1)) + } + set(offsets) { + var (start, end) = offsets + when (id) { + "#contenteditable" -> mainSession.evaluateJS( + """(function() { + let selection = document.getSelection(); + let text = document.querySelector('$id').firstChild; + if (text) { + selection.setBaseAndExtent(text, $start, text, $end) + } else { + selection.collapse(document.querySelector('$id'), 0); + } + })()""", + ) + "#designmode" -> mainSession.evaluateJS( + """(function() { + let selection = document.querySelector('$id').contentDocument.getSelection(); + let text = document.querySelector('$id').contentDocument.body.firstChild; + if (text) { + selection.setBaseAndExtent(text, $start, text, $end) + } else { + selection.collapse(document.querySelector('$id').contentDocument.body, 0); + } + })()""", + ) + else -> mainSession.evaluateJS("document.querySelector('$id').setSelectionRange($start, $end)") + } + } + + private fun processParentEvents() { + sessionRule.requestedLocales + } + + private fun processChildEvents() { + mainSession.waitForJS("new Promise(r => requestAnimationFrame(r))") + } + + private fun setComposingText(ic: InputConnection, text: CharSequence, newCursorPosition: Int) { + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionupdate', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionupdate', r, { once: true }))" + }, + ) + ic.setComposingText(text, newCursorPosition) + promise.value + } + + private fun finishComposingText(ic: InputConnection) { + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionend', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionend', r, { once: true }))" + }, + ) + ic.finishComposingText() + promise.value + } + + private fun commitText(ic: InputConnection, text: CharSequence, newCursorPosition: Int) { + if (text == "") { + // No composition event is fired + ic.commitText(text, newCursorPosition) + return + } + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('compositionend', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('compositionend', r, { once: true }))" + }, + ) + ic.commitText(text, newCursorPosition) + promise.value + } + + private fun deleteSurroundingText(ic: InputConnection, before: Int, after: Int) { + // deleteSurroundingText might fire multiple events. + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))" + }, + ) + ic.deleteSurroundingText(before, after) + if (before != 0 || after != 0) { + promise.value + } + // XXX: No way to wait for all events. + processChildEvents() + } + + private fun setSelection(ic: InputConnection, start: Int, end: Int) { + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('selectionchange', r, { once: true }))" + "#contenteditable" -> "new Promise(r => document.addEventListener('selectionchange', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('selectionchange', r, { once: true }))" + }, + ) + ic.setSelection(start, end) + promise.value + } + + private fun pressKey(ic: InputConnection, keyCode: Int) { + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('keyup', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('keyup', r, { once: true }))" + }, + ) + val time = SystemClock.uptimeMillis() + val keyEvent = KeyEvent(time, time, KeyEvent.ACTION_DOWN, keyCode, 0) + ic.sendKeyEvent(keyEvent) + ic.sendKeyEvent(KeyEvent.changeAction(keyEvent, KeyEvent.ACTION_UP)) + promise.value + } + + private fun syncShadowText(ic: InputConnection) { + // Workaround for sync shadow text + ic.beginBatchEdit() + ic.endBatchEdit() + } + + @Test fun restartInput() { + // Check that restartInput is called on focus and blur. + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 1) + override fun restartInput(session: GeckoSession, reason: Int) { + assertThat( + "Reason should be correct", + reason, + equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS), + ) + } + }) + + mainSession.evaluateJS("document.querySelector('$id').blur()") + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 1) + override fun restartInput(session: GeckoSession, reason: Int) { + assertThat( + "Reason should be correct", + reason, + equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_BLUR), + ) + } + + // Also check that showSoftInput/hideSoftInput are not called before a user action. + @AssertCalled(count = 0) + override fun showSoftInput(session: GeckoSession) { + } + + @AssertCalled(count = 0) + override fun hideSoftInput(session: GeckoSession) { + } + }) + } + + @Test fun restartInput_temporaryFocus() { + // Our user action trick doesn't work for design-mode, so we can't test that here. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + // Disable for frequent failures Bug 1542525 + assumeThat(sessionRule.env.isDebugBuild, equalTo(false)) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + // Focus the input once here and once below, but we should only get a + // single restartInput or showSoftInput call for the second focus. + mainSession.evaluateJS("document.querySelector('$id').focus(); document.querySelector('$id').blur()") + + // Simulate a user action so we're allowed to show/hide the keyboard. + mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT) + mainSession.evaluateJS("document.querySelector('$id').focus()") + + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 1, order = [1]) + override fun restartInput(session: GeckoSession, reason: Int) { + assertThat( + "Reason should be correct", + reason, + equalTo(GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS), + ) + } + + @AssertCalled(count = 1, order = [2]) + override fun showSoftInput(session: GeckoSession) { + } + + @AssertCalled(count = 0) + override fun hideSoftInput(session: GeckoSession) { + } + }) + } + + @Test fun restartInput_temporaryBlur() { + // Our user action trick doesn't work for design-mode, so we can't test that here. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + // Simulate a user action so we're allowed to show/hide the keyboard. + mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT) + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled( + GeckoSession.TextInputDelegate::class, + "restartInput", + "showSoftInput", + ) + + // We should get a pair of restartInput calls for the blur/focus, + // but only one showSoftInput call and no hideSoftInput call. + mainSession.evaluateJS("document.querySelector('$id').blur(); document.querySelector('$id').focus()") + + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 2, order = [1]) + override fun restartInput(session: GeckoSession, reason: Int) { + assertThat( + "Reason should be correct", + reason, + equalTo( + forEachCall( + GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, + GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, + ), + ), + ) + } + + @AssertCalled(count = 1, order = [2]) + override fun showSoftInput(session: GeckoSession) { + } + + @AssertCalled(count = 0) + override fun hideSoftInput(session: GeckoSession) { + } + }) + } + + @Test fun showHideSoftInput() { + // Our user action trick doesn't work for design-mode, so we can't test that here. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + // Simulate a user action so we're allowed to show/hide the keyboard. + mainSession.pressKey(KeyEvent.KEYCODE_CTRL_LEFT) + + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 1, order = [1]) + override fun restartInput(session: GeckoSession, reason: Int) { + } + + @AssertCalled(count = 1, order = [2]) + override fun showSoftInput(session: GeckoSession) { + } + + @AssertCalled(count = 0) + override fun hideSoftInput(session: GeckoSession) { + } + }) + + mainSession.evaluateJS("document.querySelector('$id').blur()") + mainSession.waitUntilCalled(object : TextInputDelegate { + @AssertCalled(count = 1, order = [1]) + override fun restartInput(session: GeckoSession, reason: Int) { + } + + @AssertCalled(count = 0) + override fun showSoftInput(session: GeckoSession) { + } + + @AssertCalled(count = 1, order = [2]) + override fun hideSoftInput(session: GeckoSession) { + } + }) + } + + private fun getText(ic: InputConnection) = + ic.getExtractedText(ExtractedTextRequest(), 0).text.toString() + + private fun assertText(message: String, actual: String, expected: String) = + // In an HTML editor, Gecko may insert an additional element that show up as a + // return character at the end. Deal with that here. + assertThat(message, actual.trimEnd('\n'), equalTo(expected)) + + private fun assertText( + message: String, + ic: InputConnection, + expected: String, + checkGecko: Boolean = true, + ) { + processChildEvents() + processParentEvents() + + if (checkGecko) { + assertText(message, textContent, expected) + } + assertText(message, getText(ic), expected) + } + + private fun assertSelection( + message: String, + ic: InputConnection, + start: Int, + end: Int, + checkGecko: Boolean = true, + ) { + processChildEvents() + processParentEvents() + + if (checkGecko) { + assertThat(message, selectionOffsets, equalTo(Pair(start, end))) + } + + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + assertThat(message, extracted.selectionStart, equalTo(start)) + assertThat(message, extracted.selectionEnd, equalTo(end)) + } + + private fun assertSelectionAt( + message: String, + ic: InputConnection, + value: Int, + checkGecko: Boolean = true, + ) = + assertSelection(message, ic, value, value, checkGecko) + + private fun assertTextAndSelection( + message: String, + ic: InputConnection, + expected: String, + start: Int, + end: Int, + checkGecko: Boolean = true, + ) { + processChildEvents() + processParentEvents() + + if (checkGecko) { + assertText(message, textContent, expected) + assertThat(message, selectionOffsets, equalTo(Pair(start, end))) + } + + val extracted = ic.getExtractedText(ExtractedTextRequest(), 0) + assertText(message, extracted.text.toString(), expected) + assertThat(message, extracted.selectionStart, equalTo(start)) + assertThat(message, extracted.selectionEnd, equalTo(end)) + } + + private fun assertTextAndSelectionAt( + message: String, + ic: InputConnection, + expected: String, + value: Int, + checkGecko: Boolean = true, + ) = + assertTextAndSelection(message, ic, expected, value, value, checkGecko) + + private fun setupContent(content: String) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.select_events.textcontrols.enabled" to true, + ), + ) + + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + textContent = content + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + } + + // Test setSelection + @Ignore + // Disable for frequent timeout for selection event. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_setSelection() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + // TODO: + // onselectionchange won't be fired if caret is last. But commitText + // can set text and selection well (Bug 1360388). + commitText(ic, "foo", 1) // Selection at end of new text + assertTextAndSelectionAt("Can commit text", ic, "foo", 3) + + setSelection(ic, 0, 3) + assertSelection("Can set selection to range", ic, 0, 3) + // No selection change event is fired + ic.setSelection(-3, 6) + // Test both forms of assert + assertTextAndSelection( + "Can handle invalid range", + ic, + "foo", + 0, + 3, + ) + setSelection(ic, 3, 3) + assertSelectionAt("Can collapse selection", ic, 3) + // No selection change event is fired + ic.setSelection(4, 4) + assertTextAndSelectionAt("Can handle invalid cursor", ic, "foo", 3) + } + + // Test commitText + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_commitText() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "foo", 1) // Selection at end of new text + assertTextAndSelectionAt("Can commit empty text", ic, "foo", 3) + + commitText(ic, "", 10) // Selection past end of new text + assertTextAndSelectionAt("Can commit empty text", ic, "foo", 3) + commitText(ic, "bar", 1) // Selection at end of new text + assertTextAndSelectionAt( + "Can commit text (select after)", + ic, + "foobar", + 6, + ) + commitText(ic, "foo", -1) // Selection at start of new text + assertTextAndSelectionAt( + "Can commit text (select before)", + ic, + "foobarfoo", + 5, /* checkGecko */ + false, + ) + } + + // Test deleteSurroundingText + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_deleteSurroundingText() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + commitText(ic, "foobarfoo", 1) + assertTextAndSelectionAt("Set initial text and selection", ic, "foobarfoo", 9) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + assertSelection("Can set selection to range", ic, 5, 5) + + deleteSurroundingText(ic, 1, 0) + assertTextAndSelectionAt( + "Can delete text before", + ic, + "foobrfoo", + 4, + ) + deleteSurroundingText(ic, 1, 1) + assertTextAndSelectionAt( + "Can delete text before/after", + ic, + "foofoo", + 3, + ) + deleteSurroundingText(ic, 0, 10) + assertTextAndSelectionAt("Can delete text after", ic, "foo", 3) + deleteSurroundingText(ic, 0, 0) + assertTextAndSelectionAt("Can delete empty text", ic, "foo", 3) + } + + // Test setComposingText + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_setComposingText() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "foo", 1) // Selection at end of new text + assertTextAndSelectionAt("Can commit text", ic, "foo", 3) + + setComposingText(ic, "foo", 1) + assertTextAndSelectionAt("Can start composition", ic, "foofoo", 6) + setComposingText(ic, "", 1) + assertTextAndSelectionAt("Can set empty composition", ic, "foo", 3) + setComposingText(ic, "bar", 1) + assertTextAndSelectionAt("Can update composition", ic, "foobar", 6) + + // Test finishComposingText + finishComposingText(ic) + assertTextAndSelectionAt("Can finish composition", ic, "foobar", 6) + } + + // Test setComposingRegion + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_setComposingRegion() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "foobar", 1) // Selection at end of new text + assertTextAndSelectionAt("Can commit text", ic, "foobar", 6) + + ic.setComposingRegion(0, 3) + assertTextAndSelectionAt("Can set composing region", ic, "foobar", 6) + + setComposingText(ic, "far", 1) + assertTextAndSelectionAt( + "Can set composing region text", + ic, + "farbar", + 3, + ) + + ic.setComposingRegion(1, 4) + assertTextAndSelectionAt( + "Can set existing composing region", + ic, + "farbar", + 3, + ) + + setComposingText(ic, "rab", 3) + assertTextAndSelectionAt( + "Can set new composing region text", + ic, + "frabar", + 6, /* checkGecko */ + false, + ) + + finishComposingText(ic) + } + + // Test getTextBefore/AfterCursor + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_getTextBeforeAfterCursor() { + setupContent("foobar") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "foobar") + + setSelection(ic, 3, 3) + assertSelection("Can set selection to range", ic, 3, 3) + + // Test getTextBeforeCursor + assertThat( + "Can retrieve text before cursor", + "foo", + equalTo(ic.getTextBeforeCursor(3, 0)), + ) + + // Test getTextAfterCursor + assertThat( + "Can retrieve text after cursor", + "bar", + equalTo(ic.getTextAfterCursor(3, 0)), + ) + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_selectionByArrowKey() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Set initial text", ic, "") + + commitText(ic, "foo", 1) // Selection at end of new text + assertTextAndSelectionAt("Commit foo text", ic, "foo", 3) + + // backward selection test + var time = SystemClock.uptimeMillis() + var shiftKey = KeyEvent( + time, + time, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_SHIFT_LEFT, + 0, + ) + ic.sendKeyEvent(shiftKey) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP)) + // No way to get notification for selection on Java side. So sync shadow text + syncShadowText(ic) + assertSelection("Set backward select using key event", ic, 3, 0) + + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + // No way to get notification for selection on Java side. So sync shadow text + syncShadowText(ic) + assertSelectionAt("Reset selection using key event", ic, 0) + + // forward selection test + time = SystemClock.uptimeMillis() + shiftKey = KeyEvent( + time, + time, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_SHIFT_LEFT, + 0, + ) + ic.sendKeyEvent(shiftKey) + pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT) + ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP)) + // No way to get notification for selection on Java side. So sync shadow text + syncShadowText(ic) + assertSelection("Set forward select using key event", ic, 0, 3) + } + + // Test sendKeyEvent + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_sendKeyEvent() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "frabar", 1) // Selection at end of new text + assertTextAndSelectionAt("Can commit text", ic, "frabar", 6) + + val time = SystemClock.uptimeMillis() + val shiftKey = KeyEvent( + time, + time, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_SHIFT_LEFT, + 0, + ) + + // Wait for selection change + var promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('selectionchange', r, { once: true }))" + "#contenteditable" -> "new Promise(r => document.addEventListener('selectionchange', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('selectionchange', r, { once: true }))" + }, + ) + + ic.sendKeyEvent(shiftKey) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP)) + promise.value + + // TODO(m_kato) + // Since geckoview-junit doesn't attach View, there is no way to wait for correct selection data. + // So Sync shadow text to avoid failures. + syncShadowText(ic) + assertTextAndSelection( + "Can select using key event", + ic, + "frabar", + 6, + 5, + ) + + promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))" + }, + ) + + pressKey(ic, KeyEvent.KEYCODE_T) + promise.value + assertText("Can type using event", ic, "frabat") + } + + // Test for Multiple setComposingText with same string length. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_multiple_setComposingText() { + setupContent("") + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + // Don't wait composition event for this test. + ic.setComposingText("aaa", 1) + ic.setComposingText("aaa", 1) + ic.setComposingText("aab", 1) + + finishComposingText(ic) + assertTextAndSelectionAt( + "Multiple setComposingText don't commit composition string", + ic, + "aab", + 3, + ) + } + + // Test for setting large text on text box. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_largeText() { + val content = (1..102400).map { + ('a'..'z').random() + }.joinToString("") + setupContent(content) + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set large initial text", ic, content, /* checkGecko */ false) + } + + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.N_MR1) + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_commitContent() { + if (id == "#input" || id == "#textarea") { + assertThat( + "This test is only for contenteditable or designmode", + true, + equalTo(true), + ) + return + } + + setupContent("") + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Set initial text", ic, "") + + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> """ + new Promise((resolve, reject) => document.querySelector('$id').contentDocument.addEventListener('input', e => { + if (e.inputType == 'insertFromPaste') { + resolve(); + } else { + reject(); + } + }, { once: true })) + """.trimIndent() + else -> """ + new Promise((resolve, reject) => document.querySelector('$id').addEventListener('input', e => { + if (e.inputType == 'insertFromPaste') { + resolve(); + } else { + reject(); + } + }, { once: true })) + """.trimIndent() + }, + ) + + // InputContentInfo requires content:// uri, so we have to set test data to custom content provider. + TestContentProvider.setTestData(this.getTestBytes("/assets/www/images/test.gif"), "image/gif") + val info = InputContentInfo(Uri.parse("content://org.mozilla.geckoview.test.provider/gif"), ClipDescription("test", arrayOf("image/gif"))) + ic.commitContent(info, 0, null) + promise.value + assertThat("Input event is fired by inserting image", true, equalTo(true)) + } + + // Bug 1133802, duplication when setting the same composing text more than once. + @Ignore + // Disable for frequent failures. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1133802() { + // TODO: + // Disable this test for frequent failures. We consider another way to + // wait/ignore event handling. + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + setComposingText(ic, "foo", 1) + assertTextAndSelectionAt("Can set the composing text", ic, "foo", 3) + // Setting same text doesn't fire compositionupdate + ic.setComposingText("foo", 1) + assertTextAndSelectionAt( + "Can set the same composing text", + ic, + "foo", + 3, + ) + setComposingText(ic, "bar", 1) + assertTextAndSelectionAt( + "Can set different composing text", + ic, + "bar", + 3, + ) + // Setting same text doesn't fire compositionupdate + ic.setComposingText("bar", 1) + assertTextAndSelectionAt( + "Can set the same composing text", + ic, + "bar", + 3, + ) + // Setting same text doesn't fire compositionupdate + ic.setComposingText("bar", 1) + assertTextAndSelectionAt( + "Can set the same composing text again", + ic, + "bar", + 3, + ) + finishComposingText(ic) + assertTextAndSelectionAt("Can finish composing text", ic, "bar", 3) + } + + // Bug 1209465, cannot enter ideographic space character by itself (U+3000). + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1209465() { + // The ideographic space char may trigger font fallback; we don't want that to be async, + // as the resulting deferred reflow may confuse a following test. + sessionRule.setPrefsUntilTestEnd(mapOf("gfx.font_rendering.fallback.async" to false)) + + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "\u3000", 1) + assertTextAndSelectionAt( + "Can commit ideographic space", + ic, + "\u3000", + 1, + ) + } + + // Bug 1275371 - shift+backspace should not forward delete on Android. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1275371() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + ic.beginBatchEdit() + commitText(ic, "foo", 1) + setSelection(ic, 1, 1) + ic.endBatchEdit() + assertTextAndSelectionAt("Can commit text", ic, "foo", 1) + + val time = SystemClock.uptimeMillis() + val shiftKey = KeyEvent( + time, + time, + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_SHIFT_LEFT, + 0, + ) + ic.sendKeyEvent(shiftKey) + + // Wait for input change + val promise = mainSession.evaluatePromiseJS( + when (id) { + "#designmode" -> "new Promise(r => document.querySelector('$id').contentDocument.addEventListener('input', r, { once: true }))" + else -> "new Promise(r => document.querySelector('$id').addEventListener('input', r, { once: true }))" + }, + ) + + pressKey(ic, KeyEvent.KEYCODE_DEL) + promise.value + assertText("Can backspace with shift+backspace", ic, "oo") + + pressKey(ic, KeyEvent.KEYCODE_DEL) + ic.sendKeyEvent(KeyEvent.changeAction(shiftKey, KeyEvent.ACTION_UP)) + assertTextAndSelectionAt( + "Cannot forward delete with shift+backspace", + ic, + "oo", + 0, + ) + } + + // Bug 1490391 - Committing then setting composition can result in duplicates. + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1490391() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Can set initial text", ic, "") + + commitText(ic, "far", 1) + setComposingText(ic, "bar", 1) + assertTextAndSelectionAt( + "Can commit then set composition", + ic, + "farbar", + 6, + ) + setComposingText(ic, "baz", 1) + assertTextAndSelectionAt( + "Composition still exists after setting", + ic, + "farbaz", + 6, + ) + + finishComposingText(ic) + + // TODO: + // Call ic.deleteSurroundingText(6, 0) and check result. + // Actually, no way to wait deleteSurroudingText since this may fire + // multiple events. + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun sendDummyKeyboardEvent() { + // unnecessary for designmode + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + assertText("Set initial text", ic, "") + + commitText(ic, "foo", 1) + assertTextAndSelectionAt("commit text and selection", ic, "foo", 3) + + // Dispatching keydown, input and keyup + val promise = + mainSession.evaluatePromiseJS( + """ + new Promise(r => window.addEventListener('keydown', () => { + window.addEventListener('input',() => { + window.addEventListener('keyup', r, { once: true }) }, + { once: true }) }, + { once: true}))""", + ) + ic.beginBatchEdit() + ic.setSelection(0, 3) + ic.setComposingText("", 1) + ic.endBatchEdit() + promise.value + assertText("empty text", ic, "") + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun editorInfo_default() { + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + textContent = "" + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + + val editorInfo = EditorInfo() + mainSession.textInput.onCreateInputConnection(editorInfo) + assertThat( + "Default EditorInfo.inputType", + editorInfo.inputType, + equalTo( + when (id) { + "#input" -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or + InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE + else -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or + InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE + }, + ), + ) + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun editorInfo_defaultByInputType() { + assumeThat("type attribute is input element only", id, equalTo("#input")) + // Disable this with WebRender due to unexpected abort by mozilla::gl::GLContext::fTexSubImage2D + // (Bug 1706688, Bug 1710060 and etc) + assumeThat(sessionRule.env.isWebrender and sessionRule.env.isDebugBuild, equalTo(false)) + + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + mainSession.loadTestPath(FORMS5_HTML_PATH) + mainSession.waitForPageStop() + + for (inputType in listOf("#email1", "#pass1", "#search1", "#tel1", "#url1")) { + mainSession.evaluateJS("document.querySelector('$inputType').focus()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + + // IC will be updated asynchronously, so spin event loop + processChildEvents() + processParentEvents() + + val editorInfo = EditorInfo() + val ic = mainSession.textInput.onCreateInputConnection(editorInfo)!! + assertThat("InputConnection is created correctly", ic, notNullValue()) + + // Even if we get IC, new EditorInfo isn't updated yet. + // We post and wait for empty job to IC thread to flush all IC's job. + val result = object : GeckoResult() { + init { + val icHandler = mainSession.textInput.getHandler(Handler(Looper.getMainLooper())) + icHandler.post({ + complete(true) + }) + } + } + sessionRule.waitForResult(result) + mainSession.textInput.onCreateInputConnection(editorInfo) + + assertThat( + "EditorInfo.inputType of $inputType", + editorInfo.inputType, + equalTo( + when (inputType) { + "#email1" -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS + "#pass1" -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_PASSWORD + "#search1" -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT or + InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + "#tel1" -> InputType.TYPE_CLASS_PHONE + "#url1" -> + InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_VARIATION_URI + else -> 0 + }, + ), + ) + } + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun editorInfo_enterKeyHint() { + // no way to set enterkeyhint on designmode. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.forms.enterkeyhint" to true)) + + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + textContent = "" + val values = listOf("enter", "done", "go", "previous", "next", "search", "send") + for (enterkeyhint in values) { + mainSession.evaluateJS( + """ + document.querySelector('$id').enterKeyHint = '$enterkeyhint'; + document.querySelector('$id').focus()""", + ) + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + + val editorInfo = EditorInfo() + mainSession.textInput.onCreateInputConnection(editorInfo) + assertThat( + "EditorInfo.imeOptions by $enterkeyhint", + editorInfo.imeOptions and EditorInfo.IME_MASK_ACTION, + equalTo( + when (enterkeyhint) { + "done" -> EditorInfo.IME_ACTION_DONE + "go" -> EditorInfo.IME_ACTION_GO + "next" -> EditorInfo.IME_ACTION_NEXT + "previous" -> EditorInfo.IME_ACTION_PREVIOUS + "search" -> EditorInfo.IME_ACTION_SEARCH + "send" -> EditorInfo.IME_ACTION_SEND + else -> EditorInfo.IME_ACTION_NONE + }, + ), + ) + + mainSession.evaluateJS("document.querySelector('$id').blur()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + } + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun editorInfo_autocapitalize() { + // no way to set autocapitalize on designmode. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.forms.autocapitalize" to true)) + + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + textContent = "" + val values = listOf("characters", "none", "sentences", "words", "off", "on") + for (autocapitalize in values) { + mainSession.evaluateJS( + """ + document.querySelector('$id').autocapitalize = '$autocapitalize'; + document.querySelector('$id').focus()""", + ) + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + + val editorInfo = EditorInfo() + mainSession.textInput.onCreateInputConnection(editorInfo) + assertThat( + "EditorInfo.inputType by $autocapitalize", + editorInfo.inputType and 0x00007000, + equalTo( + when (autocapitalize) { + "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS + "on" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + "sentences" -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES + "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS + else -> 0 + }, + ), + ) + + mainSession.evaluateJS("document.querySelector('$id').blur()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + } + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun bug1613804_finishComposingText() { + mainSession.textInput.view = View(InstrumentationRegistry.getInstrumentation().targetContext) + + mainSession.loadTestPath(INPUTS_PATH) + mainSession.waitForPageStop() + + textContent = "" + mainSession.evaluateJS("document.querySelector('$id').focus()") + mainSession.waitUntilCalled(GeckoSession.TextInputDelegate::class, "restartInput") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + ic.beginBatchEdit() + ic.setComposingText("abc", 1) + ic.endBatchEdit() + + // finishComposingText has to dispatch compositionend event. + finishComposingText(ic) + + assertText("commit abc", ic, "abc") + } + + // Bug 1593683 - Cursor is jumping when using the arrow keys in input field on GBoard + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1593683() { + setupContent("") + + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + setComposingText(ic, "foo", 1) + assertTextAndSelectionAt("Can set the composing text", ic, "foo", 3) + // Arrow key should keep composition then move caret + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + assertSelection("IME caret is moved to top", ic, 0, 0, /* checkGecko */ false) + + setComposingText(ic, "bar", 1) + finishComposingText(ic) + assertText("commit abc", ic, "bar") + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1633621() { + // no way on designmode. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + setupContent("") + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + mainSession.evaluateJS( + """ + document.querySelector('$id').addEventListener('input', () => { + document.querySelector('$id').blur(); + document.querySelector('$id').focus(); + }) + """, + ) + + setComposingText(ic, "b", 1) + assertTextAndSelectionAt( + "Don't change caret position after calling blur and focus", + ic, + "b", + 1, + ) + + setComposingText(ic, "a", 1) + assertTextAndSelectionAt( + "Can set composition string after calling blur and focus", + ic, + "ba", + 2, + ) + + pressKey(ic, KeyEvent.KEYCODE_R) + assertText( + "Can set input string by keypress after calling blur and focus", + ic, + "bar", + ) + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1650705() { + // no way on designmode. + assumeThat("Not in designmode", id, not(equalTo("#designmode"))) + + setupContent("") + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + commitText(ic, "foo", 1) + ic.setSelection(0, 3) + + mainSession.evaluateJS( + """ + input_event_count = 0; + document.querySelector('$id').addEventListener('input', () => { + input_event_count++; + }) + """, + ) + + setComposingText(ic, "barbaz", 1) + + val count = mainSession.evaluateJS("input_event_count") as Double + assertThat("input event is once", count, equalTo(1.0)) + + finishComposingText(ic) + } + + @WithDisplay(width = 512, height = 512) + // Child process updates require having a display. + @Test + fun inputConnection_bug1767556() { + setupContent("") + val ic = mainSession.textInput.onCreateInputConnection(EditorInfo())!! + + // Emulate GBoard's InputConnection API calls + ic.beginBatchEdit() + ic.setComposingText("fooba", 1) + ic.endBatchEdit() + ic.setComposingText("fooba", 1) + processChildEvents() + + ic.beginBatchEdit() + ic.setComposingText("foobaz", 1) + ic.endBatchEdit() + ic.setComposingText("foobaz", 1) + processChildEvents() + + ic.beginBatchEdit() + ic.setComposingText("foobaz1", 1) + ic.endBatchEdit() + ic.setComposingText("foobaz1", 1) + processChildEvents() + + finishComposingText(ic) + assertText("commit foobaz1", ic, "foobaz1") + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java new file mode 100644 index 0000000000..141849589e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java @@ -0,0 +1,119 @@ +package org.mozilla.geckoview.test; + +import android.content.Context; +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.File; +import java.util.List; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission; +import org.mozilla.geckoview.GeckoSessionSettings; + +public class TrackingPermissionService extends TestRuntimeService { + public static final int MESSAGE_SET_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 1; + public static final int MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 2; + public static final int MESSAGE_GET_TRACKING_PERMISSION = FIRST_SAFE_MESSAGE + 3; + + private ContentPermission mContentPermission; + + @Override + protected GeckoSession createSession(final Intent intent) { + return new GeckoSession( + new GeckoSessionSettings.Builder() + .usePrivateMode(mTestData.getBoolean("privateMode")) + .build()); + } + + @Override + protected void onSessionReady(final GeckoSession session) { + session.setNavigationDelegate( + new GeckoSession.NavigationDelegate() { + @Override + public void onLocationChange( + final @NonNull GeckoSession session, + final @Nullable String url, + final @NonNull List perms) { + for (ContentPermission perm : perms) { + if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) { + mContentPermission = perm; + } + } + } + }); + } + + @Override + protected GeckoResult handleMessage(final int messageId, final GeckoBundle data) { + if (mContentPermission == null) { + throw new IllegalStateException("Content permission not received yet!"); + } + + switch (messageId) { + case MESSAGE_SET_TRACKING_PERMISSION: + { + final int permission = data.getInt("trackingPermission"); + mRuntime.getStorageController().setPermission(mContentPermission, permission); + break; + } + case MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION: + { + final int permission = data.getInt("trackingPermission"); + mRuntime + .getStorageController() + .setPrivateBrowsingPermanentPermission(mContentPermission, permission); + break; + } + case MESSAGE_GET_TRACKING_PERMISSION: + { + final GeckoBundle result = new GeckoBundle(1); + result.putInt("trackingPermission", mContentPermission.value); + return GeckoResult.fromValue(result); + } + } + + return null; + } + + public static class TrackingPermissionInstance + extends RuntimeInstance { + public static GeckoBundle testData(boolean privateMode) { + GeckoBundle testData = new GeckoBundle(1); + testData.putBoolean("privateMode", privateMode); + return testData; + } + + private TrackingPermissionInstance( + final Context context, final File profileFolder, final boolean privateMode) { + super(context, TrackingPermissionService.class, profileFolder, testData(privateMode)); + } + + public static TrackingPermissionInstance start( + final Context context, final File profileFolder, final boolean privateMode) { + TrackingPermissionInstance instance = + new TrackingPermissionInstance(context, profileFolder, privateMode); + instance.sendIntent(); + return instance; + } + + public GeckoResult getTrackingPermission() { + return query(MESSAGE_GET_TRACKING_PERMISSION) + .map(bundle -> bundle.getInt("trackingPermission")); + } + + public void setTrackingPermission(final int permission) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putInt("trackingPermission", permission); + sendMessage(MESSAGE_SET_TRACKING_PERMISSION, bundle); + } + + public void setPrivateBrowsingPermanentTrackingPermission(final int permission) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putInt("trackingPermission", permission); + sendMessage(MESSAGE_SET_PRIVATE_BROWSING_TRACKING_PERMISSION, bundle); + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt new file mode 100644 index 0000000000..2e340c09c2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt @@ -0,0 +1,88 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.graphics.* // ktlint-disable no-wildcard-imports +import android.graphics.Bitmap +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.notNullValue +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +private const val SCREEN_HEIGHT = 800 +private const val SCREEN_WIDTH = 800 +private const val BANNER_HEIGHT = SCREEN_HEIGHT * 0.1f // height: 10% + +@RunWith(AndroidJUnit4::class) +@MediumTest +class VerticalClippingTest : BaseSessionTest() { + private fun getComparisonScreenshot(bottomOffset: Int): Bitmap { + val screenshotFile = Bitmap.createBitmap(SCREEN_WIDTH, SCREEN_HEIGHT, Bitmap.Config.ARGB_8888) + val canvas = Canvas(screenshotFile) + val paint = Paint() + + // Draw body + paint.color = Color.rgb(0, 0, 255) + canvas.drawRect(0f, 0f, SCREEN_WIDTH.toFloat(), SCREEN_HEIGHT.toFloat(), paint) + + // Draw bottom banner + paint.color = Color.rgb(0, 255, 0) + canvas.drawRect( + 0f, + SCREEN_HEIGHT - BANNER_HEIGHT - bottomOffset, + SCREEN_WIDTH.toFloat(), + (SCREEN_HEIGHT - bottomOffset).toFloat(), + paint, + ) + + return screenshotFile + } + + private fun assertScreenshotResult(result: GeckoResult, comparisonImage: Bitmap) { + sessionRule.waitForResult(result).let { + assertThat( + "Screenshot is not null", + it, + notNullValue(), + ) + assertThat("Widths are the same", comparisonImage.width, equalTo(it.width)) + assertThat("Heights are the same", comparisonImage.height, equalTo(it.height)) + assertThat("Byte counts are the same", comparisonImage.byteCount, equalTo(it.byteCount)) + assertThat("Configs are the same", comparisonImage.config, equalTo(it.config)) + assertThat( + "Images are almost identical", + ScreenshotTest.Companion.imageElementDifference(comparisonImage, it), + Matchers.lessThanOrEqualTo(1), + ) + } + } + + @WithDisplay(height = SCREEN_HEIGHT, width = SCREEN_WIDTH) + @Test + fun verticalClippingSucceeds() { + // Disable failing test on Webrender. Bug 1670267 + assumeThat(sessionRule.env.isWebrender, equalTo(false)) + sessionRule.display?.setVerticalClipping(45) + mainSession.loadTestPath(FIXED_BOTTOM) + sessionRule.waitUntilCalled(object : ContentDelegate { + @AssertCalled(count = 1) + override fun onFirstContentfulPaint(session: GeckoSession) { + } + }) + + sessionRule.display?.let { + assertScreenshotResult(it.capturePixels(), getComparisonScreenshot(45)) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt new file mode 100644 index 0000000000..3f4af40a0b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt @@ -0,0 +1,545 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Build +import android.os.SystemClock +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.mozilla.gecko.util.ThreadUtils +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.test.util.RuntimeCreator +import org.mozilla.geckoview.test.util.TestServer +import java.io.IOException +import java.lang.IllegalStateException +import java.math.BigInteger +import java.net.UnknownHostException +import java.nio.ByteBuffer +import java.nio.charset.Charset +import java.security.MessageDigest +import java.util.* // ktlint-disable no-wildcard-imports + +@MediumTest +@RunWith(Parameterized::class) +class WebExecutorTest { + companion object { + const val TEST_PORT: Int = 4242 + const val TEST_ENDPOINT: String = "http://localhost:$TEST_PORT" + + @get:Parameterized.Parameters(name = "{0}") + @JvmStatic + val parameters: List> = listOf( + arrayOf("#conservative"), + arrayOf("#normal"), + ) + } + + @field:Parameterized.Parameter(0) + @JvmField + var id: String = "" + + lateinit var executor: GeckoWebExecutor + lateinit var server: TestServer + + @Before + fun setup() { + // Using @UiThreadTest here does not seem to block + // the tests which are not using @UiThreadTest, so we do that + // ourselves here as GeckoRuntime needs to be initialized + // on the UI thread. + runBlocking(Dispatchers.Main) { + executor = GeckoWebExecutor(RuntimeCreator.getRuntime()) + } + + server = TestServer(InstrumentationRegistry.getInstrumentation().targetContext) + server.start(TEST_PORT) + } + + @After + fun cleanup() { + server.stop() + } + + private fun fetch(request: WebRequest): WebResponse { + return fetch(request, GeckoWebExecutor.FETCH_FLAGS_NONE) + } + + private fun fetch(request: WebRequest, flags: Int): WebResponse { + return executor.fetch(request, flags).pollDefault()!! + } + + fun WebResponse.getBodyBytes(): ByteBuffer { + body!!.use { + return ByteBuffer.wrap(it.readBytes()) + } + } + + fun WebResponse.getJSONBody(): JSONObject { + val bytes = this.getBodyBytes() + val bodyString = Charset.forName("UTF-8").decode(bytes).toString() + return JSONObject(bodyString) + } + + private fun randomString(count: Int): String { + val chars = "01234567890abcdefghijklmnopqrstuvwxyz[],./?;'" + val builder = StringBuilder(count) + val rand = Random(System.currentTimeMillis()) + + for (i in 0 until count) { + builder.append(chars[rand.nextInt(chars.length)]) + } + + return builder.toString() + } + + fun webRequestBuilder(uri: String): WebRequest.Builder { + val beConservative = when (id) { + "#conservative" -> true + else -> false + } + return WebRequest.Builder(uri).beConservative(beConservative) + } + + fun webRequest(uri: String): WebRequest { + return webRequestBuilder(uri).build() + } + + @Test + fun smoke() { + val uri = "$TEST_ENDPOINT/anything" + val bodyString = randomString(8192) + val referrer = "http://foo/bar" + + val request = webRequestBuilder(uri) + .method("POST") + .header("Header1", "Clobbered") + .header("Header1", "Value") + .addHeader("Header2", "Value1") + .addHeader("Header2", "Value2") + .referrer(referrer) + .header("Content-Type", "text/plain") + .body(bodyString) + .build() + + val response = fetch(request) + + assertThat("URI should match", response.uri, equalTo(uri)) + assertThat("Status could should match", response.statusCode, equalTo(200)) + assertThat("Content type should match", response.headers["Content-Type"], equalTo("application/json; charset=utf-8")) + assertThat("Redirected should match", response.redirected, equalTo(false)) + assertThat("isSecure should match", response.isSecure, equalTo(false)) + + val body = response.getJSONBody() + assertThat("Method should match", body.getString("method"), equalTo("POST")) + assertThat("Headers should match", body.getJSONObject("headers").getString("Header1"), equalTo("Value")) + assertThat("Headers should match", body.getJSONObject("headers").getString("Header2"), equalTo("Value1, Value2")) + assertThat("Headers should match", body.getJSONObject("headers").getString("Content-Type"), equalTo("text/plain")) + assertThat("Referrer should match", body.getJSONObject("headers").getString("Referer"), equalTo("http://foo/")) + assertThat("Data should match", body.getString("data"), equalTo(bodyString)) + } + + @Test + fun testFetchAsset() { + val response = fetch(webRequest("$TEST_ENDPOINT/assets/www/hello.html")) + assertThat("Status should match", response.statusCode, equalTo(200)) + assertThat("Body should have bytes", response.getBodyBytes().remaining(), greaterThan(0)) + } + + @Test + fun testStatus() { + val response = fetch(webRequest("$TEST_ENDPOINT/status/500")) + assertThat("Status code should match", response.statusCode, equalTo(500)) + } + + @Test + fun testRedirect() { + val response = fetch(webRequest("$TEST_ENDPOINT/redirect-to?url=/status/200")) + + assertThat("URI should match", response.uri, equalTo(TEST_ENDPOINT + "/status/200")) + assertThat("Redirected should match", response.redirected, equalTo(true)) + assertThat("Status code should match", response.statusCode, equalTo(200)) + } + + @Test + fun testDisallowRedirect() { + val response = fetch(webRequest("$TEST_ENDPOINT/redirect-to?url=/status/200"), GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS) + + assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/redirect-to?url=/status/200")) + assertThat("Redirected should match", response.redirected, equalTo(false)) + assertThat("Status code should match", response.statusCode, equalTo(302)) + } + + @Test + fun testRedirectLoop() { + val thrown = assertThrows(WebRequestError::class.java) { + fetch(webRequest("$TEST_ENDPOINT/redirect/100")) + } + assertThat(thrown, equalTo(WebRequestError(WebRequestError.ERROR_REDIRECT_LOOP, WebRequestError.ERROR_CATEGORY_NETWORK))) + } + + @Test + fun testAuth() { + // We don't support authentication yet, but want to make sure it doesn't do anything + // silly like try to prompt the user. + val response = fetch(webRequest("$TEST_ENDPOINT/basic-auth/foo/bar")) + assertThat("Status code should match", response.statusCode, equalTo(401)) + } + + @Test + fun testSslError() { + val uri = if (env.isAutomation) { + "https://expired.example.com/" + } else { + "https://expired.badssl.com/" + } + + try { + fetch(webRequest(uri)) + throw IllegalStateException("fetch() should have thrown") + } catch (e: WebRequestError) { + assertThat("Category should match", e.category, equalTo(WebRequestError.ERROR_CATEGORY_SECURITY)) + assertThat("Code should match", e.code, equalTo(WebRequestError.ERROR_SECURITY_BAD_CERT)) + assertThat("Certificate should be present", e.certificate, notNullValue()) + assertThat("Certificate issuer should be present", e.certificate?.issuerX500Principal?.name, not(isEmptyOrNullString())) + } + } + + @Test + fun testSecure() { + val response = fetch(webRequest("https://example.com")) + assertThat("Status should match", response.statusCode, equalTo(200)) + assertThat("isSecure should match", response.isSecure, equalTo(true)) + + val expectedSubject = if (env.isAutomation) { + "CN=example.com" + } else { + "CN=www.example.org,OU=Technology,O=Internet Corporation for Assigned Names and Numbers,L=Los Angeles,ST=California,C=US" + } + + val expectedIssuer = if (env.isAutomation) { + "OU=Profile Guided Optimization,O=Mozilla Testing,CN=Temporary Certificate Authority" + } else { + "CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US" + } + + assertThat( + "Subject should match", + response.certificate?.subjectX500Principal?.name, + equalTo(expectedSubject), + ) + assertThat( + "Issuer should match", + response.certificate?.issuerX500Principal?.name, + equalTo(expectedIssuer), + ) + } + + @Test + fun testCookies() { + val uptimeMillis = SystemClock.uptimeMillis() + val response = fetch(webRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis")) + + // We get redirected to /cookies which returns the cookies that were sent in the request + assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies")) + assertThat("Status code should match", response.statusCode, equalTo(200)) + + val body = response.getJSONBody() + assertThat( + "Body should match", + body.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString()), + ) + + val anotherBody = fetch(webRequest("$TEST_ENDPOINT/cookies")).getJSONBody() + assertThat( + "Body should match", + anotherBody.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString()), + ) + } + + @Test + fun testAnonymousSendCookies() { + val uptimeMillis = SystemClock.uptimeMillis() + val response = fetch(webRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis"), GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS) + + // We get redirected to /cookies which returns the cookies that were sent in the request + assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies")) + assertThat("Status code should match", response.statusCode, equalTo(200)) + + val body = response.getJSONBody() + assertThat( + "Cookies should not be set for the test server", + body.getJSONObject("cookies").length(), + equalTo(0), + ) + } + + @Test + fun testAnonymousGetCookies() { + // Ensure a cookie is set for the test server + testCookies() + + val response = fetch( + webRequest("$TEST_ENDPOINT/cookies"), + GeckoWebExecutor.FETCH_FLAGS_ANONYMOUS, + ) + + assertThat("Status code should match", response.statusCode, equalTo(200)) + val cookies = response.getJSONBody().getJSONObject("cookies") + assertThat("Cookies should be empty", cookies.length(), equalTo(0)) + } + + @Test + fun testPrivateCookies() { + val clearData = GeckoResult() + ThreadUtils.runOnUiThread { + clearData.completeFrom( + RuntimeCreator.getRuntime() + .storageController + .clearData(StorageController.ClearFlags.ALL), + ) + } + + clearData.pollDefault() + + val uptimeMillis = SystemClock.uptimeMillis() + val response = fetch(webRequest("$TEST_ENDPOINT/cookies/set/uptimeMillis/$uptimeMillis"), GeckoWebExecutor.FETCH_FLAGS_PRIVATE) + + // We get redirected to /cookies which returns the cookies that were sent in the request + assertThat("URI should match", response.uri, equalTo("$TEST_ENDPOINT/cookies")) + assertThat("Status code should match", response.statusCode, equalTo(200)) + + val body = response.getJSONBody() + assertThat( + "Cookies should be set for the test server", + body.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString()), + ) + + val anotherBody = fetch(webRequest("$TEST_ENDPOINT/cookies"), GeckoWebExecutor.FETCH_FLAGS_PRIVATE).getJSONBody() + assertThat( + "Body should match", + anotherBody.getJSONObject("cookies").getString("uptimeMillis"), + equalTo(uptimeMillis.toString()), + ) + + val yetAnotherBody = fetch(webRequest("$TEST_ENDPOINT/cookies")).getJSONBody() + assertThat( + "Cookies set in private session are not supposed to be seen in normal download", + yetAnotherBody.getJSONObject("cookies").length(), + equalTo(0), + ) + } + + @Test + fun testSpeculativeConnect() { + // We don't have a way to know if it succeeds or not, but at least we can ensure + // it doesn't explode. + executor.speculativeConnect("http://localhost") + + // This is just a fence to ensure the above actually ran. + fetch(webRequest("$TEST_ENDPOINT/cookies")) + } + + @Test + fun testResolveV4() { + val addresses = executor.resolve("localhost").pollDefault()!! + assertThat( + "Addresses should not be null", + addresses, + notNullValue(), + ) + assertThat( + "First address should be loopback", + addresses.first().isLoopbackAddress, + equalTo(true), + ) + assertThat( + "First address size should be 4", + addresses.first().address.size, + equalTo(4), + ) + } + + @Test + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.LOLLIPOP) + fun testResolveV6() { + val addresses = executor.resolve("ip6-localhost").pollDefault()!! + assertThat( + "Addresses should not be null", + addresses, + notNullValue(), + ) + assertThat( + "First address should be loopback", + addresses.first().isLoopbackAddress, + equalTo(true), + ) + assertThat( + "First address size should be 16", + addresses.first().address.size, + equalTo(16), + ) + } + + @Test + fun testFetchUnknownHost() { + val thrown = assertThrows(WebRequestError::class.java) { + fetch(webRequest("https://this.should.not.resolve")) + } + assertThat(thrown, equalTo(WebRequestError(WebRequestError.ERROR_UNKNOWN_HOST, WebRequestError.ERROR_CATEGORY_URI))) + } + + @Test(expected = UnknownHostException::class) + fun testResolveError() { + executor.resolve("this.should.not.resolve").pollDefault() + } + + @Test + fun testFetchStream() { + val expectedCount = 1 * 1024 * 1024 // 1MB + val response = executor.fetch(webRequest("$TEST_ENDPOINT/bytes/$expectedCount")).pollDefault()!! + + assertThat("Status code should match", response.statusCode, equalTo(200)) + assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount)) + + val stream = response.body!! + val bytes = stream.readBytes() + stream.close() + + assertThat("Byte counts should match", bytes.size, equalTo(expectedCount)) + + val digest = MessageDigest.getInstance("SHA-256").digest(bytes) + assertThat( + "Hashes should match", + response.headers["X-SHA-256"], + equalTo(String.format("%064x", BigInteger(1, digest))), + ) + } + + @Test(expected = IOException::class) + fun testFetchStreamError() { + val expectedCount = 1 * 1024 * 1024 // 1MB + val response = executor.fetch( + webRequest("$TEST_ENDPOINT/bytes/$expectedCount"), + GeckoWebExecutor.FETCH_FLAGS_STREAM_FAILURE_TEST, + ).pollDefault()!! + + assertThat("Status code should match", response.statusCode, equalTo(200)) + assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount)) + + val stream = response.body!! + val bytes = ByteArray(1) + stream.read(bytes) + } + + @Test(expected = IOException::class) + fun readClosedStream() { + val response = executor.fetch(webRequest("$TEST_ENDPOINT/bytes/1024")).pollDefault()!! + + assertThat("Status code should match", response.statusCode, equalTo(200)) + + val stream = response.body!! + stream.close() + stream.readBytes() + } + + @Test(expected = IOException::class) + fun readTimeout() { + val expectedCount = 10 + val response = executor.fetch(webRequest("$TEST_ENDPOINT/trickle/$expectedCount")).pollDefault()!! + + assertThat("Status code should match", response.statusCode, equalTo(200)) + assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount)) + + // Only allow 1ms of blocking. This should reliably timeout with 1MB of data. + response.setReadTimeoutMillis(1) + + val stream = response.body!! + stream.readBytes() + } + + @Test + fun testFetchStreamCancel() { + val expectedCount = 1 * 1024 * 1024 // 1MB + val response = executor.fetch(webRequest("$TEST_ENDPOINT/bytes/$expectedCount")).pollDefault()!! + + assertThat("Status code should match", response.statusCode, equalTo(200)) + assertThat("Content-Length should match", response.headers["Content-Length"]!!.toInt(), equalTo(expectedCount)) + + val stream = response.body!! + + assertThat("Stream should have 0 bytes available", stream.available(), equalTo(0)) + + // Wait a second. Not perfect, but should be enough time for at least one buffer + // to be appended if things are not going as they should. + SystemClock.sleep(1000) + + assertThat("Stream should still have 0 bytes available", stream.available(), equalTo(0)) + + stream.close() + } + + @Test + fun unsupportedUriScheme() { + val illegal = mapOf( + "" to "", + "a" to "a", + "ab" to "ab", + "abc" to "abc", + "htt" to "htt", + "123456789" to "123456789", + "1234567890" to "1234567890", + "12345678901" to "1234567890", + "file://test" to "file://tes", + "moz-extension://what" to "moz-extens", + ) + + for ((uri, truncated) in illegal) { + try { + fetch(webRequest(uri)) + throw IllegalStateException("fetch() should have thrown") + } catch (e: IllegalArgumentException) { + assertThat( + "Message should match", + e.message, + equalTo("Unsupported URI scheme: $truncated"), + ) + } + } + + val legal = listOf( + "http://$TEST_ENDPOINT\n", + "http://$TEST_ENDPOINT/🥲", + "http://$TEST_ENDPOINT/abc", + ) + + for (uri in legal) { + try { + fetch(webRequest(uri)) + throw IllegalStateException("fetch() should have thrown") + } catch (e: WebRequestError) { + assertThat( + "Request should pass initial validation.", + true, + equalTo(true), + ) + } + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt new file mode 100644 index 0000000000..65952ecfb2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt @@ -0,0 +1,2989 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.core.IsEqual.equalTo +import org.hamcrest.core.StringEndsWith.endsWith +import org.json.JSONObject +import org.junit.Assert.* // ktlint-disable no-wildcard-imports +import org.junit.Assume.assumeThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.NavigationDelegate +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.GeckoSession.ProgressDelegate +import org.mozilla.geckoview.WebExtension.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.WebExtension.BrowsingDataDelegate.Type.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.WebExtensionController.EnableSource +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay +import org.mozilla.geckoview.test.util.RuntimeCreator +import org.mozilla.geckoview.test.util.UiThreadUtils +import java.nio.charset.Charset +import java.util.* // ktlint-disable no-wildcard-imports +import java.util.concurrent.CancellationException +import kotlin.collections.HashMap + +@RunWith(AndroidJUnit4::class) +@MediumTest +class WebExtensionTest : BaseSessionTest() { + companion object { + private const val TABS_CREATE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create/" + private const val TABS_CREATE_2_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create-2/" + private const val TABS_CREATE_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-create-remove/" + private const val TABS_ACTIVATE_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-activate-remove/" + private const val TABS_REMOVE_BACKGROUND: String = + "resource://android/assets/web_extensions/tabs-remove/" + private const val MESSAGING_BACKGROUND: String = + "resource://android/assets/web_extensions/messaging/" + private const val MESSAGING_CONTENT: String = + "resource://android/assets/web_extensions/messaging-content/" + private const val OPENOPTIONSPAGE_1_BACKGROUND: String = + "resource://android/assets/web_extensions/openoptionspage-1/" + private const val OPENOPTIONSPAGE_2_BACKGROUND: String = + "resource://android/assets/web_extensions/openoptionspage-2/" + private const val EXTENSION_PAGE_RESTORE: String = + "resource://android/assets/web_extensions/extension-page-restore/" + private const val BROWSING_DATA: String = + "resource://android/assets/web_extensions/browsing-data-built-in/" + } + + private val controller + get() = sessionRule.runtime.webExtensionController + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd(mapOf("extensions.isembedded" to true)) + sessionRule.runtime.webExtensionController.setTabActive(mainSession, true) + } + + @Test + fun installBuiltIn() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + // Load the WebExtension that will add a border to the body + val borderify = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/borderify/", + ), + ) + + assertTrue(borderify.isBuiltIn) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + private fun assertBodyBorderEqualTo(expected: String) { + val color = mainSession.evaluateJS("document.body.style.borderColor") + assertThat( + "The border color should be '$expected'", + color as String, + equalTo(expected), + ) + } + + private fun checkDisabledState( + extension: WebExtension, + userDisabled: Boolean = false, + appDisabled: Boolean = false, + blocklistDisabled: Boolean = false, + ) { + val enabled = !userDisabled && !appDisabled && !blocklistDisabled + + mainSession.reload() + sessionRule.waitForPageStop() + + if (!enabled) { + // Border should be empty because the extension is disabled + assertBodyBorderEqualTo("") + } else { + assertBodyBorderEqualTo("red") + } + + assertThat( + "enabled should match", + extension.metaData.enabled, + equalTo(enabled), + ) + assertThat( + "userDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.USER > 0, + equalTo(userDisabled), + ) + assertThat( + "appDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.APP > 0, + equalTo(appDisabled), + ) + assertThat( + "blocklistDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.BLOCKLIST > 0, + equalTo(blocklistDisabled), + ) + } + + @Test + fun noDelegateErrorMessage() { + try { + sessionRule.evaluateExtensionJS( + """ + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + await browser.tabs.update(tab.id, { url: "www.google.com" }); + """, + ) + assertThat("tabs.update should not succeed", true, equalTo(false)) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: tabs.update is not supported"), + ) + } + + try { + sessionRule.evaluateExtensionJS( + """ + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + await browser.tabs.remove(tab.id); + """, + ) + assertThat("tabs.remove should not succeed", true, equalTo(false)) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: tabs.remove is not supported"), + ) + } + + try { + sessionRule.evaluateExtensionJS( + """ + await browser.runtime.openOptionsPage(); + """, + ) + assertThat( + "runtime.openOptionsPage should not succeed", + true, + equalTo(false), + ) + } catch (ex: RejectedPromiseException) { + assertThat( + "Error message matches", + ex.message, + equalTo("Error: runtime.openOptionsPage is not supported"), + ) + } + } + + @Test + fun enableDisable() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 3) + override fun onEnabling(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onEnabled(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onDisabling(extension: WebExtension) {} + + @AssertCalled(count = 3) + override fun onDisabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onInstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onInstalled(extension: WebExtension) {} + }, + ) + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + var borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.USER)) + checkDisabledState(borderify, userDisabled = true, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = true, appDisabled = true) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = true, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.USER)) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + borderify = sessionRule.waitForResult(controller.disable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = false, appDisabled = true) + + borderify = sessionRule.waitForResult(controller.enable(borderify, EnableSource.APP)) + checkDisabledState(borderify, userDisabled = false, appDisabled = false) + + sessionRule.waitForResult(controller.uninstall(borderify)) + mainSession.reload() + sessionRule.waitForPageStop() + + // Border should be empty because the extension is not installed anymore + assertBodyBorderEqualTo("") + } + + @Test + fun installWebExtension() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals( + extension.metaData.description, + "Adds a red border to all webpages matching example.com.", + ) + assertEquals(extension.metaData.name, "Borderify") + assertEquals(extension.metaData.version, "1.0") + assertEquals(extension.isBuiltIn, false) + assertEquals(extension.metaData.enabled, false) + assertEquals( + extension.metaData.signedState, + WebExtension.SignedStateFlags.SIGNED, + ) + assertEquals( + extension.metaData.blocklistState, + WebExtension.BlocklistStateFlags.NOT_BLOCKED, + ) + + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + var list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 2) + assertTrue(list.containsKey(borderify.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + @Test + @Setting.List(Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true")) + fun runInPrivateBrowsing() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // Make sure border is empty before running the extension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + var borderify = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi"), + ) + + // Make sure private mode is enabled + assertTrue(mainSession.settings.usePrivateMode) + assertFalse(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was not applied to a private mode page + assertBodyBorderEqualTo("") + + borderify = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(borderify, true), + ) + + assertTrue(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was applied to a private mode page now that the extension + // is enabled in private mode + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("red") + + borderify = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(borderify, false), + ) + + assertFalse(borderify.metaData.allowedInPrivateBrowsing) + // Check that the WebExtension was not applied to a private mode page after being + // not allowed to run in private mode + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(borderify)) + mainSession.reload() + sessionRule.waitForPageStop() + assertBodyBorderEqualTo("") + } + + @Test + fun optionsPageMetadata() { + // dummy.xpi is not signed, but it could be + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val dummy = sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/dummy.xpi"), + ) + + val metadata = dummy.metaData + assertTrue((metadata.optionsPageUrl ?: "").matches("^moz-extension://[0-9a-f\\-]*/options.html$".toRegex())) + assertEquals(metadata.openOptionsPageInTab, true) + assertTrue(metadata.baseUrl.matches("^moz-extension://[0-9a-f\\-]*/$".toRegex())) + + sessionRule.waitForResult(controller.uninstall(dummy)) + } + + @Test + fun installMultiple() { + // dummy.xpi is not signed, but it could be + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + // First, make sure the list only contains the test support extension + var list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 2) + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + // Install in parallell borderify and dummy + val borderifyResult = controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + ) + val dummyResult = controller.install( + "resource://android/assets/web_extensions/dummy.xpi", + ) + + val (borderify, dummy) = sessionRule.waitForResult( + GeckoResult.allOf(borderifyResult, dummyResult), + ) + + // Make sure the list is updated accordingly + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertTrue(list.containsKey(borderify.id)) + assertTrue(list.containsKey(dummy.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + assertEquals(list.size, 3) + + // Uninstall borderify and verify that it's not in the list anymore + sessionRule.waitForResult(controller.uninstall(borderify)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 2) + assertTrue(list.containsKey(dummy.id)) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + assertFalse(list.containsKey(borderify.id)) + + // Uninstall dummy and make sure the list is now empty + sessionRule.waitForResult(controller.uninstall(dummy)) + + list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertEquals(list.size, 1) + assertTrue(list.containsKey(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) + } + + private fun testInstallError(name: String, expectedError: Int) { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 0) + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/$name") + .accept({ + // We should not be able to install unsigned extensions + assertTrue(false) + }, { exception -> + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, expectedError) + }), + ) + } + + private fun extensionsMap(extensionList: List): Map { + val map = HashMap() + for (extension in extensionList) { + map.put(extension.id, extension) + } + return map + } + + @Test + fun installUnsignedExtensionSignatureNotRequired() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify-unsigned.xpi", + ) + .then { extension -> + assertEquals( + extension!!.metaData.signedState, + WebExtension.SignedStateFlags.MISSING, + ) + assertEquals( + extension.metaData.blocklistState, + WebExtension.BlocklistStateFlags.NOT_BLOCKED, + ) + assertEquals(extension.metaData.name, "Borderify") + GeckoResult.fromValue(extension) + }, + ) + + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun installUnsignedExtensionSignatureRequired() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to true, + ), + ) + testInstallError( + "borderify-unsigned.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + ) + } + + @Test + fun installExtensionFileNotFound() { + testInstallError( + "file-not-found.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE, + ) + } + + @Test + fun installExtensionMissingId() { + testInstallError( + "borderify-missing-id.xpi", + WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE, + ) + } + + @Test + fun installDeny() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // Ensure border is empty to start. + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.deny() + } + }) + + sessionRule.waitForResult( + controller.install("resource://android/assets/web_extensions/borderify.xpi").accept({ + // We should not be able to install the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not installed and the border is still empty. + assertBodyBorderEqualTo("") + } + + @Test + fun createNotification() { + sessionRule.delegateUntilTestEnd(object : WebNotificationDelegate { + @AssertCalled + override fun onShowNotification(notification: WebNotification) { + } + }) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/notification-test/"), + ) + + sessionRule.waitUntilCalled(object : WebNotificationDelegate { + @AssertCalled(count = 1) + override fun onShowNotification(notification: WebNotification) { + assertEquals(notification.title, "Time for cake!") + assertEquals(notification.text, "Something something cake") + assertEquals(notification.imageUrl, "https://example.com/img.svg") + // This should be filled out, Bug 1589693 + assertEquals(notification.source, null) + } + }) + + sessionRule.waitForResult( + controller.uninstall(extension), + ) + } + + // This test + // - Registers a web extension + // - Listens for messages and waits for a message + // - Sends a response to the message and waits for a second message + // - Verify that the second message has the correct value + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testOnMessage(background: Boolean) { + val messageResult = GeckoResult() + + val prefix = if (background) "testBackground" else "testContent" + + val messageDelegate = object : WebExtension.MessageDelegate { + var awaitingResponse = false + var completed = false + + override fun onConnect(port: WebExtension.Port) { + // Ignored for this test + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + checkSender(nativeApp, sender, background) + + if (!awaitingResponse) { + assertThat( + "We should receive a message from the WebExtension", + message as String, + equalTo("${prefix}BrowserMessage"), + ) + awaitingResponse = true + return GeckoResult.fromValue("${prefix}MessageResponse") + } else if (!completed) { + assertThat( + "The background script should receive our message and respond", + message as String, + equalTo("response: ${prefix}MessageResponse"), + ) + messageResult.complete(null) + completed = true + } + return null + } + } + + val messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(messageResult) + + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + // This test + // - Listen for a new tab request from a web extension + // - Registers a web extension + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserTabsCreate() { + val tabsCreateResult = GeckoResult() + var tabsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult { + assertEquals(details.url, "https://www.mozilla.org/en-US/") + assertEquals(details.active, true) + assertEquals(tabsExtension!!, source) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_BACKGROUND)) + tabsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Listen for a new tab request from a web extension + // - Registers a web extension + // - Extension requests creation of new tab with a cookie store id. + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserTabsCreateWithCookieStoreId() { + sessionRule.setPrefsUntilTestEnd(mapOf("privacy.userContext.enabled" to true)) + val tabsCreateResult = GeckoResult() + var tabsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult { + assertEquals(details.url, "https://www.mozilla.org/en-US/") + assertEquals(details.active, true) + assertEquals(details.cookieStoreId, "1") + assertEquals(tabsExtension!!.id, source.id) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + tabsExtension = sessionRule.waitForResult(controller.installBuiltIn(TABS_CREATE_2_BACKGROUND)) + tabsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs + // - Registers a WebExtension + // - Extension requests creation of new tab + // - TabDelegate handles creation of new tab + // - Extension requests removal of newly created tab + // - TabDelegate handles closing of newly created tab + // - Verify that close request came from right extension and targeted session + @Test + fun testBrowserTabsCreateBrowserTabsRemove() { + val onCloseRequestResult = GeckoResult() + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_CREATE_REMOVE_BACKGROUND), + ) + + tabsExtension.tabDelegate = object : WebExtension.TabDelegate { + override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult { + val extensionCreatedSession = sessionRule.createClosedSession(mainSession.settings) + + extensionCreatedSession.webExtensionController.setTabDelegate( + tabsExtension, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult { + assertEquals(tabsExtension.id, source!!.id) + assertEquals(details.active, true) + assertNotEquals(null, extensionCreatedSession) + assertEquals(extensionCreatedSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + return GeckoResult.fromValue(extensionCreatedSession) + } + } + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle creation and closing of tabs + // - Create and opens a new GeckoSession + // - Set the main session as active tab + // - Registers a WebExtension + // - Extension listens for activated tab changes + // - Set the main session as inactive tab + // - Set the newly created GeckoSession as active tab + // - Extension requests removal of newly created tab if tabs.query({active: true}) + // contains only the newly activated tab + // - TabDelegate handles closing of newly created tab + // - Verify that close request came from right extension and targeted session + @Test + fun testSetTabActive() { + val onCloseRequestResult = GeckoResult() + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_ACTIVATE_REMOVE_BACKGROUND), + ) + val newTabSession = sessionRule.createOpenSession(mainSession.settings) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> newTabSession.webExtensionController.setTabDelegate(tabsExtension, delegate) }, + { newTabSession.webExtensionController.setTabDelegate(tabsExtension, null) }, + object : WebExtension.SessionTabDelegate { + + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult { + assertEquals(tabsExtension, source) + assertEquals(newTabSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + controller.setTabActive(mainSession, false) + controller.setTabActive(newTabSession, true) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + private fun browsingDataMessage( + port: WebExtension.Port, + type: String, + since: Long? = null, + ): GeckoResult { + val message = JSONObject( + "{" + + "\"type\": \"$type\"" + + "}", + ) + if (since != null) { + message.put("since", since) + } + return browsingDataCall(port, message) + } + + private fun browsingDataCall( + port: WebExtension.Port, + json: JSONObject, + ): GeckoResult { + val uuid = UUID.randomUUID().toString() + json.put("uuid", uuid) + port.postMessage(json) + + val response = GeckoResult() + port.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + assertThat( + "Response ID Matches.", + (message as JSONObject).getString("uuid"), + equalTo(uuid), + ) + response.complete(message) + } + }) + return response + } + + @Test + fun testBrowsingDataDelegateBuiltIn() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn(BROWSING_DATA), + ) + + val portResult = GeckoResult() + extension.setMessageDelegate( + object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + portResult.complete(port) + } + }, + "browser", + ) + + val TEST_SINCE_VALUE = 59294 + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.BrowsingDataDelegate::class, + { delegate -> extension.browsingDataDelegate = delegate }, + { extension.browsingDataDelegate = null }, + object : WebExtension.BrowsingDataDelegate { + override fun onGetSettings(): GeckoResult? { + return GeckoResult.fromValue( + WebExtension.BrowsingDataDelegate.Settings( + TEST_SINCE_VALUE, + CACHE or COOKIES or DOWNLOADS or HISTORY or LOCAL_STORAGE, + CACHE or COOKIES or HISTORY, + ), + ) + } + }, + ) + + val port = sessionRule.waitForResult(portResult) + + // Test browsingData.removeDownloads + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-downloads", 1234)) + + // Test browsingData.removeFormData + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-form-data", 1234)) + + // Test browsingData.removeHistory + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-history", 1234)) + + // Test browsingData.removePasswords + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(1234L), + ) + return null + } + }) + sessionRule.waitForResult(browsingDataMessage(port, "clear-passwords", 1234)) + + // Test browsingData.remove({ indexedDB: true, localStorage: true, passwords: true }) + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + }) + var response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"localStorage\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove should succeed", + response.getString("type"), + equalTo("response"), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove should succeed", + response.getString("type"), + equalTo("response"), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + // with failure + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return null + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized.")) + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + assertThat( + "browsingData.remove returns expected error.", + response.getString("error"), + equalTo("Not authorized."), + ) + + // Test browsingData.remove({ indexedDB: true, history: true, passwords: true }) + // with multiple failures + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + @AssertCalled + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized passwords.")) + } + + @AssertCalled + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult? { + assertThat( + "timestamp should match", + sinceUnixTimestamp, + equalTo(0L), + ) + return GeckoResult.fromException(RuntimeException("Not authorized history.")) + } + }) + response = sessionRule.waitForResult( + browsingDataCall( + port, + JSONObject( + "{" + + "\"type\": \"clear\"," + + "\"removalOptions\": {}," + + "\"dataTypes\": {\"indexedDB\": true, \"history\": true, \"passwords\": true}" + + "}", + ), + ), + ) + val error = response.getString("error") + assertThat( + "browsingData.remove returns expected error.", + error == "Not authorized passwords." || error == "Not authorized history.", + equalTo(true), + ) + + // Test browsingData.settings() + response = sessionRule.waitForResult( + browsingDataMessage(port, "get-settings"), + ) + + val settings = response.getJSONObject("result") + val dataToRemove = settings.getJSONObject("dataToRemove") + val options = settings.getJSONObject("options") + + assertThat( + "Since should be correct", + options.getInt("since"), + equalTo(TEST_SINCE_VALUE), + ) + for (key in listOf("cache", "cookies", "history")) { + assertThat( + "Data to remove should be correct", + dataToRemove.getBoolean(key), + equalTo(true), + ) + } + for (key in listOf("downloads", "localStorage")) { + assertThat( + "Data to remove should be correct", + dataToRemove.getBoolean(key), + equalTo(false), + ) + } + + val dataRemovalPermitted = settings.getJSONObject("dataRemovalPermitted") + for (key in listOf("cache", "cookies", "downloads", "history", "localStorage")) { + assertThat( + "Data removal permitted should be correct", + dataRemovalPermitted.getBoolean(key), + equalTo(true), + ) + } + + // Test browsingData.settings() with no delegate + sessionRule.delegateDuringNextWait(object : WebExtension.BrowsingDataDelegate { + override fun onGetSettings(): GeckoResult? { + return null + } + }) + response = sessionRule.waitForResult( + browsingDataMessage(port, "get-settings"), + ) + assertThat( + "browsingData.settings returns expected error.", + response.getString("error"), + equalTo("browsingData.settings is not supported"), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + @Test + fun testBrowsingDataDelegate() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val extension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/browsing-data.xpi"), + ) + + val accumulator = mutableListOf() + val result = GeckoResult>() + + extension.browsingDataDelegate = object : WebExtension.BrowsingDataDelegate { + fun register(type: String, timestamp: Long) { + accumulator.add("$type $timestamp") + if (accumulator.size >= 5) { + result.complete(accumulator) + } + } + + override fun onClearDownloads(sinceUnixTimestamp: Long): GeckoResult { + register("downloads", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult { + register("formData", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult { + register("history", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + + override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult { + register("passwords", sinceUnixTimestamp) + return GeckoResult.fromValue(null) + } + } + + val actual = sessionRule.waitForResult(result) + assertThat( + "Delegate methods get called in the right order", + actual, + equalTo( + listOf( + "downloads 10001", + "formData 10002", + "history 10003", + "passwords 10004", + "downloads 10005", + ), + ), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // Same as testSetTabActive when the extension is not allowed in private browsing + @Test + fun testSetTabActiveNotAllowedInPrivateBrowsing() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val onCloseRequestResult = GeckoResult() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + val tabsExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/tabs-activate-remove.xpi"), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + var tabsExtensionPB = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/tabs-activate-remove-2.xpi"), + ) + + tabsExtensionPB = sessionRule.waitForResult( + controller.setAllowedInPrivateBrowsing(tabsExtensionPB, true), + ) + + val newTabSession = sessionRule.createOpenSession(mainSession.settings) + + val newPrivateSession = sessionRule.createOpenSession( + GeckoSessionSettings.Builder().usePrivateMode(true).build(), + ) + + val privateBrowsingNewTabSession = GeckoResult() + + class TabDelegate( + val result: GeckoResult, + val extension: WebExtension, + val expectedSession: GeckoSession, + ) : + WebExtension.SessionTabDelegate { + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult { + assertEquals(extension.id, source!!.id) + assertEquals(expectedSession, session) + result.complete(null) + return GeckoResult.allow() + } + } + + newTabSession.webExtensionController.setTabDelegate( + tabsExtensionPB, + TabDelegate(privateBrowsingNewTabSession, tabsExtensionPB, newTabSession), + ) + + newTabSession.webExtensionController.setTabDelegate( + tabsExtension, + TabDelegate(onCloseRequestResult, tabsExtension, newTabSession), + ) + + val privateBrowsingPrivateSession = GeckoResult() + + newPrivateSession.webExtensionController.setTabDelegate( + tabsExtensionPB, + TabDelegate(privateBrowsingPrivateSession, tabsExtensionPB, newPrivateSession), + ) + + // tabsExtension is not allowed in private browsing and shouldn't get this event + newPrivateSession.webExtensionController.setTabDelegate( + tabsExtension, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult { + privateBrowsingPrivateSession.completeExceptionally( + RuntimeException("Should never happen"), + ) + return GeckoResult.allow() + } + }, + ) + + controller.setTabActive(mainSession, false) + controller.setTabActive(newPrivateSession, true) + + sessionRule.waitForResult(privateBrowsingPrivateSession) + + controller.setTabActive(newPrivateSession, false) + controller.setTabActive(newTabSession, true) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(privateBrowsingNewTabSession) + + sessionRule.waitForResult( + sessionRule.runtime.webExtensionController.uninstall(tabsExtension), + ) + sessionRule.waitForResult( + sessionRule.runtime.webExtensionController.uninstall(tabsExtensionPB), + ) + + newTabSession.close() + newPrivateSession.close() + } + + // Verifies that the following messages are received from an extension page loaded in the session + // - HELLO_FROM_PAGE_1 from nativeApp browser1 + // - HELLO_FROM_PAGE_2 from nativeApp browser2 + // - connection request from browser1 + // - HELLO_FROM_PORT from the port opened at the above step + private fun testExtensionMessages(extension: WebExtension, session: GeckoSession) { + val messageResult2 = GeckoResult() + session.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + messageResult2.complete(message as String) + return null + } + }, + "browser2", + ) + + val message2 = sessionRule.waitForResult(messageResult2) + assertThat( + "Message is received correctly", + message2, + equalTo("HELLO_FROM_PAGE_2"), + ) + + val messageResult1 = GeckoResult() + val portResult = GeckoResult() + session.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + messageResult1.complete(message as String) + return null + } + + override fun onConnect(port: WebExtension.Port) { + portResult.complete(port) + } + }, + "browser1", + ) + + val message1 = sessionRule.waitForResult(messageResult1) + assertThat( + "Message is received correctly", + message1, + equalTo("HELLO_FROM_PAGE_1"), + ) + + val port = sessionRule.waitForResult(portResult) + val portMessageResult = GeckoResult() + port.setDelegate(object : WebExtension.PortDelegate { + override fun onPortMessage(message: Any, port: WebExtension.Port) { + portMessageResult.complete(message as String) + } + }) + + val portMessage = sessionRule.waitForResult(portMessageResult) + assertThat( + "Message is received correctly", + portMessage, + equalTo("HELLO_FROM_PORT"), + ) + } + + // This test: + // - loads an extension that tries to send some messages when loading tab.html + // - verifies that the messages are received when loading the tab normally + // - verifies that the messages are received when restoring the tab in a fresh session + @Test + fun testRestoringExtensionPagePreservesMessages() { + // TODO: Bug 1648158 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + val extension = sessionRule.waitForResult( + controller.installBuiltIn(EXTENSION_PAGE_RESTORE), + ) + + mainSession.loadUri("${extension.metaData.baseUrl}tab.html") + sessionRule.waitForPageStop() + + var savedState: GeckoSession.SessionState? = null + sessionRule.waitUntilCalled(object : ProgressDelegate { + @AssertCalled(count = 1) + override fun onSessionStateChange(session: GeckoSession, state: GeckoSession.SessionState) { + savedState = state + } + }) + + // Test that messages are received in the main session + testExtensionMessages(extension, mainSession) + + val newSession = sessionRule.createOpenSession() + newSession.restoreState(savedState!!) + newSession.waitForPageStop() + + // Test that messages are received in a restored state + testExtensionMessages(extension, newSession) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // This test + // - Create and assign WebExtension TabDelegate to handle closing of tabs + // - Create new GeckoSession for WebExtension to close + // - Load url that will allow extension to identify the tab + // - Registers a WebExtension + // - Extension finds the tab by url and removes it + // - TabDelegate handles closing of the tab + // - Verify that request targets previously created GeckoSession + @Test + fun testBrowserTabsRemove() { + val onCloseRequestResult = GeckoResult() + val existingSession = sessionRule.createOpenSession() + + existingSession.loadTestPath("$HELLO_HTML_PATH?tabToClose") + existingSession.waitForPageStop() + + val tabsExtension = sessionRule.waitForResult( + controller.installBuiltIn(TABS_REMOVE_BACKGROUND), + ) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> existingSession.webExtensionController.setTabDelegate(tabsExtension, delegate) }, + { existingSession.webExtensionController.setTabDelegate(tabsExtension, null) }, + object : WebExtension.SessionTabDelegate { + override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult { + assertEquals(existingSession, session) + onCloseRequestResult.complete(null) + return GeckoResult.allow() + } + }, + ) + + sessionRule.waitForResult(onCloseRequestResult) + sessionRule.waitForResult(controller.uninstall(tabsExtension)) + } + + private fun installWebExtension( + background: Boolean, + messageDelegate: WebExtension.MessageDelegate, + ): WebExtension { + val webExtension: WebExtension + + if (background) { + webExtension = sessionRule.waitForResult( + controller.installBuiltIn(MESSAGING_BACKGROUND), + ) + webExtension.setMessageDelegate(messageDelegate, "browser") + } else { + webExtension = sessionRule.waitForResult( + controller.installBuiltIn(MESSAGING_CONTENT), + ) + mainSession.webExtensionController + .setMessageDelegate(webExtension, messageDelegate, "browser") + } + + return webExtension + } + + @Test + fun contentMessaging() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testOnMessage(false) + } + + @Test + fun backgroundMessaging() { + testOnMessage(true) + } + + // This test + // - installs a web extension + // - waits for the web extension to connect to the browser + // - on connect it will start listening on the port for a message + // - When the message is received it sends a message in response and waits for another message + // - When the second message is received it verifies it contains the expected value + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testPortMessage(background: Boolean) { + val result = GeckoResult() + val prefix = if (background) "testBackground" else "testContent" + + val portDelegate = object : WebExtension.PortDelegate { + var awaitingResponse = false + + override fun onPortMessage(message: Any, port: WebExtension.Port) { + assertEquals(port.name, "browser") + + if (!awaitingResponse) { + assertThat( + "We should receive a message from the WebExtension", + message as String, + equalTo("${prefix}PortMessage"), + ) + port.postMessage(JSONObject("{\"message\": \"${prefix}PortMessageResponse\"}")) + awaitingResponse = true + } else { + assertThat( + "The background script should receive our message and respond", + message as String, + equalTo("response: ${prefix}PortMessageResponse"), + ) + result.complete(null) + } + } + + override fun onDisconnect(port: WebExtension.Port) { + // ignored + } + } + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + checkSender(port.name, port.sender, background) + + assertEquals(port.name, "browser") + + port.setDelegate(portDelegate) + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + // Ignored for this test + return null + } + } + + val messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortMessaging() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortMessage(false) + } + + @Test + fun backgroundPortMessaging() { + testPortMessage(true) + } + + // This test + // - Registers a web extension + // - Awaits for the web extension to connect to the browser + // - When connected, it triggers a disconnection from the other side and verifies that + // the browser is notified of the port being disconnected. + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + // + // When `refresh == true` the disconnection will be triggered by refreshing the page, otherwise + // it will be triggered by sending a message to the web extension. + private fun testPortDisconnect(background: Boolean, refresh: Boolean) { + val result = GeckoResult() + + var messaging: WebExtension? = null + var messagingPort: WebExtension.Port? = null + + val portDelegate = object : WebExtension.PortDelegate { + override fun onPortMessage( + message: Any, + port: WebExtension.Port, + ) { + assertEquals(port, messagingPort) + } + + override fun onDisconnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + assertEquals(port, messagingPort) + // We successfully received a disconnection + result.complete(null) + } + } + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + checkSender(port.name, port.sender, background) + + assertEquals(port.name, "browser") + messagingPort = port + port.setDelegate(portDelegate) + + if (refresh) { + // Refreshing the page should disconnect the port + mainSession.reload() + } else { + // Let's ask the web extension to disconnect this port + val message = JSONObject() + message.put("action", "disconnect") + + port.postMessage(message) + } + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(messaging!!.id, sender.webExtension.id) + + // Ignored for this test + return null + } + } + + messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortDisconnect() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnect(background = false, refresh = false) + } + + @Test + fun backgroundPortDisconnect() { + testPortDisconnect(background = true, refresh = false) + } + + @Test + fun contentPortDisconnectAfterRefresh() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnect(background = false, refresh = true) + } + + fun checkSender(nativeApp: String, sender: WebExtension.MessageSender, background: Boolean) { + assertEquals("nativeApp should always be 'browser'", nativeApp, "browser") + + if (background) { + // For background scripts we only want messages from the extension, this should never + // happen and it's a bug if we get here. + assertEquals( + "Called from content script with background-only delegate.", + sender.environmentType, + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + ) + assertTrue( + "Unexpected sender url", + sender.url.endsWith("/_generated_background_page.html"), + ) + } else { + assertEquals( + "Called from background script, expecting only content scripts", + sender.environmentType, + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + ) + assertTrue("Expecting only top level senders.", sender.isTopLevel) + assertEquals("Unexpected sender url", sender.url, "https://example.com/") + } + } + + // This test + // - Register a web extension and waits for connections + // - When connected it disconnects the port from the app side + // - Awaits for a message from the web extension confirming the web extension was notified of + // port being closed. + // + // When `background == true` the test will be run using background messaging, otherwise the + // test will use content script messaging. + private fun testPortDisconnectFromApp(background: Boolean) { + val result = GeckoResult() + + var messaging: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + checkSender(port.name, port.sender, background) + + port.disconnect() + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(messaging!!.id, sender.webExtension.id) + checkSender(nativeApp, sender, background) + + if (message is JSONObject) { + if (message.getString("type") == "portDisconnected") { + result.complete(null) + } + } + + return null + } + } + + messaging = installWebExtension(background, messageDelegate) + sessionRule.waitForResult(result) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun contentPortDisconnectFromApp() { + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + testPortDisconnectFromApp(false) + } + + @Test + fun backgroundPortDisconnectFromApp() { + testPortDisconnectFromApp(true) + } + + // This test checks that scripts running in a iframe have the `isTopLevel` property set to false. + private fun testIframeTopLevel() { + val portTopLevel = GeckoResult() + val portIframe = GeckoResult() + val messageTopLevel = GeckoResult() + val messageIframe = GeckoResult() + + var messaging: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onConnect(port: WebExtension.Port) { + assertEquals(messaging!!.id, port.sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + port.sender.environmentType, + ) + when (port.sender.url) { + "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> { + assertTrue(port.sender.isTopLevel) + portTopLevel.complete(null) + } + "$TEST_ENDPOINT$HELLO_HTML_PATH" -> { + assertFalse(port.sender.isTopLevel) + portIframe.complete(null) + } + else -> // We shouldn't get other messages + fail() + } + + port.disconnect() + } + + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(messaging!!.id, sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT, + sender.environmentType, + ) + when (sender.url) { + "$TEST_ENDPOINT$HELLO_IFRAME_HTML_PATH" -> { + assertTrue(sender.isTopLevel) + messageTopLevel.complete(null) + } + "$TEST_ENDPOINT$HELLO_HTML_PATH" -> { + assertFalse(sender.isTopLevel) + messageIframe.complete(null) + } + else -> // We shouldn't get other messages + fail() + } + + return null + } + } + + messaging = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/messaging-iframe/", + ), + ) + mainSession.webExtensionController + .setMessageDelegate(messaging, messageDelegate, "browser") + sessionRule.waitForResult(portTopLevel) + sessionRule.waitForResult(portIframe) + sessionRule.waitForResult(messageTopLevel) + sessionRule.waitForResult(messageIframe) + sessionRule.waitForResult(controller.uninstall(messaging)) + } + + @Test + fun iframeTopLevel() { + mainSession.loadTestPath(HELLO_IFRAME_HTML_PATH) + sessionRule.waitForPageStop() + testIframeTopLevel() + } + + @Test + fun redirectToExtensionResource() { + val result = GeckoResult() + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(message, "setupReadyStartTest") + result.complete(null) + return null + } + } + + val extension = sessionRule.waitForResult( + controller.installBuiltIn( + "resource://android/assets/web_extensions/redirect-to-android-resource/", + ), + ) + + extension.setMessageDelegate(messageDelegate, "browser") + sessionRule.waitForResult(result) + + // Extension has set up some webRequest listeners to redirect requests. + // Open the test page and verify that the extension has redirected the + // scripts as expected. + mainSession.loadTestPath(TRACKERS_PATH) + sessionRule.waitForPageStop() + + val textContent = mainSession.evaluateJS("document.body.textContent.replace(/\\s/g, '')") + assertThat( + "The extension should have rewritten the script requests and the body", + textContent as String, + equalTo("start,extension-was-here,end"), + ) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + @Test + fun loadWebExtensionPage() { + val result = GeckoResult() + var extension: WebExtension? = null + + val messageDelegate = object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + assertEquals(extension!!.id, sender.webExtension.id) + assertEquals( + WebExtension.MessageSender.ENV_TYPE_EXTENSION, + sender.environmentType, + ) + result.complete(message as String) + + return null + } + } + + extension = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/extension-page-update/", + "extension-page-update@tests.mozilla.org", + ), + ) + + val sessionController = mainSession.webExtensionController + sessionController.setMessageDelegate(extension, messageDelegate, "browser") + sessionController.setTabDelegate( + extension, + object : WebExtension.SessionTabDelegate { + override fun onUpdateTab( + extension: WebExtension, + session: GeckoSession, + details: WebExtension.UpdateTabDetails, + ): GeckoResult { + return GeckoResult.allow() + } + }, + ) + + mainSession.loadUri("https://example.com") + + mainSession.waitUntilCalled(object : NavigationDelegate, ProgressDelegate { + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + assertThat( + "Url should load example.com first", + url, + equalTo("https://example.com/"), + ) + } + + @GeckoSessionTestRule.AssertCalled(count = 1) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat( + "Page should load successfully.", + success, + equalTo(true), + ) + } + }) + + var page: String? = null + val pageStop = GeckoResult() + + mainSession.delegateUntilTestEnd(object : NavigationDelegate, ProgressDelegate { + override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList) { + page = url + } + + override fun onPageStop(session: GeckoSession, success: Boolean) { + if (success && page != null && page!!.endsWith("/tab.html")) { + pageStop.complete(true) + } + } + }) + + // If ensureBuiltIn works correctly, this will not re-install the extension. + // We can verify that it won't reinstall because that would cause the extension page to + // close prematurely, making the test fail. + val ensure = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/extension-page-update/", + "extension-page-update@tests.mozilla.org", + ), + ) + + assertThat("ID match", ensure.id, equalTo(extension.id)) + assertThat("version match", ensure.metaData.version, equalTo(extension.metaData.version)) + + // Make sure the page loaded successfully + sessionRule.waitForResult(pageStop) + + assertThat("Url should load WebExtension page", page, endsWith("/tab.html")) + + assertThat( + "WebExtension page should have access to privileged APIs", + sessionRule.waitForResult(result), + equalTo("HELLO_FROM_PAGE"), + ) + + // Test that after uninstalling an extension, all its pages get closed + sessionRule.addExternalDelegateUntilTestEnd( + WebExtension.SessionTabDelegate::class, + { delegate -> mainSession.webExtensionController.setTabDelegate(extension, delegate) }, + { mainSession.webExtensionController.setTabDelegate(extension, null) }, + object : WebExtension.SessionTabDelegate {}, + ) + + val uninstall = controller.uninstall(extension) + + sessionRule.waitUntilCalled(object : WebExtension.SessionTabDelegate { + @AssertCalled + override fun onCloseTab( + source: WebExtension?, + session: GeckoSession, + ): GeckoResult { + assertEquals(extension.id, source!!.id) + assertEquals(mainSession, session) + return GeckoResult.allow() + } + }) + + sessionRule.waitForResult(uninstall) + } + + @Test + fun badUrl() { + testInstallBuiltInError("invalid url", "Could not parse uri") + } + + @Test + fun badHost() { + testInstallBuiltInError("resource://gre/", "Only resource://android") + } + + @Test + fun dontAllowRemoteUris() { + testInstallBuiltInError("https://example.com/extension/", "Only resource://android") + } + + @Test + fun badFileType() { + testInstallBuiltInError( + "resource://android/bad/location/error", + "does not point to a folder", + ) + } + + @Test + fun badLocationXpi() { + testInstallBuiltInError( + "resource://android/bad/location/error.xpi", + "does not point to a folder", + ) + } + + @Test + fun testInstallBuiltInError() { + testInstallBuiltInError( + "resource://android/bad/location/error/", + "does not contain a valid manifest", + ) + } + + private fun testInstallBuiltInError(location: String, expectedError: String) { + try { + sessionRule.waitForResult(controller.installBuiltIn(location)) + } catch (ex: Exception) { + // Let's make sure the error message contains the expected error message + assertTrue(ex.message!!.contains(expectedError)) + + return + } + + fail("The above code should throw.") + } + + // Test web extension permission.request. + @WithDisplay(width = 100, height = 100) + @Test + fun permissionRequest() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + val extension = sessionRule.waitForResult( + controller.ensureBuiltIn( + "resource://android/assets/web_extensions/permission-request/", + "permissions@example.com", + ), + ) + + mainSession.loadUri("${extension.metaData.baseUrl}clickToRequestPermission.html") + sessionRule.waitForPageStop() + + // click triggers permissions.request + mainSession.synthesizeTap(50, 50) + + sessionRule.delegateUntilTestEnd(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 2) + override fun onOptionalPrompt(extension: WebExtension, permissions: Array, origins: Array): GeckoResult { + val expected = arrayOf("geolocation") + assertThat("Permissions should match the requested permissions", permissions, equalTo(expected)) + assertThat("Origins should match the requested origins", origins, equalTo(arrayOf("*://example.com/*"))) + return forEachCall(GeckoResult.deny(), GeckoResult.allow()) + } + }) + + var result = GeckoResult() + mainSession.webExtensionController.setMessageDelegate( + extension, + object : WebExtension.MessageDelegate { + override fun onMessage( + nativeApp: String, + message: Any, + sender: WebExtension.MessageSender, + ): GeckoResult? { + result.complete(message as String) + return null + } + }, + "browser", + ) + + val message = sessionRule.waitForResult(result) + assertThat("Permission request should first be denied.", message, equalTo("false")) + + mainSession.synthesizeTap(50, 50) + result = GeckoResult() + val message2 = sessionRule.waitForResult(result) + assertThat("Permission request should be accepted.", message2, equalTo("true")) + + mainSession.synthesizeTap(50, 50) + result = GeckoResult() + val message3 = sessionRule.waitForResult(result) + assertThat("Permission request should already be accepted.", message3, equalTo("true")) + + sessionRule.waitForResult(controller.uninstall(extension)) + } + + // Test the basic update extension flow with no new permissions. + @Test + fun update() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertEquals(update2.metaData.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Test extension updating when the new extension has different permissions. + @Test + fun updateWithPerms() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt( + currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array, + newOrigins: Array, + ): GeckoResult { + assertEquals(currentlyInstalled.metaData.version, "1.0") + assertEquals(updatedExtension.metaData.version, "2.0") + assertEquals(newPermissions.size, 1) + assertEquals(newPermissions[0], "tabs") + return GeckoResult.allow() + } + }) + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertEquals(update2.metaData.version, "2.0") + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("blue") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update2)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Ensure update extension works as expected when there is no update available. + @Test + fun updateNotAvailable() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData.version, "2.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-2.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("blue") + + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertNull(update2) + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + // Test denying an extension update. + @Test + fun updateDenyPerms() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-with-perms-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onUpdatePrompt( + currentlyInstalled: WebExtension, + updatedExtension: WebExtension, + newPermissions: Array, + newOrigins: Array, + ): GeckoResult { + assertEquals(currentlyInstalled.metaData.version, "1.0") + assertEquals(updatedExtension.metaData.version, "2.0") + return GeckoResult.deny() + } + }) + + sessionRule.waitForResult( + controller.update(update1).accept({ + // We should not be able to update the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_USER_CANCELED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that updated extension changed the border color. + assertBodyBorderEqualTo("red") + + // Uninstall WebExtension and check again + sessionRule.waitForResult(controller.uninstall(update1)) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was not applied after being uninstalled + assertBodyBorderEqualTo("") + } + + @Test(expected = CancellationException::class) + fun cancelInstall() { + val install = controller.install("$TEST_ENDPOINT/stall/test.xpi") + val cancel = sessionRule.waitForResult(install.cancel()) + assertTrue(cancel) + + sessionRule.waitForResult(install) + } + + @Test + fun cancelInstallFailsAfterInstalled() { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + var install = controller.install("resource://android/assets/web_extensions/borderify.xpi") + val borderify = sessionRule.waitForResult(install) + + val cancel = sessionRule.waitForResult(install.cancel()) + assertFalse(cancel) + + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun updatePostpone() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + "extensions.webextensions.warnings-as-errors" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // First let's check that the color of the border is empty before loading + // the WebExtension + assertBodyBorderEqualTo("") + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + assertEquals(extension.metaData.version, "1.0") + return GeckoResult.allow() + } + }) + + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-postpone-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension was applied by checking the border color + assertBodyBorderEqualTo("red") + + sessionRule.waitForResult( + controller.update(update1).accept({ + // We should not be able to update the extension. + assertTrue(false) + }, { exception -> + assertTrue(exception is WebExtension.InstallException) + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, WebExtension.InstallException.ErrorCodes.ERROR_POSTPONED) + }), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + // Check that the WebExtension is still the first extension. + assertBodyBorderEqualTo("red") + + sessionRule.waitForResult(controller.uninstall(update1)) + } + + /* + This function installs a web extension, disables it, updates it and uninstalls it + + @param source: Int - represents a logical type; can be EnableSource.APP or EnableSource.USER + */ + private fun testUpdatingExtensionDisabledBy(source: Int) { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 0) + override fun onEnabling(extension: WebExtension) {} + + @AssertCalled(count = 0) + override fun onEnabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onDisabling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onDisabled(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalling(extension: WebExtension) {} + + @AssertCalled(count = 1) + override fun onUninstalled(extension: WebExtension) {} + + // We expect onInstalling/onInstalled to be invoked twice + // because we first install the extension and then we update + // it, which results in a second install. + @AssertCalled(count = 2) + override fun onInstalling(extension: WebExtension) {} + + @AssertCalled(count = 2) + override fun onInstalled(extension: WebExtension) {} + }, + ) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi"), + ) + + mainSession.reload() + sessionRule.waitForPageStop() + + val disabledWebExtension = sessionRule.waitForResult(controller.disable(webExtension, source)) + + when (source) { + EnableSource.APP -> checkDisabledState(disabledWebExtension, appDisabled = true) + EnableSource.USER -> checkDisabledState(disabledWebExtension, userDisabled = true) + } + + val updatedWebExtension = sessionRule.waitForResult(controller.update(disabledWebExtension)) + + mainSession.reload() + sessionRule.waitForPageStop() + + sessionRule.waitForResult(controller.uninstall(updatedWebExtension)) + } + + @Test + fun updateDisabledByUser() { + testUpdatingExtensionDisabledBy(EnableSource.USER) + } + + @Test + fun updateDisabledByApp() { + testUpdatingExtensionDisabledBy(EnableSource.APP) + } + + // This test + // - Listen for a newTab request from a web extension + // - Registers a web extension + // - Waits for onNewTab request + // - Verify that request came from right extension + @Test + fun testBrowserRuntimeOpenOptionsPageInNewTab() { + val tabsCreateResult = GeckoResult() + var optionsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + @AssertCalled(count = 1) + override fun onNewTab( + source: WebExtension, + details: WebExtension.CreateTabDetails, + ): GeckoResult { + assertThat(details.url, endsWith("options.html")) + assertEquals(details.active, true) + assertEquals(optionsExtension!!.id, source.id) + tabsCreateResult.complete(null) + return GeckoResult.fromValue(null) + } + } + + optionsExtension = sessionRule.waitForResult( + controller.installBuiltIn(OPENOPTIONSPAGE_1_BACKGROUND), + ) + optionsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(tabsCreateResult) + + sessionRule.waitForResult(controller.uninstall(optionsExtension)) + } + + // This test + // - Listen for an openOptionsPage request from a web extension + // - Registers a web extension + // - Waits for onOpenOptionsPage request + // - Verify that request came from right extension + @Test + fun testBrowserRuntimeOpenOptionsPageDelegate() { + val openOptionsPageResult = GeckoResult() + var optionsExtension: WebExtension? = null + val tabDelegate = object : WebExtension.TabDelegate { + @AssertCalled(count = 1) + override fun onOpenOptionsPage(source: WebExtension) { + assertThat( + source.metaData.optionsPageUrl, + endsWith("options.html"), + ) + assertEquals(optionsExtension!!.id, source.id) + openOptionsPageResult.complete(null) + } + } + + optionsExtension = sessionRule.waitForResult( + controller.installBuiltIn(OPENOPTIONSPAGE_2_BACKGROUND), + ) + optionsExtension.setTabDelegate(tabDelegate) + sessionRule.waitForResult(openOptionsPageResult) + + sessionRule.waitForResult(controller.uninstall(optionsExtension)) + } + + // This test checks if the request from Web Extension is processed correctly in Java + // the Boolean flags are true, other options have non-default values + @Test + fun testDownloadsFlagsTrue() { + val uri = createTestUrl("/assets/www/images/test.gif") + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/download-flags-true.xpi"), + ) + + val assertOnDownloadCalled = GeckoResult() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + assertEquals("POST", request.request.method) + + request.request.body?.rewind() + val result = Charset.forName("UTF-8").decode(request.request.body!!).toString() + assertEquals("postbody", result) + + assertEquals("Mozilla Firefox", request.request.headers.get("User-Agent")) + assertEquals("banana.gif", request.filename) + assertTrue(request.allowHttpErrors) + assertTrue(request.saveAs) + assertEquals(GeckoWebExecutor.FETCH_FLAGS_PRIVATE, request.downloadFlags) + assertEquals(DownloadRequest.CONFLICT_ACTION_OVERWRITE, request.conflictActionFlag) + + val download = controller.createDownload(1) + assertOnDownloadCalled.complete(download) + + val downloadInfo = object : Download.Info {} + + val initialData = DownloadInitData(download, downloadInfo) + return GeckoResult.fromValue(initialData) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + + mainSession.reload() + sessionRule.waitForPageStop() + + try { + sessionRule.waitForResult(assertOnDownloadCalled) + } catch (exception: UiThreadUtils.TimeoutException) { + controller.setAllowedInPrivateBrowsing(webExtension, true) + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertNotNull(downloadCreated.id) + + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + } + + // This test checks if the request from Web Extension is processed correctly in Java + // the Boolean flags are absent/false, other options have default values + @Test + fun testDownloadsFlagsFalse() { + val uri = createTestUrl("/assets/www/images/test.gif") + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult { + return GeckoResult.allow() + } + }) + + val webExtension = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/download-flags-false.xpi"), + ) + + val assertOnDownloadCalled = GeckoResult() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + assertEquals("GET", request.request.method) + assertNull(request.request.body) + assertEquals(0, request.request.headers.size) + assertNull(request.filename) + assertFalse(request.allowHttpErrors) + assertFalse(request.saveAs) + assertEquals(GeckoWebExecutor.FETCH_FLAGS_NONE, request.downloadFlags) + assertEquals(DownloadRequest.CONFLICT_ACTION_UNIQUIFY, request.conflictActionFlag) + + val download = controller.createDownload(2) + assertOnDownloadCalled.complete(download) + + val downloadInfo = object : Download.Info {} + + val initialData = DownloadInitData(download, downloadInfo) + return GeckoResult.fromValue(initialData) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + + mainSession.reload() + sessionRule.waitForPageStop() + + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertNotNull(downloadCreated.id) + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + + @Test + fun testOnChanged() { + val uri = createTestUrl("/assets/www/images/test.gif") + val downloadId = 4 + val unfinishedDownloadSize = 5L + val finishedDownloadSize = 25L + val expectedFilename = "test.gif" + val expectedMime = "image/gif" + val expectedEndTime = Date().time + val expectedFilesize = 48L + + // first and second update + val downloadData = object : Download.Info { + var endTime: Long? = null + val startTime = Date().time - 50000 + var fileExists = false + var totalBytes: Long = -1 + var mime = "" + var fileSize: Long = -1 + var filename = "" + var state = Download.STATE_IN_PROGRESS + + override fun state(): Int { + return state + } + + override fun endTime(): Long? { + return endTime + } + + override fun startTime(): Long { + return startTime + } + + override fun fileExists(): Boolean { + return fileExists + } + + override fun totalBytes(): Long { + return totalBytes + } + + override fun mime(): String { + return mime + } + + override fun fileSize(): Long { + return fileSize + } + + override fun filename(): String { + return filename + } + } + + val webExtension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"), + ) + + val assertOnDownloadCalled = GeckoResult() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + + val download = controller.createDownload(downloadId) + assertOnDownloadCalled.complete(download) + return GeckoResult.fromValue(DownloadInitData(download, downloadData)) + } + } + + val updates = mutableListOf() + + val thirdUpdateReceived = GeckoResult() + val messageDelegate = object : MessageDelegate { + override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult? { + val current = (message as JSONObject).getJSONObject("current") + + updates.add(message) + + // Once we get the size finished download, that means we got the last update + if (current.getLong("totalBytes") == finishedDownloadSize) { + thirdUpdateReceived.complete(message) + } + + return GeckoResult.fromValue(message) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + webExtension.setMessageDelegate(messageDelegate, "browser") + + mainSession.reload() + sessionRule.waitForPageStop() + + val downloadCreated = sessionRule.waitForResult(assertOnDownloadCalled) + assertEquals(downloadId, downloadCreated.id) + + // first and second update (they are identical) + downloadData.filename = expectedFilename + downloadData.mime = expectedMime + downloadData.totalBytes = unfinishedDownloadSize + + downloadCreated.update(downloadData) + downloadCreated.update(downloadData) + + downloadData.fileSize = expectedFilesize + downloadData.endTime = expectedEndTime + downloadData.totalBytes = finishedDownloadSize + downloadData.state = Download.STATE_COMPLETE + downloadCreated.update(downloadData) + + sessionRule.waitForResult(thirdUpdateReceived) + + // The second update should not be there because the data was identical + assertEquals(2, updates.size) + + val firstUpdateCurrent = updates[0].getJSONObject("current") + val firstUpdatePrevious = updates[0].getJSONObject("previous") + assertEquals(3, firstUpdateCurrent.length()) + assertEquals(3, firstUpdatePrevious.length()) + assertEquals(expectedMime, firstUpdateCurrent.getString("mime")) + assertEquals("", firstUpdatePrevious.getString("mime")) + assertEquals(expectedFilename, firstUpdateCurrent.getString("filename")) + assertEquals("", firstUpdatePrevious.getString("filename")) + assertEquals(unfinishedDownloadSize, firstUpdateCurrent.getLong("totalBytes")) + assertEquals(-1, firstUpdatePrevious.getLong("totalBytes")) + + val secondUpdateCurrent = updates[1].getJSONObject("current") + val secondUpdatePrevious = updates[1].getJSONObject("previous") + assertEquals(4, secondUpdateCurrent.length()) + assertEquals(4, secondUpdatePrevious.length()) + assertEquals(finishedDownloadSize, secondUpdateCurrent.getLong("totalBytes")) + assertEquals(firstUpdateCurrent.getLong("totalBytes"), secondUpdatePrevious.getLong("totalBytes")) + assertEquals("complete", secondUpdateCurrent.get("state").toString()) + assertEquals("in_progress", secondUpdatePrevious.get("state").toString()) + assertEquals(expectedEndTime.toString(), secondUpdateCurrent.getString("endTime")) + assertEquals("null", secondUpdatePrevious.getString("endTime")) + assertEquals(expectedFilesize, secondUpdateCurrent.getLong("fileSize")) + assertEquals(-1, secondUpdatePrevious.getLong("fileSize")) + + sessionRule.waitForResult(controller.uninstall(webExtension)) + } + + @Test + fun testOnChangedWrongId() { + val uri = createTestUrl("/assets/www/images/test.gif") + val downloadId = 5 + + val webExtension = sessionRule.waitForResult( + controller.installBuiltIn("resource://android/assets/web_extensions/download-onChanged/"), + ) + + val assertOnDownloadCalled = GeckoResult() + val downloadDelegate = object : DownloadDelegate { + override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult? { + assertEquals(webExtension!!.id, source.id) + assertEquals(uri, request.request.uri) + + val download = controller.createDownload(downloadId) + assertOnDownloadCalled.complete(download) + return GeckoResult.fromValue(DownloadInitData(download, object : Download.Info {})) + } + } + + val onMessageCalled = GeckoResult() + val messageDelegate = object : MessageDelegate { + override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult? { + onMessageCalled.complete(message as String) + return GeckoResult.fromValue(message) + } + } + + webExtension.setDownloadDelegate(downloadDelegate) + webExtension.setMessageDelegate(messageDelegate, "browser") + + mainSession.reload() + sessionRule.waitForPageStop() + + val updateData = object : WebExtension.Download.Info { + override fun state(): Int { + return WebExtension.Download.STATE_COMPLETE + } + } + + val randomDownload = controller.createDownload(25) + + val r = randomDownload!!.update(updateData) + + try { + sessionRule.waitForResult(r!!) + } catch (ex: Exception) { + val a = ex.message!! + assertEquals("Error: Trying to update unknown download", a) + sessionRule.waitForResult(controller.uninstall(webExtension)) + return + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt new file mode 100644 index 0000000000..469fd049ce --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt @@ -0,0 +1,386 @@ +package org.mozilla.geckoview.test + +import android.os.Parcel +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.WebNotification +import org.mozilla.geckoview.WebNotificationDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule + +const val VERY_LONG_IMAGE_URL = "https://example.com/this/is/a/very/long/address/that/is/meant/to/be/longer/than/is/one/hundred/and/fifth/characters/long/for/testing/imageurl/length.ico" + +@RunWith(AndroidJUnit4::class) +@MediumTest +class WebNotificationTest : BaseSessionTest() { + + @Before fun setup() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + + // Grant "desktop notification" permission + mainSession.delegateUntilTestEnd(object : PermissionDelegate { + override fun onContentPermissionRequest(session: GeckoSession, perm: PermissionDelegate.ContentPermission): + GeckoResult? { + assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION)) + return GeckoResult.fromValue(PermissionDelegate.ContentPermission.VALUE_ALLOW) + } + }) + + val result = mainSession.waitForJS("Notification.requestPermission()") + assertThat( + "Permission should be granted", + result as String, + equalTo("granted"), + ) + } + + @Test fun onSilentNotification() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.silent.enabled" to true)) + val notificationResult = GeckoResult() + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + assertThat("Title should match", notification.title, equalTo("The Title")) + assertThat("Silent should match", notification.silent, equalTo(true)) + assertThat("Vibrate should match", notification.vibrate, equalTo(intArrayOf())) + assertThat("Source should match", notification.source, equalTo(createTestUrl(HELLO_HTML_PATH))) + notificationResult.complete(null) + } + }) + + mainSession.evaluateJS( + """ + new Notification('The Title', { body: 'The Text', silent: true }); + """.trimIndent(), + ) + + sessionRule.waitForResult(notificationResult) + } + + fun assertNotificationData(notification: WebNotification, requireInteraction: Boolean) { + assertThat("Title should match", notification.title, equalTo("The Title")) + assertThat("Body should match", notification.text, equalTo("The Text")) + assertThat("Tag should match", notification.tag, endsWith("Tag")) + assertThat("ImageUrl should match", notification.imageUrl, endsWith("icon.png")) + assertThat("Language should match", notification.lang, equalTo("en-US")) + assertThat("Direction should match", notification.textDirection, equalTo("ltr")) + assertThat( + "Require Interaction should match", + notification.requireInteraction, + equalTo(requireInteraction), + ) + assertThat("Vibrate should match", notification.vibrate, equalTo(intArrayOf(1, 2, 3, 4))) + assertThat("Silent should match", notification.silent, equalTo(false)) + assertThat("Source should match", notification.source, equalTo(createTestUrl(HELLO_HTML_PATH))) + } + + @GeckoSessionTestRule.Setting.List( + GeckoSessionTestRule.Setting( + key = GeckoSessionTestRule.Setting.Key.USE_PRIVATE_MODE, + value = "true", + ), + ) + @Ignore // Bug 1843046 - Disabled because private notifications are temporarily disabled. + @Test + fun onShowNotification() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true)) + val notificationResult = GeckoResult() + val requireInteraction = + sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + assertNotificationData(notification, requireInteraction) + assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(true)) + notificationResult.complete(null) + } + }) + + mainSession.evaluateJS( + """ + new Notification('The Title', { body: 'The Text', cookie: 'Cookie', + icon: 'icon.png', tag: 'Tag', dir: 'ltr', lang: 'en-US', + requireInteraction: true, vibrate: [1,2,3,4] }); + """.trimIndent(), + ) + + sessionRule.waitForResult(notificationResult) + } + + @Test fun onCloseNotification() { + val closeCalled = GeckoResult() + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onCloseNotification(notification: WebNotification) { + closeCalled.complete(null) + } + }) + + mainSession.evaluateJS( + """ + const notification = new Notification('The Title', { body: 'The Text'}); + notification.close(); + """.trimIndent(), + ) + + sessionRule.waitForResult(closeCalled) + } + + @Test fun clickNotificationParceled() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true)) + val notificationResult = GeckoResult() + val requireInteraction = + sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationResult.complete(notification) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', { + body: 'The Text', + cookie: 'Cookie', + icon: 'icon.png', + tag: 'Tag', + dir: 'ltr', + lang: 'en-US', + requireInteraction: true, + vibrate: [1,2,3,4] + }); + notification.onclick = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + val notification = sessionRule.waitForResult(notificationResult) + assertNotificationData(notification, requireInteraction) + assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(false)) + + // Test that we can click from a deserialized notification + val parcel = Parcel.obtain() + notification.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val deserialized = WebNotification.CREATOR.createFromParcel(parcel) + assertNotificationData(deserialized, requireInteraction) + assertThat("privateBrowsing should match", deserialized.privateBrowsing, equalTo(false)) + + deserialized!!.click() + assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0)) + } + + @GeckoSessionTestRule.Setting.List( + GeckoSessionTestRule.Setting( + key = GeckoSessionTestRule.Setting.Key.USE_PRIVATE_MODE, + value = "true", + ), + ) + @Ignore // Bug 1843046 - Disabled because private notifications are temporarily disabled. + @Test + fun clickPrivateNotificationParceled() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.vibrate.enabled" to true)) + val notificationResult = GeckoResult() + val requireInteraction = + sessionRule.getPrefs("dom.webnotifications.requireinteraction.enabled")[0] as Boolean + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationResult.complete(notification) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', { + body: 'The Text', + cookie: 'Cookie', + icon: 'icon.png', + tag: 'Tag', + dir: 'ltr', + lang: 'en-US', + requireInteraction: true, + vibrate: [1,2,3,4] + }); + notification.onclick = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + val notification = sessionRule.waitForResult(notificationResult) + assertNotificationData(notification, requireInteraction) + assertThat("privateBrowsing should match", notification.privateBrowsing, equalTo(true)) + + // Test that we can click from a deserialized notification + val parcel = Parcel.obtain() + notification.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val deserialized = WebNotification.CREATOR.createFromParcel(parcel) + assertNotificationData(deserialized, requireInteraction) + assertThat("privateBrowsing should match", deserialized.privateBrowsing, equalTo(true)) + + deserialized!!.click() + assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0)) + } + + @Test fun clickNotification() { + val notificationResult = GeckoResult() + var notificationShown: WebNotification? = null + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationShown = notification + notificationResult.complete(null) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', { body: 'The Text' }); + notification.onclick = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + sessionRule.waitForResult(notificationResult) + notificationShown!!.click() + + assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0)) + } + + @Test fun dismissNotification() { + val notificationResult = GeckoResult() + var notificationShown: WebNotification? = null + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationShown = notification + notificationResult.complete(null) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', { body: 'The Text'}); + notification.onclose = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + sessionRule.waitForResult(notificationResult) + notificationShown!!.dismiss() + + assertThat("Promise should have been resolved", promiseResult.value as Double, equalTo(1.0)) + } + + @Test fun writeToParcel() { + val notificationResult = GeckoResult() + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationResult.complete(notification) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', { body: 'The Text' }); + notification.onclose = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + val notification = sessionRule.waitForResult(notificationResult) + notification.dismiss() + + // Ensure we always have a non-null URL from js. + assertNotNull(notification.imageUrl) + + // Test that we can serialize a notification + val parcel = Parcel.obtain() + notification.writeToParcel(parcel, /* ignored */ -1) + + assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0)) + } + + @Test fun writeToParcelLongImageUrl() { + val notificationResult = GeckoResult() + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + notificationResult.complete(notification) + } + }) + + val promiseResult = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + const notification = new Notification('The Title', + { + body: 'The Text', + icon: '$VERY_LONG_IMAGE_URL' + }); + notification.onclose = function() { + resolve(1); + } + }); + """.trimIndent(), + ) + + val notification = sessionRule.waitForResult(notificationResult) + notification.dismiss() + + // Ensure we have an imageUrl longer than our max to start with. + assertNotNull(notification.imageUrl) + assertTrue(notification.imageUrl!!.length > 150) + + // Test that we can serialize a notification with an imageUrl.length >= 150 + val parcel = Parcel.obtain() + notification.writeToParcel(parcel, /* ignored */ -1) + parcel.setDataPosition(0) + + val serializedNotification = WebNotification.CREATOR.createFromParcel(parcel) + assertTrue(serializedNotification.imageUrl!!.isBlank()) + + assertThat("Promise should have been resolved.", promiseResult.value as Double, equalTo(1.0)) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt new file mode 100644 index 0000000000..a2e6d58f3a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt @@ -0,0 +1,257 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test + +import android.os.Parcel +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +import org.json.JSONObject +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports +import org.mozilla.geckoview.GeckoSession.PermissionDelegate +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.RejectedPromiseException +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.interfaces.ECPublicKey +import java.security.spec.ECGenParameterSpec + +@RunWith(AndroidJUnit4::class) +@MediumTest +class WebPushTest : BaseSessionTest() { + companion object { + val PUSH_ENDPOINT: String = "https://test.endpoint" + val APP_SERVER_KEY_PAIR: KeyPair = generateKeyPair() + val AUTH_SECRET: ByteArray = generateAuthSecret() + val BROWSER_KEY_PAIR: KeyPair = generateKeyPair() + + private fun generateKeyPair(): KeyPair { + try { + val spec = ECGenParameterSpec("secp256r1") + val generator = KeyPairGenerator.getInstance("EC") + generator.initialize(spec) + return generator.generateKeyPair() + } catch (e: Exception) { + throw RuntimeException(e) + } + } + + private fun generateAuthSecret(): ByteArray { + val bytes = ByteArray(16) + SecureRandom().nextBytes(bytes) + + return bytes + } + } + + var delegate: TestPushDelegate? = null + + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd(mapOf("dom.webnotifications.requireuserinteraction" to false)) + // Grant "desktop notification" permission + mainSession.delegateUntilTestEnd(object : PermissionDelegate { + override fun onContentPermissionRequest(session: GeckoSession, perm: GeckoSession.PermissionDelegate.ContentPermission): + GeckoResult? { + assertThat("Should grant DESKTOP_NOTIFICATIONS permission", perm.permission, equalTo(GeckoSession.PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION)) + return GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW) + } + }) + + delegate = TestPushDelegate() + + sessionRule.delegateUntilTestEnd(delegate!!) + + mainSession.loadTestPath(PUSH_HTML_PATH) + mainSession.waitForPageStop() + } + + @After + fun tearDown() { + sessionRule.runtime.webPushController.setDelegate(null) + delegate = null + } + + private fun verifySubscription(subscription: JSONObject) { + assertThat("Push endpoint should match", subscription.getString("endpoint"), equalTo(PUSH_ENDPOINT)) + + val keys = subscription.getJSONObject("keys") + val authSecret = Base64.decode(keys.getString("auth"), Base64.URL_SAFE) + val encryptionKey = WebPushUtils.keyFromString(keys.getString("p256dh")) + + assertThat("Auth secret should match", authSecret, equalTo(AUTH_SECRET)) + assertThat("Encryption key should match", encryptionKey, equalTo(BROWSER_KEY_PAIR.public)) + } + + @Test + fun subscribe() { + // PushManager.subscribe() + val appServerKey = WebPushUtils.keyToString(APP_SERVER_KEY_PAIR.public as ECPublicKey) + var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe(\"$appServerKey\")").value as JSONObject + assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue()) + verifySubscription(pushSubscription) + + // PushManager.getSubscription() + pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject + verifySubscription(pushSubscription) + } + + @Test + fun subscribeNoAppServerKey() { + // PushManager.subscribe() + var pushSubscription = mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject + assertThat("Should have a stored subscription", delegate!!.storedSubscription, notNullValue()) + verifySubscription(pushSubscription) + + // PushManager.getSubscription() + pushSubscription = mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject + verifySubscription(pushSubscription) + } + + @Test(expected = RejectedPromiseException::class) + fun subscribeNullDelegate() { + sessionRule.runtime.webPushController.setDelegate(null) + mainSession.evaluatePromiseJS("window.doSubscribe()").value as JSONObject + } + + @Test(expected = RejectedPromiseException::class) + fun getSubscriptionNullDelegate() { + sessionRule.runtime.webPushController.setDelegate(null) + mainSession.evaluatePromiseJS("window.doGetSubscription()").value as JSONObject + } + + @Test + fun unsubscribe() { + subscribe() + + // PushManager.unsubscribe() + val unsubResult = mainSession.evaluatePromiseJS("window.doUnsubscribe()").value as JSONObject + assertThat("Unsubscribe result should be non-null", unsubResult, notNullValue()) + assertThat("Should not have a stored subscription", delegate!!.storedSubscription, nullValue()) + } + + @Test + fun pushEvent() { + subscribe() + + val p = mainSession.evaluatePromiseJS("window.doWaitForPushEvent()") + + val testPayload = "The Payload" + sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, testPayload.toByteArray(Charsets.UTF_8)) + + assertThat("Push data should match", p.value as String, equalTo(testPayload)) + } + + @Test + fun pushEventWithoutData() { + subscribe() + + val p = mainSession.evaluatePromiseJS("window.doWaitForPushEvent()") + + sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, null) + + assertThat("Push data should be empty", p.value as String, equalTo("")) + } + + private fun sendNotification() { + val notificationResult = GeckoResult() + val expectedTitle = "The title" + val expectedBody = "The body" + + sessionRule.delegateDuringNextWait(object : WebNotificationDelegate { + @GeckoSessionTestRule.AssertCalled + override fun onShowNotification(notification: WebNotification) { + assertThat("Title should match", notification.title, equalTo(expectedTitle)) + assertThat("Body should match", notification.text, equalTo(expectedBody)) + assertThat("Source should match", notification.source, endsWith("sw.js")) + notificationResult.complete(null) + } + }) + + val testPayload = JSONObject() + testPayload.put("title", expectedTitle) + testPayload.put("body", expectedBody) + + sessionRule.runtime.webPushController.onPushEvent(delegate!!.storedSubscription!!.scope, testPayload.toString().toByteArray(Charsets.UTF_8)) + sessionRule.waitForResult(notificationResult) + } + + @Test + fun pushEventWithNotification() { + subscribe() + sendNotification() + } + + @Test + fun subscriptionChanged() { + subscribe() + + val p = mainSession.evaluatePromiseJS("window.doWaitForSubscriptionChange()") + + sessionRule.runtime.webPushController.onSubscriptionChanged(delegate!!.storedSubscription!!.scope) + + assertThat("Result should not be null", p.value, notNullValue()) + } + + @Test(expected = IllegalArgumentException::class) + fun invalidDuplicateKeys() { + WebPushSubscription( + "https://scope", + PUSH_ENDPOINT, + WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey), + WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey)!!, + AUTH_SECRET, + ) + } + + @Test + fun parceling() { + val testScope = "https://test.scope" + val sub = WebPushSubscription( + testScope, + PUSH_ENDPOINT, + WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey), + WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, + AUTH_SECRET, + ) + + val parcel = Parcel.obtain() + sub.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + + val sub2 = WebPushSubscription.CREATOR.createFromParcel(parcel) + assertThat("Scope should match", sub.scope, equalTo(sub2.scope)) + assertThat("Endpoint should match", sub.endpoint, equalTo(sub2.endpoint)) + assertThat("App server key should match", sub.appServerKey, equalTo(sub2.appServerKey)) + assertThat("Encryption key should match", sub.browserPublicKey, equalTo(sub2.browserPublicKey)) + assertThat("Auth secret should match", sub.authSecret, equalTo(sub2.authSecret)) + } + + class TestPushDelegate : WebPushDelegate { + var storedSubscription: WebPushSubscription? = null + + override fun onGetSubscription(scope: String): GeckoResult? { + return GeckoResult.fromValue(storedSubscription) + } + + override fun onUnsubscribe(scope: String): GeckoResult? { + storedSubscription = null + return GeckoResult.fromValue(null) + } + + override fun onSubscribe(scope: String, appServerKey: ByteArray?): GeckoResult? { + appServerKey?.let { assertThat("Application server key should match", it, equalTo(WebPushUtils.keyToBytes(APP_SERVER_KEY_PAIR.public as ECPublicKey))) } + storedSubscription = WebPushSubscription(scope, PUSH_ENDPOINT, appServerKey, WebPushUtils.keyToBytes(BROWSER_KEY_PAIR.public as ECPublicKey)!!, AUTH_SECRET) + return GeckoResult.fromValue(storedSubscription) + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java new file mode 100644 index 0000000000..340025502e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test; + +import android.util.Base64; +import androidx.annotation.AnyThread; +import androidx.annotation.Nullable; +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyFactory; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.ECPublicKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; +import java.security.spec.ECPoint; +import java.security.spec.ECPublicKeySpec; +import java.security.spec.InvalidKeySpecException; + +/** + * Utilities for converting {@link ECPublicKey} to/from X9.62 encoding. + * + * @see Message Encryption for Web Push + */ +/* package */ class WebPushUtils { + public static final int P256_PUBLIC_KEY_LENGTH = 65; // 1 + 32 + 32 + private static final byte NIST_HEADER = 0x04; // uncompressed format + + private static ECParameterSpec sSpec; + + private WebPushUtils() {} + + /** + * Encodes an {@link ECPublicKey} into X9.62 format as required by Web Push. + * + * @param key the {@link ECPublicKey} to encode + * @return the encoded {@link ECPublicKey} + */ + @AnyThread + public static @Nullable byte[] keyToBytes(final @Nullable ECPublicKey key) { + if (key == null) { + return null; + } + + final ByteBuffer buffer = ByteBuffer.allocate(P256_PUBLIC_KEY_LENGTH); + buffer.put(NIST_HEADER); + + putUnsignedBigInteger(buffer, key.getW().getAffineX()); + putUnsignedBigInteger(buffer, key.getW().getAffineY()); + + if (buffer.position() != P256_PUBLIC_KEY_LENGTH) { + throw new RuntimeException("Unexpected key length " + buffer.position()); + } + + return buffer.array(); + } + + private static void putUnsignedBigInteger(final ByteBuffer buffer, final BigInteger value) { + final byte[] bytes = value.toByteArray(); + if (bytes.length < 32) { + buffer.put(new byte[32 - bytes.length]); + buffer.put(bytes); + } else { + buffer.put(bytes, bytes.length - 32, 32); + } + } + + /** + * Encodes an {@link ECPublicKey} into X9.62 format as required by Web Push, further encoded into + * Base64. + * + * @param key the {@link ECPublicKey} to encode + * @return the encoded {@link ECPublicKey} + */ + @AnyThread + public static @Nullable String keyToString(final @Nullable ECPublicKey key) { + return Base64.encodeToString( + keyToBytes(key), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); + } + + /** + * @return A {@link ECParameterSpec} for P-256 (secp256r1). + */ + public static ECParameterSpec getP256Spec() { + if (sSpec == null) { + try { + final KeyPairGenerator gen = KeyPairGenerator.getInstance("EC"); + final ECGenParameterSpec genSpec = new ECGenParameterSpec("secp256r1"); + gen.initialize(genSpec); + sSpec = ((ECPublicKey) gen.generateKeyPair().getPublic()).getParams(); + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (final InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + } + + return sSpec; + } + + /** + * Converts a Base64 X9.62 encoded Web Push key into a {@link ECPublicKey}. + * + * @param base64Bytes the X9.62 data as Base64 + * @return a {@link ECPublicKey} + */ + @AnyThread + public static @Nullable ECPublicKey keyFromString(final @Nullable String base64Bytes) { + if (base64Bytes == null) { + return null; + } + + return keyFromBytes(Base64.decode(base64Bytes, Base64.URL_SAFE)); + } + + private static BigInteger readUnsignedBigInteger( + final byte[] bytes, final int offset, final int length) { + byte[] mag = bytes; + if (offset != 0 || length != bytes.length) { + mag = new byte[length]; + System.arraycopy(bytes, offset, mag, 0, length); + } + return new BigInteger(1, mag); + } + + /** + * Converts a X9.62 encoded Web Push key into a {@link ECPublicKey}. + * + * @param bytes the X9.62 data + * @return a {@link ECPublicKey} + */ + @AnyThread + public static @Nullable ECPublicKey keyFromBytes(final @Nullable byte[] bytes) { + if (bytes == null) { + return null; + } + + if (bytes.length != P256_PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + String.format("Expected exactly %d bytes", P256_PUBLIC_KEY_LENGTH)); + } + + if (bytes[0] != NIST_HEADER) { + throw new IllegalArgumentException("Expected uncompressed NIST format"); + } + + try { + final BigInteger x = readUnsignedBigInteger(bytes, 1, 32); + final BigInteger y = readUnsignedBigInteger(bytes, 33, 32); + + final ECPoint point = new ECPoint(x, y); + final ECPublicKeySpec spec = new ECPublicKeySpec(point, getP256Spec()); + final KeyFactory factory = KeyFactory.getInstance("EC"); + final ECPublicKey key = (ECPublicKey) factory.generatePublic(spec); + + return key; + } catch (final NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } catch (final InvalidKeySpecException e) { + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt new file mode 100644 index 0000000000..e19997dfc3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt @@ -0,0 +1,48 @@ +package org.mozilla.geckoview.test.crash + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import org.hamcrest.Matchers.equalTo +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.test.BaseSessionTest +import org.mozilla.geckoview.test.TestCrashHandler +import org.mozilla.geckoview.test.TestRuntimeService +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.ClosedSessionAtStart + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ParentCrashTest : BaseSessionTest() { + private val targetContext + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private val timeout + get() = sessionRule.env.defaultTimeoutMillis + + @Test + @ClosedSessionAtStart + fun crashParent() { + // TODO: Bug 1673956 + assumeThat(sessionRule.env.isFission, equalTo(false)) + val client = TestCrashHandler.Client(targetContext) + + assertTrue(client.connect(timeout)) + client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN) + + val runtime = TestRuntimeService.RuntimeInstance.start( + targetContext, + RuntimeCrashTestService::class.java, + temporaryProfile.get(), + ) + runtime.loadUri("about:crashparent") + + val evalResult = client.getEvalResult(timeout) + assertTrue(evalResult.mMsg, evalResult.mResult) + + client.disconnect() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt new file mode 100644 index 0000000000..bfdc40621e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt @@ -0,0 +1,19 @@ +package org.mozilla.geckoview.test.crash + +import android.content.Context +import android.content.Intent +import org.mozilla.geckoview.GeckoRuntime +import org.mozilla.geckoview.GeckoRuntimeSettings +import org.mozilla.geckoview.test.TestCrashHandler +import org.mozilla.geckoview.test.TestRuntimeService + +class RuntimeCrashTestService : TestRuntimeService() { + override fun createRuntime(context: Context, intent: Intent): GeckoRuntime { + return GeckoRuntime.create( + this.applicationContext, + GeckoRuntimeSettings.Builder() + .extras(intent.extras!!) + .crashHandler(TestCrashHandler::class.java).build(), + ) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java new file mode 100644 index 0000000000..81133bb063 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java @@ -0,0 +1,2915 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.rule; + +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +import android.app.Instrumentation; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.SurfaceTexture; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationManager; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.Surface; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.platform.app.InstrumentationRegistry; +import java.io.File; +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import kotlin.jvm.JvmClassMappingKt; +import kotlin.reflect.KClass; +import org.hamcrest.Matcher; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONTokener; +import org.junit.rules.ErrorCollector; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; +import org.mozilla.gecko.MultiMap; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.Autocomplete; +import org.mozilla.geckoview.Autofill; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.GeckoDisplay; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntime.ActivityDelegate; +import org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSession.ContentDelegate; +import org.mozilla.geckoview.GeckoSession.HistoryDelegate; +import org.mozilla.geckoview.GeckoSession.MediaDelegate; +import org.mozilla.geckoview.GeckoSession.NavigationDelegate; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate; +import org.mozilla.geckoview.GeckoSession.PrintDelegate; +import org.mozilla.geckoview.GeckoSession.ProgressDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate; +import org.mozilla.geckoview.GeckoSession.ScrollDelegate; +import org.mozilla.geckoview.GeckoSession.SelectionActionDelegate; +import org.mozilla.geckoview.GeckoSession.TextInputDelegate; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.MediaSession; +import org.mozilla.geckoview.OrientationController; +import org.mozilla.geckoview.RuntimeTelemetry; +import org.mozilla.geckoview.SessionTextInput; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.WebExtensionController; +import org.mozilla.geckoview.WebNotificationDelegate; +import org.mozilla.geckoview.WebPushDelegate; +import org.mozilla.geckoview.test.GeckoViewTestActivity; +import org.mozilla.geckoview.test.util.Environment; +import org.mozilla.geckoview.test.util.RuntimeCreator; +import org.mozilla.geckoview.test.util.TestServer; +import org.mozilla.geckoview.test.util.UiThreadUtils; + +/** + * TestRule that, for each test, sets up a GeckoSession, runs the test on the UI thread, and tears + * down the GeckoSession at the end of the test. The rule also provides methods for waiting on + * particular callbacks to be called, and methods for asserting that callbacks are called in the + * proper order. + */ +public class GeckoSessionTestRule implements TestRule { + private static final String LOGTAG = "GeckoSessionTestRule"; + + public static final int TEST_PORT = 4245; + public static final String TEST_HOST = "localhost"; + public static final String TEST_ENDPOINT = "http://" + TEST_HOST + ":" + TEST_PORT; + + private static final Method sOnPageStart; + private static final Method sOnPageStop; + private static final Method sOnNewSession; + private static final Method sOnCrash; + private static final Method sOnKill; + + static { + try { + sOnPageStart = + GeckoSession.ProgressDelegate.class.getMethod( + "onPageStart", GeckoSession.class, String.class); + sOnPageStop = + GeckoSession.ProgressDelegate.class.getMethod( + "onPageStop", GeckoSession.class, boolean.class); + sOnNewSession = + GeckoSession.NavigationDelegate.class.getMethod( + "onNewSession", GeckoSession.class, String.class); + sOnCrash = GeckoSession.ContentDelegate.class.getMethod("onCrash", GeckoSession.class); + sOnKill = GeckoSession.ContentDelegate.class.getMethod("onKill", GeckoSession.class); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + public void addDisplay(final GeckoSession session, final int x, final int y) { + final GeckoDisplay display = session.acquireDisplay(); + + final SurfaceTexture displayTexture = new SurfaceTexture(0); + displayTexture.setDefaultBufferSize(x, y); + + final Surface displaySurface = new Surface(displayTexture); + display.surfaceChanged(new GeckoDisplay.SurfaceInfo.Builder(displaySurface).size(x, y).build()); + + mDisplays.put(session, display); + mDisplayTextures.put(session, displayTexture); + mDisplaySurfaces.put(session, displaySurface); + } + + public void releaseDisplay(final GeckoSession session) { + if (!mDisplays.containsKey(session)) { + // No display to release + return; + } + final GeckoDisplay display = mDisplays.remove(session); + display.surfaceDestroyed(); + session.releaseDisplay(display); + final Surface displaySurface = mDisplaySurfaces.remove(session); + displaySurface.release(); + final SurfaceTexture displayTexture = mDisplayTextures.remove(session); + displayTexture.release(); + } + + /** + * Specify the timeout for any of the wait methods, in milliseconds, relative to {@link + * Environment#DEFAULT_TIMEOUT_MILLIS}. When the default timeout scales to account for differences + * in the device under test, the timeout value here will be scaled as well. Can be used on classes + * or methods. + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface TimeoutMillis { + long value(); + } + + /** Specify the display size for the GeckoSession in device pixels */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface WithDisplay { + int width(); + + int height(); + } + + /** Specify that the main session should not be opened at the start of the test. */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface ClosedSessionAtStart { + boolean value() default true; + } + + /** + * Specify that the test will set a delegate to null when creating a session, rather than setting + * the delegate to a proxy. The test cannot wait on any delegates that are set to null. + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface NullDelegate { + Class value(); + + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + NullDelegate[] value(); + } + } + + /** + * Specify a list of GeckoSession settings to be applied to the GeckoSession object under test. + * Can be used on classes or methods. Note that the settings values must be string literals + * regardless of the type of the settings. + * + *

    Enable tracking protection for a particular test: + * + *

    +   * @Setting.List(@Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
    +   *                        value = "false"))
    +   * @Test public void test() { ... }
    +   * 
    + * + *

    Use multiple settings: + * + *

    +   * @Setting.List({@Setting(key = Setting.Key.USE_PRIVATE_MODE,
    +   *                         value = "true"),
    +   *                @Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
    +   *                         value = "false")})
    +   * 
    + */ + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Setting { + enum Key { + CHROME_URI, + DISPLAY_MODE, + ALLOW_JAVASCRIPT, + SCREEN_ID, + USE_PRIVATE_MODE, + USE_TRACKING_PROTECTION, + FULL_ACCESSIBILITY_TREE; + + private final GeckoSessionSettings.Key mKey; + private final Class mType; + + Key() { + final Field field; + try { + field = GeckoSessionSettings.class.getDeclaredField(name()); + field.setAccessible(true); + mKey = (GeckoSessionSettings.Key) field.get(null); + } catch (final NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + final ParameterizedType genericType = (ParameterizedType) field.getGenericType(); + mType = (Class) genericType.getActualTypeArguments()[0]; + } + + @SuppressWarnings("unchecked") + public void set(final GeckoSessionSettings settings, final String value) { + try { + if (boolean.class.equals(mType) || Boolean.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setBoolean", GeckoSessionSettings.Key.class, boolean.class); + method.setAccessible(true); + method.invoke(settings, mKey, Boolean.valueOf(value)); + } else if (int.class.equals(mType) || Integer.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setInt", GeckoSessionSettings.Key.class, int.class); + method.setAccessible(true); + try { + method.invoke( + settings, mKey, (Integer) GeckoSessionSettings.class.getField(value).get(null)); + } catch (final NoSuchFieldException | IllegalAccessException | ClassCastException e) { + method.invoke(settings, mKey, Integer.valueOf(value)); + } + } else if (String.class.equals(mType)) { + final Method method = + GeckoSessionSettings.class.getDeclaredMethod( + "setString", GeckoSessionSettings.Key.class, String.class); + method.setAccessible(true); + method.invoke(settings, mKey, value); + } else { + throw new IllegalArgumentException("Unsupported type: " + mType.getSimpleName()); + } + } catch (final NoSuchMethodException + | IllegalAccessException + | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + @Target({ElementType.METHOD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + Setting[] value(); + } + + Key key(); + + String value(); + } + + /** + * Assert that a method is called or not called, and if called, the order and number of times it + * is called. The order number is a monotonically increasing integer; if an called method's order + * number is less than the current order number, an exception is raised for out-of-order call. + * + *

    {@code @AssertCalled} asserts the method must be called at least once. + * + *

    {@code @AssertCalled(false)} asserts the method must not be called. + * + *

    {@code @AssertCalled(order = 2)} asserts the method must be called once and after any other + * method with order number less than 2. + * + *

    {@code @AssertCalled(order = {2, 4})} asserts order number 2 for first call and order number + * 4 for any subsequent calls. + * + *

    {@code @AssertCalled(count = 2)} asserts two calls total in any order with respect to other + * calls. + * + *

    {@code @AssertCalled(count = 2, order = 2)} asserts two calls, both with order number 2. + * + *

    {@code @AssertCalled(count = 2, order = {2, 4, 6})} asserts two calls total: the first with + * order number 2 and the second with order number 4. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface AssertCalled { + /** + * @return True if the method must be called if count != 0, or false if the method must not be + * called. + */ + boolean value() default true; + + /** + * @return The number of calls allowed. Specify -1 to allow any number > 0. Specify 0 to assert + * the method is not called, even if value() is true. + */ + int count() default -1; + + /** + * @return If called, the order number for each call, or 0 to allow arbitrary order. If order's + * length is more than count, extra elements are not used; if order's length is less than + * count, the last element is repeated. + */ + int[] order() default 0; + } + + /** Interface that represents a function that registers or unregisters a delegate. */ + public interface DelegateRegistrar { + void invoke(T delegate) throws Throwable; + } + + /* + * If the value here is true, content crashes will be ignored. If false, the test will + * be failed immediately if a content crash occurs. This is also the case when + * {@link IgnoreCrash} is not present. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + public @interface IgnoreCrash { + /** + * @return True if content crashes should be ignored, false otherwise. Default is true. + */ + boolean value() default true; + } + + public static class ChildCrashedException extends RuntimeException { + public ChildCrashedException(final String detailMessage) { + super(detailMessage); + } + } + + public static class RejectedPromiseException extends RuntimeException { + private final Object mReason; + + /* package */ RejectedPromiseException(final Object reason) { + super(String.valueOf(reason)); + mReason = reason; + } + + public Object getReason() { + return mReason; + } + } + + public static class CallRequirement { + public final boolean allowed; + public final int count; + public final int[] order; + + public CallRequirement(final boolean allowed, final int count, final int[] order) { + this.allowed = allowed; + this.count = count; + this.order = order; + } + } + + public static class CallInfo { + public final int counter; + public final int order; + + /* package */ CallInfo(final int counter, final int order) { + this.counter = counter; + this.order = order; + } + } + + public static class MethodCall { + public final GeckoSession session; + public final Method method; + public final CallRequirement requirement; + public final Object target; + private int currentCount; + + public MethodCall( + final GeckoSession session, final Method method, final CallRequirement requirement) { + this(session, method, requirement, /* target */ null); + } + + /* package */ MethodCall( + final GeckoSession session, + final Method method, + final AssertCalled annotation, + final Object target) { + this( + session, + method, + (annotation != null) + ? new CallRequirement(annotation.value(), annotation.count(), annotation.order()) + : null, + /* target */ target); + } + + /* package */ MethodCall( + final GeckoSession session, + final Method method, + final CallRequirement requirement, + final Object target) { + this.session = session; + this.method = method; + this.requirement = requirement; + this.target = target; + currentCount = 0; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } else if (other instanceof MethodCall) { + final MethodCall otherCall = (MethodCall) other; + return (session == null || otherCall.session == null || session.equals(otherCall.session)) + && methodsEqual(method, ((MethodCall) other).method); + } else if (other instanceof Method) { + return methodsEqual(method, (Method) other); + } + return false; + } + + @Override + public int hashCode() { + return method.hashCode(); + } + + /* package */ int getOrder() { + if (requirement == null || currentCount == 0) { + return 0; + } + + final int[] order = requirement.order; + if (order == null || order.length == 0) { + return 0; + } + return order[Math.min(currentCount - 1, order.length - 1)]; + } + + /* package */ int getCount() { + return (requirement == null) ? -1 : requirement.allowed ? requirement.count : 0; + } + + /* package */ void incrementCounter() { + currentCount++; + } + + /* package */ int getCurrentCount() { + return currentCount; + } + + /* package */ boolean allowUnlimitedCalls() { + return getCount() == -1; + } + + /* package */ boolean allowMoreCalls() { + final int count = getCount(); + return count == -1 || count > currentCount; + } + + /* package */ CallInfo getInfo() { + return new CallInfo(currentCount, getOrder()); + } + + // Similar to Method.equals, but treat the same method from an interface and an + // overriding class as the same (e.g. CharSequence.length == String.length). + private static boolean methodsEqual(final @NonNull Method m1, final @NonNull Method m2) { + return (m1.getDeclaringClass().isAssignableFrom(m2.getDeclaringClass()) + || m2.getDeclaringClass().isAssignableFrom(m1.getDeclaringClass())) + && m1.getName().equals(m2.getName()) + && m1.getReturnType().equals(m2.getReturnType()) + && Arrays.equals(m1.getParameterTypes(), m2.getParameterTypes()); + } + } + + protected static class CallRecord { + public final Method method; + public final MethodCall methodCall; + public final Object[] args; + + public CallRecord(final GeckoSession session, final Method method, final Object[] args) { + this.method = method; + this.methodCall = new MethodCall(session, method, /* requirement */ null); + this.args = args; + } + } + + protected interface CallRecordHandler { + boolean handleCall(Method method, Object[] args); + } + + protected final class ExternalDelegate { + public final Class delegate; + private final DelegateRegistrar mRegister; + private final DelegateRegistrar mUnregister; + private final T mProxy; + private boolean mRegistered; + + public ExternalDelegate( + final Class delegate, + final T impl, + final DelegateRegistrar register, + final DelegateRegistrar unregister) { + this.delegate = delegate; + mRegister = register; + mUnregister = unregister; + + @SuppressWarnings("unchecked") + final T delegateProxy = + (T) + Proxy.newProxyInstance( + getClass().getClassLoader(), + impl.getClass().getInterfaces(), + Proxy.getInvocationHandler(mCallbackProxy)); + mProxy = delegateProxy; + } + + @Override + public int hashCode() { + return delegate.hashCode(); + } + + @Override + public boolean equals(final Object obj) { + return obj instanceof ExternalDelegate + && delegate.equals(((ExternalDelegate) obj).delegate); + } + + public void register() { + try { + if (!mRegistered) { + mRegister.invoke(mProxy); + mRegistered = true; + } + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + } + + public void unregister() { + try { + if (mRegistered) { + mUnregister.invoke(mProxy); + mRegistered = false; + } + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + } + } + + protected class CallbackDelegates { + private final Map, MethodCall> mDelegates = new HashMap<>(); + private final List> mExternalDelegates = new ArrayList<>(); + private int mOrder; + private JSONObject mOldPrefs; + + public void delegate(final @Nullable GeckoSession session, final @NonNull Object callback) { + for (final Class ifce : mAllDelegates) { + if (!ifce.isInstance(callback)) { + continue; + } + assertThat("Cannot delegate null-delegate callbacks", ifce, not(isIn(mNullDelegates))); + addDelegatesForInterface(session, callback, ifce); + } + } + + private void addDelegatesForInterface( + @Nullable final GeckoSession session, + @NonNull final Object callback, + @NonNull final Class ifce) { + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final Pair pair = new Pair<>(session, method); + final MethodCall call = + new MethodCall( + session, callbackMethod, getAssertCalled(callbackMethod, callback), callback); + // It's unclear if we should assert the call count if we replace an existing + // delegate half way through. Until that is resolved, forbid replacing an + // existing delegate during a test. If you are thinking about changing this + // behavior, first see if #delegateDuringNextWait fits your needs. + assertThat("Cannot replace an existing delegate", mDelegates, not(hasKey(pair))); + mDelegates.put(pair, call); + } + } + + public ExternalDelegate addExternalDelegate( + @NonNull final Class delegate, + @NonNull final DelegateRegistrar register, + @NonNull final DelegateRegistrar unregister, + @NonNull final T impl) { + assertThat("Delegate must be an interface", delegate.isInterface(), equalTo(true)); + + // Delegate each interface to the real thing, then register the delegate using our + // proxy. That way all calls to the delegate are recorded just like our internal + // delegates. + addDelegatesForInterface(/* session */ null, impl, delegate); + + final ExternalDelegate externalDelegate = + new ExternalDelegate<>(delegate, impl, register, unregister); + mExternalDelegates.add(externalDelegate); + mAllDelegates.add(delegate); + return externalDelegate; + } + + @NonNull + public List> getExternalDelegates() { + return mExternalDelegates; + } + + /** Generate a JS function to set new prefs and return a set of saved prefs. */ + public void setPrefs(final @NonNull Map prefs) { + mOldPrefs = + (JSONObject) + webExtensionApiCall( + "SetPrefs", + args -> { + final JSONObject existingPrefs = + mOldPrefs != null ? mOldPrefs : new JSONObject(); + + final JSONObject newPrefs = new JSONObject(); + for (final Map.Entry pref : prefs.entrySet()) { + final Object value = pref.getValue(); + if (value instanceof Boolean + || value instanceof Number + || value instanceof CharSequence) { + newPrefs.put(pref.getKey(), value); + } else { + throw new IllegalArgumentException("Unsupported pref value: " + value); + } + } + + args.put("oldPrefs", existingPrefs); + args.put("newPrefs", newPrefs); + }); + } + + /** Generate a JS function to set new prefs and reset a set of saved prefs. */ + private void restorePrefs() { + if (mOldPrefs == null) { + return; + } + + webExtensionApiCall( + "RestorePrefs", + args -> { + args.put("oldPrefs", mOldPrefs); + mOldPrefs = null; + }); + } + + public void clear() { + for (int i = mExternalDelegates.size() - 1; i >= 0; i--) { + mExternalDelegates.get(i).unregister(); + } + mExternalDelegates.clear(); + mDelegates.clear(); + mOrder = 0; + + restorePrefs(); + } + + public void clearAndAssert() { + final Collection values = mDelegates.values(); + final MethodCall[] valuesArray = values.toArray(new MethodCall[values.size()]); + + clear(); + + for (final MethodCall call : valuesArray) { + assertMatchesCount(call); + } + } + + public MethodCall prepareMethodCall(final GeckoSession session, final Method method) { + MethodCall call = mDelegates.get(new Pair<>(session, method)); + if (call == null && session != null) { + call = mDelegates.get(new Pair<>((GeckoSession) null, method)); + } + if (call == null) { + return null; + } + + assertAllowMoreCalls(call); + call.incrementCounter(); + assertOrder(call, mOrder); + mOrder = Math.max(call.getOrder(), mOrder); + return call; + } + } + + /* package */ static AssertCalled getAssertCalled(final Method method, final Object callback) { + final AssertCalled annotation = method.getAnnotation(AssertCalled.class); + if (annotation != null) { + return annotation; + } + + // Some Kotlin lambdas have an invoke method that carries the annotation, + // instead of the interface method carrying the annotation. + try { + return callback + .getClass() + .getDeclaredMethod("invoke", method.getParameterTypes()) + .getAnnotation(AssertCalled.class); + } catch (final NoSuchMethodException e) { + return null; + } + } + + private static final Set> DEFAULT_DELEGATES = new HashSet<>(); + + static { + DEFAULT_DELEGATES.add(Autofill.Delegate.class); + DEFAULT_DELEGATES.add(ContentBlocking.Delegate.class); + DEFAULT_DELEGATES.add(ContentDelegate.class); + DEFAULT_DELEGATES.add(HistoryDelegate.class); + DEFAULT_DELEGATES.add(MediaDelegate.class); + DEFAULT_DELEGATES.add(MediaSession.Delegate.class); + DEFAULT_DELEGATES.add(NavigationDelegate.class); + DEFAULT_DELEGATES.add(PermissionDelegate.class); + DEFAULT_DELEGATES.add(PrintDelegate.class); + DEFAULT_DELEGATES.add(ProgressDelegate.class); + DEFAULT_DELEGATES.add(PromptDelegate.class); + DEFAULT_DELEGATES.add(ScrollDelegate.class); + DEFAULT_DELEGATES.add(SelectionActionDelegate.class); + DEFAULT_DELEGATES.add(TextInputDelegate.class); + } + + private static final Set> DEFAULT_RUNTIME_DELEGATES = new HashSet<>(); + + static { + DEFAULT_RUNTIME_DELEGATES.add(Autocomplete.StorageDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(ActivityDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(GeckoRuntime.Delegate.class); + DEFAULT_RUNTIME_DELEGATES.add(OrientationController.OrientationDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(ServiceWorkerDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebNotificationDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebExtensionController.PromptDelegate.class); + DEFAULT_RUNTIME_DELEGATES.add(WebPushDelegate.class); + } + + private static class DefaultImpl + implements + // Session delegates + Autofill.Delegate, + ContentBlocking.Delegate, + ContentDelegate, + HistoryDelegate, + MediaDelegate, + MediaSession.Delegate, + NavigationDelegate, + PermissionDelegate, + PrintDelegate, + ProgressDelegate, + PromptDelegate, + ScrollDelegate, + SelectionActionDelegate, + TextInputDelegate, + // Runtime delegates + ActivityDelegate, + Autocomplete.StorageDelegate, + GeckoRuntime.Delegate, + OrientationController.OrientationDelegate, + ServiceWorkerDelegate, + WebExtensionController.PromptDelegate, + WebNotificationDelegate, + WebPushDelegate { + @Override + public GeckoResult onStartActivityForResult(@NonNull PendingIntent intent) { + return null; + } + + // The default impl of this will call `onLocationChange(2)` which causes duplicated + // call records, to avoid that we implement it here so that it doesn't do anything. + @Override + public void onLocationChange( + @NonNull GeckoSession session, + @Nullable String url, + @NonNull List perms) {} + + @Override + public void onShutdown() {} + + @Override + public GeckoResult onOpenWindow(@NonNull String url) { + return GeckoResult.fromValue(null); + } + } + + private static final DefaultImpl DEFAULT_IMPL = new DefaultImpl(); + + public final Environment env = new Environment(); + + protected final Instrumentation mInstrumentation = InstrumentationRegistry.getInstrumentation(); + protected final GeckoSessionSettings mDefaultSettings; + protected final Set mSubSessions = new HashSet<>(); + + protected ErrorCollector mErrorCollector; + protected GeckoSession mMainSession; + protected Object mCallbackProxy; + protected Set> mNullDelegates; + protected Set> mAllDelegates; + protected List mCallRecords; + protected CallRecordHandler mCallRecordHandler; + protected CallbackDelegates mWaitScopeDelegates; + protected CallbackDelegates mTestScopeDelegates; + protected int mLastWaitStart; + protected int mLastWaitEnd; + protected MethodCall mCurrentMethodCall; + protected long mTimeoutMillis; + protected Point mDisplaySize; + protected Map mDisplayTextures = new HashMap<>(); + protected Map mDisplaySurfaces = new HashMap<>(); + protected Map mDisplays = new HashMap<>(); + protected boolean mClosedSession; + protected boolean mIgnoreCrash; + + public GeckoSessionTestRule() { + mDefaultSettings = new GeckoSessionSettings.Builder().build(); + } + + /** + * Set an ErrorCollector for assertion errors, or null to not use one. + * + * @param ec ErrorCollector or null. + */ + public void setErrorCollector(final @Nullable ErrorCollector ec) { + mErrorCollector = ec; + } + + /** + * Get the current ErrorCollector, or null if not using one. + * + * @return ErrorCollector or null. + */ + public @Nullable ErrorCollector getErrorCollector() { + return mErrorCollector; + } + + /** + * Get the current timeout value in milliseconds. + * + * @return The current timeout value in milliseconds. + */ + public long getTimeoutMillis() { + return mTimeoutMillis; + } + + /** + * Assert a condition with junit.Assert or an error collector. + * + * @param reason Reason string + * @param value Value to check + * @param matcher Matcher for checking the value + */ + public void checkThat(final String reason, final T value, final Matcher matcher) { + if (mErrorCollector != null) { + mErrorCollector.checkThat(reason, value, matcher); + } else { + assertThat(reason, value, matcher); + } + } + + private void assertAllowMoreCalls(final MethodCall call) { + final int count = call.getCount(); + if (count != -1) { + checkThat( + call.method.getName() + " call count should be within limit", + call.getCurrentCount() + 1, + lessThanOrEqualTo(count)); + } + } + + private void assertOrder(final MethodCall call, final int order) { + final int newOrder = call.getOrder(); + if (newOrder != 0) { + checkThat( + call.method.getName() + " should be in order", newOrder, greaterThanOrEqualTo(order)); + } + } + + private void assertMatchesCount(final MethodCall call) { + if (call.requirement == null) { + return; + } + final int count = call.getCount(); + if (count == 0) { + checkThat( + call.method.getName() + " should not be called", call.getCurrentCount(), equalTo(0)); + } else if (count == -1) { + checkThat( + call.method.getName() + " should be called", call.getCurrentCount(), greaterThan(0)); + } else { + checkThat( + call.method.getName() + " should be called specified number of times", + call.getCurrentCount(), + equalTo(count)); + } + } + + /** + * Get the session set up for the current test. + * + * @return GeckoSession object. + */ + public @NonNull GeckoSession getSession() { + return mMainSession; + } + + /** + * Get the runtime set up for the current test. + * + * @return GeckoRuntime object. + */ + public @NonNull GeckoRuntime getRuntime() { + return RuntimeCreator.getRuntime(); + } + + public void setTelemetryDelegate(final RuntimeTelemetry.Delegate delegate) { + RuntimeCreator.setTelemetryDelegate(delegate); + } + + public @Nullable GeckoDisplay getDisplay() { + return mDisplays.get(mMainSession); + } + + protected static void setDelegate( + final @NonNull Class cls, + final @NonNull GeckoSession session, + final @Nullable Object delegate) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + if (cls == GeckoSession.TextInputDelegate.class) { + session.getTextInput().setDelegate((TextInputDelegate) delegate); + } else if (cls == ContentBlocking.Delegate.class) { + session.setContentBlockingDelegate((ContentBlocking.Delegate) delegate); + } else if (cls == Autofill.Delegate.class) { + session.setAutofillDelegate((Autofill.Delegate) delegate); + } else if (cls == MediaSession.Delegate.class) { + session.setMediaSessionDelegate((MediaSession.Delegate) delegate); + } else { + GeckoSession.class.getMethod("set" + cls.getSimpleName(), cls).invoke(session, delegate); + } + } + + protected static void setRuntimeDelegate( + final @NonNull Class cls, + final @NonNull GeckoRuntime runtime, + final @Nullable Object delegate) { + if (cls == Autocomplete.StorageDelegate.class) { + runtime.setAutocompleteStorageDelegate((Autocomplete.StorageDelegate) delegate); + } else if (cls == ActivityDelegate.class) { + runtime.setActivityDelegate((ActivityDelegate) delegate); + } else if (cls == GeckoRuntime.Delegate.class) { + runtime.setDelegate((GeckoRuntime.Delegate) delegate); + } else if (cls == OrientationController.OrientationDelegate.class) { + runtime + .getOrientationController() + .setDelegate((OrientationController.OrientationDelegate) delegate); + } else if (cls == ServiceWorkerDelegate.class) { + runtime.setServiceWorkerDelegate((ServiceWorkerDelegate) delegate); + } else if (cls == WebNotificationDelegate.class) { + runtime.setWebNotificationDelegate((WebNotificationDelegate) delegate); + } else if (cls == WebExtensionController.PromptDelegate.class) { + runtime + .getWebExtensionController() + .setPromptDelegate((WebExtensionController.PromptDelegate) delegate); + } else if (cls == WebPushDelegate.class) { + runtime.getWebPushController().setDelegate((WebPushDelegate) delegate); + } else { + throw new IllegalStateException("Unknown runtime delegate " + cls.getName()); + } + } + + protected static Object getRuntimeDelegate( + final @NonNull Class cls, final @NonNull GeckoRuntime runtime) { + if (cls == Autocomplete.StorageDelegate.class) { + return runtime.getAutocompleteStorageDelegate(); + } else if (cls == ActivityDelegate.class) { + return runtime.getActivityDelegate(); + } else if (cls == GeckoRuntime.Delegate.class) { + return runtime.getDelegate(); + } else if (cls == OrientationController.OrientationDelegate.class) { + return runtime.getOrientationController().getDelegate(); + } else if (cls == ServiceWorkerDelegate.class) { + return runtime.getServiceWorkerDelegate(); + } else if (cls == WebNotificationDelegate.class) { + return runtime.getWebNotificationDelegate(); + } else if (cls == WebExtensionController.PromptDelegate.class) { + return runtime.getWebExtensionController().getPromptDelegate(); + } else if (cls == WebPushDelegate.class) { + return runtime.getWebPushController().getDelegate(); + } else { + throw new IllegalStateException("Unknown runtime delegate " + cls.getName()); + } + } + + protected static Object getDelegate( + final @NonNull Class cls, final @NonNull GeckoSession session) + throws NoSuchMethodException, IllegalAccessException, InvocationTargetException { + if (cls == GeckoSession.TextInputDelegate.class) { + return SessionTextInput.class.getMethod("getDelegate").invoke(session.getTextInput()); + } + if (cls == ContentBlocking.Delegate.class) { + return GeckoSession.class.getMethod("getContentBlockingDelegate").invoke(session); + } + if (cls == Autofill.Delegate.class) { + return GeckoSession.class.getMethod("getAutofillDelegate").invoke(session); + } + if (cls == MediaSession.Delegate.class) { + return GeckoSession.class.getMethod("getMediaSessionDelegate").invoke(session); + } + return GeckoSession.class.getMethod("get" + cls.getSimpleName()).invoke(session); + } + + @NonNull + private Set> getCurrentDelegates() { + final List> waitDelegates = mWaitScopeDelegates.getExternalDelegates(); + final List> testDelegates = mTestScopeDelegates.getExternalDelegates(); + + final Set> set = new HashSet<>(DEFAULT_DELEGATES); + set.addAll(DEFAULT_RUNTIME_DELEGATES); + + for (final ExternalDelegate delegate : waitDelegates) { + set.add(delegate.delegate); + } + for (final ExternalDelegate delegate : testDelegates) { + set.add(delegate.delegate); + } + return set; + } + + private void addNullDelegate(final Class delegate) { + assertThat( + "Null-delegate must be valid interface class", + delegate, + either(isIn(DEFAULT_DELEGATES)).or(isIn(DEFAULT_RUNTIME_DELEGATES))); + mNullDelegates.add(delegate); + } + + protected void applyAnnotations( + final Collection annotations, final GeckoSessionSettings settings) { + for (final Annotation annotation : annotations) { + if (TimeoutMillis.class.equals(annotation.annotationType())) { + // Scale timeout based on the default timeout to account for the device under test. + final long value = ((TimeoutMillis) annotation).value(); + final long timeout = + value * env.getScaledTimeoutMillis() / Environment.DEFAULT_TIMEOUT_MILLIS; + mTimeoutMillis = Math.max(timeout, 1000); + } else if (Setting.class.equals(annotation.annotationType())) { + ((Setting) annotation).key().set(settings, ((Setting) annotation).value()); + } else if (Setting.List.class.equals(annotation.annotationType())) { + for (final Setting setting : ((Setting.List) annotation).value()) { + setting.key().set(settings, setting.value()); + } + } else if (NullDelegate.class.equals(annotation.annotationType())) { + addNullDelegate(((NullDelegate) annotation).value()); + } else if (NullDelegate.List.class.equals(annotation.annotationType())) { + for (final NullDelegate nullDelegate : ((NullDelegate.List) annotation).value()) { + addNullDelegate(nullDelegate.value()); + } + } else if (WithDisplay.class.equals(annotation.annotationType())) { + final WithDisplay displaySize = (WithDisplay) annotation; + mDisplaySize = new Point(displaySize.width(), displaySize.height()); + } else if (ClosedSessionAtStart.class.equals(annotation.annotationType())) { + mClosedSession = ((ClosedSessionAtStart) annotation).value(); + } else if (IgnoreCrash.class.equals(annotation.annotationType())) { + mIgnoreCrash = ((IgnoreCrash) annotation).value(); + } + } + } + + private static RuntimeException unwrapRuntimeException(final Throwable e) { + final Throwable cause = e.getCause(); + if (cause != null && cause instanceof RuntimeException) { + return (RuntimeException) cause; + } else if (e instanceof RuntimeException) { + return (RuntimeException) e; + } + + return new RuntimeException(cause != null ? cause : e); + } + + protected void prepareStatement(final Description description) { + final GeckoSessionSettings settings = new GeckoSessionSettings(mDefaultSettings); + mTimeoutMillis = env.getDefaultTimeoutMillis(); + mNullDelegates = new HashSet<>(); + mClosedSession = false; + mIgnoreCrash = false; + + applyAnnotations(Arrays.asList(description.getTestClass().getAnnotations()), settings); + applyAnnotations(description.getAnnotations(), settings); + + final List records = new ArrayList<>(); + final CallbackDelegates waitDelegates = new CallbackDelegates(); + final CallbackDelegates testDelegates = new CallbackDelegates(); + mCallRecords = records; + mWaitScopeDelegates = waitDelegates; + mTestScopeDelegates = testDelegates; + mLastWaitStart = 0; + mLastWaitEnd = 0; + + final InvocationHandler recorder = + new InvocationHandler() { + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) { + boolean ignore = false; + MethodCall call = null; + + if (Object.class.equals(method.getDeclaringClass())) { + switch (method.getName()) { + case "equals": + return proxy == args[0]; + case "toString": + return "Call Recorder"; + } + ignore = true; + } else if (mCallRecordHandler != null) { + ignore = mCallRecordHandler.handleCall(method, args); + } + + final boolean isDefaultDelegate = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()); + final boolean isDefaultRuntimeDelegate = + DEFAULT_RUNTIME_DELEGATES.contains(method.getDeclaringClass()); + + if (!ignore) { + if (isDefaultDelegate) { + ThreadUtils.assertOnUiThread(); + } + + final GeckoSession session; + if (!isDefaultDelegate) { + session = null; + } else { + assertThat( + "Callback first argument must be session object", + args, + arrayWithSize(greaterThan(0))); + assertThat( + "Callback first argument must be session object", + args[0], + instanceOf(GeckoSession.class)); + session = (GeckoSession) args[0]; + } + + if ((sOnCrash.equals(method) || sOnKill.equals(method)) + && !mIgnoreCrash + && isUsingSession(session)) { + if (env.shouldShutdownOnCrash()) { + getRuntime().shutdown(); + } + + throw new ChildCrashedException("Child process crashed"); + } + + records.add(new CallRecord(session, method, args)); + + call = waitDelegates.prepareMethodCall(session, method); + if (call == null) { + call = testDelegates.prepareMethodCall(session, method); + } + + if (!isDefaultDelegate && !isDefaultRuntimeDelegate) { + assertThat("External delegate should be registered", call, notNullValue()); + } + } + + Object returnValue = null; + try { + mCurrentMethodCall = call; + if (call != null && call.target != null) { + returnValue = method.invoke(call.target, args); + } else { + returnValue = method.invoke(DEFAULT_IMPL, args); + } + } catch (final IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + + return returnValue; + } + }; + + final Set> delegates = new HashSet<>(); + delegates.addAll(DEFAULT_DELEGATES); + delegates.addAll(DEFAULT_RUNTIME_DELEGATES); + final Class[] classes = delegates.toArray(new Class[delegates.size()]); + mCallbackProxy = Proxy.newProxyInstance(GeckoSession.class.getClassLoader(), classes, recorder); + mAllDelegates = new HashSet<>(delegates); + + mMainSession = new GeckoSession(settings); + prepareSession(mMainSession); + prepareRuntime(getRuntime()); + + if (mDisplaySize != null) { + addDisplay(mMainSession, mDisplaySize.x, mDisplaySize.y); + } + + if (!mClosedSession) { + openSession(mMainSession); + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + if (RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_OK) { + throw new RuntimeException("Could not register TestSupport, see logs for error."); + } + } + } + + protected void prepareRuntime(final GeckoRuntime runtime) { + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + for (final Class cls : DEFAULT_RUNTIME_DELEGATES) { + setRuntimeDelegate(cls, runtime, mNullDelegates.contains(cls) ? null : mCallbackProxy); + } + } + + protected void prepareSession(final GeckoSession session) { + UiThreadUtils.waitForCondition( + () -> RuntimeCreator.sTestSupport.get() != RuntimeCreator.TEST_SUPPORT_INITIAL, + env.getDefaultTimeoutMillis()); + session + .getWebExtensionController() + .setMessageDelegate(RuntimeCreator.sTestSupportExtension, mMessageDelegate, "browser"); + for (final Class cls : DEFAULT_DELEGATES) { + try { + setDelegate(cls, session, mNullDelegates.contains(cls) ? null : mCallbackProxy); + } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + } + + /** + * Call open() on a session, and ensure it's ready for use by the test. In particular, remove any + * extra calls recorded as part of opening the session. + * + * @param session Session to open. + */ + public void openSession(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + // We receive an initial about:blank load; don't expose that to the test. The initial + // load ends with the first onPageStop call, so ignore everything from the session + // until the first onPageStop call. + + try { + // We cannot detect initial page load without progress delegate. + assertThat( + "ProgressDelegate cannot be null-delegate when opening session", + GeckoSession.ProgressDelegate.class, + not(isIn(mNullDelegates))); + mCallRecordHandler = + (method, args) -> { + Log.e(LOGTAG, "method: " + method); + final boolean matching = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()) && session.equals(args[0]); + if (matching && sOnPageStop.equals(method)) { + mCallRecordHandler = null; + } + return matching; + }; + + session.open(getRuntime()); + + UiThreadUtils.waitForCondition( + () -> mCallRecordHandler == null, env.getDefaultTimeoutMillis()); + } finally { + mCallRecordHandler = null; + } + } + + private void waitForOpenSession(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + // We receive an initial about:blank load; don't expose that to the test. The initial + // load ends with the first onPageStop call, so ignore everything from the session + // until the first onPageStop call. + + try { + // We cannot detect initial page load without progress delegate. + assertThat( + "ProgressDelegate cannot be null-delegate when opening session", + GeckoSession.ProgressDelegate.class, + not(isIn(mNullDelegates))); + mCallRecordHandler = + (method, args) -> { + Log.e(LOGTAG, "method: " + method); + final boolean matching = + DEFAULT_DELEGATES.contains(method.getDeclaringClass()) && session.equals(args[0]); + if (matching && sOnPageStop.equals(method)) { + mCallRecordHandler = null; + } + return matching; + }; + + UiThreadUtils.waitForCondition( + () -> mCallRecordHandler == null, env.getDefaultTimeoutMillis()); + } finally { + mCallRecordHandler = null; + } + } + + /** Internal method to perform callback checks at the end of a test. */ + public void performTestEndCheck() { + mWaitScopeDelegates.clearAndAssert(); + mTestScopeDelegates.clearAndAssert(); + } + + protected void cleanupRuntime(final GeckoRuntime runtime) { + for (final Class cls : DEFAULT_RUNTIME_DELEGATES) { + setRuntimeDelegate(cls, runtime, null); + } + } + + protected void cleanupSession(final GeckoSession session) { + if (session.isOpen()) { + session.close(); + } + releaseDisplay(session); + } + + protected boolean isUsingSession(final GeckoSession session) { + return session.equals(mMainSession) || mSubSessions.contains(session); + } + + protected void deleteCrashDumps() { + final File dumpDir = new File(getProfilePath(), "minidumps"); + for (final File dump : dumpDir.listFiles()) { + dump.delete(); + } + } + + protected void cleanupExtensions() throws Throwable { + final WebExtensionController controller = getRuntime().getWebExtensionController(); + final List list = waitForResult(controller.list(), env.getDefaultTimeoutMillis()); + + boolean hasTestSupport = false; + // Uninstall any left-over extensions + for (final WebExtension extension : list) { + if (!extension.id.equals(RuntimeCreator.TEST_SUPPORT_EXTENSION_ID)) { + waitForResult(controller.uninstall(extension), env.getDefaultTimeoutMillis()); + } else { + hasTestSupport = true; + } + } + + // If an extension was still installed, this test should fail. + // Note the test support extension is always kept for speed. + assertThat( + "A WebExtension was left installed during this test.", + list.size(), + equalTo(hasTestSupport ? 1 : 0)); + } + + protected void cleanupStatement() throws Throwable { + mWaitScopeDelegates.clear(); + mTestScopeDelegates.clear(); + + for (final GeckoSession session : mSubSessions) { + cleanupSession(session); + } + + cleanupRuntime(getRuntime()); + cleanupSession(mMainSession); + cleanupExtensions(); + + if (mIgnoreCrash) { + deleteCrashDumps(); + } + + mMainSession = null; + mCallbackProxy = null; + mAllDelegates = null; + mNullDelegates = null; + mCallRecords = null; + mWaitScopeDelegates = null; + mTestScopeDelegates = null; + mLastWaitStart = 0; + mLastWaitEnd = 0; + mTimeoutMillis = 0; + RuntimeCreator.setTelemetryDelegate(null); + } + + // These markers are used by runjunit.py to capture the logcat of a test + private static final String TEST_START_MARKER = "test_start 1f0befec-3ff2-40ff-89cf-b127eb38b1ec"; + private static final String TEST_END_MARKER = "test_end c5ee677f-bc83-49bd-9e28-2d35f3d0f059"; + + @Override + public Statement apply(final Statement base, final Description description) { + return new Statement() { + private TestServer mServer; + + private void initTest() { + try { + mServer.start(TEST_PORT); + + RuntimeCreator.setPortDelegate(mMessageDelegate); + getRuntime(); + + Log.e(LOGTAG, TEST_START_MARKER + " " + description); + Log.e(LOGTAG, "before prepareStatement " + description); + prepareStatement(description); + Log.e(LOGTAG, "after prepareStatement"); + } catch (final Throwable t) { + // Any error here is not related to a specific test + throw new TestHarnessException(t); + } + } + + @Override + public void evaluate() throws Throwable { + final AtomicReference exceptionRef = new AtomicReference<>(); + + mServer = new TestServer(InstrumentationRegistry.getInstrumentation().getTargetContext()); + + mInstrumentation.runOnMainSync( + () -> { + try { + initTest(); + base.evaluate(); + Log.e(LOGTAG, "after evaluate"); + performTestEndCheck(); + Log.e(LOGTAG, "after performTestEndCheck"); + } catch (final Throwable t) { + Log.e(LOGTAG, "Error", t); + exceptionRef.set(t); + } finally { + try { + mServer.stop(); + cleanupStatement(); + } catch (final Throwable t) { + exceptionRef.compareAndSet(null, t); + } + Log.e(LOGTAG, TEST_END_MARKER + " " + description); + } + }); + + final Throwable throwable = exceptionRef.get(); + if (throwable != null) { + throw throwable; + } + } + }; + } + + /** This simply sends an empty message to the web content and waits for a reply. */ + public void waitForRoundTrip(final GeckoSession session) { + waitForJS(session, "true"); + } + + /** + * Wait until a page load has finished on any session. A session must have started a page load + * since the last wait, or this method will wait indefinitely. + */ + public void waitForPageStop() { + waitForPageStop(/* session */ null); + } + + /** + * Wait until a page load has finished. The session must have started a page load since the last + * wait, or this method will wait indefinitely. + * + * @param session Session to wait on, or null to wait on any session. + */ + public void waitForPageStop(final GeckoSession session) { + waitForPageStops(session, /* count */ 1); + } + + /** + * Wait until a page load has finished on any session. A session must have started a page load + * since the last wait, or this method will wait indefinitely. + * + * @param count Number of page loads to wait for. + */ + public void waitForPageStops(final int count) { + waitForPageStops(/* session */ null, count); + } + + /** + * Wait until a page load has finished. The session must have started a page load since the last + * wait, or this method will wait indefinitely. + * + * @param session Session to wait on, or null to wait on any session. + * @param count Number of page loads to wait for. + */ + public void waitForPageStops(final GeckoSession session, final int count) { + final List methodCalls = new ArrayList<>(1); + methodCalls.add( + new MethodCall(session, sOnPageStop, new CallRequirement(/* allowed */ true, count, null))); + + waitUntilCalled(session, GeckoSession.ProgressDelegate.class, methodCalls, null); + } + + /** + * Wait until the specified methods have been called on the specified callback interface for any + * session. If no methods are specified, wait until any method has been called. + * + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @NonNull KClass callback, final @Nullable String... methods) { + waitUntilCalled(/* session */ null, callback, methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface. If no + * methods are specified, wait until any method has been called. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull KClass callback, + final @Nullable String... methods) { + waitUntilCalled(session, JvmClassMappingKt.getJavaClass(callback), methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface for any + * session. If no methods are specified, wait until any method has been called. + * + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled(final @NonNull Class callback, final @Nullable String... methods) { + waitUntilCalled(/* session */ null, callback, methods); + } + + /** + * Wait until the specified methods have been called on the specified callback interface. If no + * methods are specified, wait until any method has been called. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback interface; must be an interface under GeckoSession. + * @param methods List of methods to wait on; use empty or null or wait on any method. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull Class callback, + final @Nullable String... methods) { + final int length = (methods != null) ? methods.length : 0; + final Pattern[] patterns = new Pattern[length]; + for (int i = 0; i < length; i++) { + patterns[i] = Pattern.compile(methods[i]); + } + + final List waitMethods = new ArrayList<>(); + boolean isSessionCallback = false; + + for (final Class ifce : getCurrentDelegates()) { + if (!ifce.isAssignableFrom(callback)) { + continue; + } + for (final Method method : ifce.getMethods()) { + for (final Pattern pattern : patterns) { + if (!pattern.matcher(method.getName()).matches()) { + continue; + } + waitMethods.add(new MethodCall(session, method, new CallRequirement(true, -1, null))); + break; + } + } + isSessionCallback = true; + } + + assertThat( + "Delegate should be a GeckoSession delegate " + "or registered external delegate", + isSessionCallback, + equalTo(true)); + + waitUntilCalled(session, callback, waitMethods, null); + } + + /** + * Wait until the specified methods have been called on the specified object for any session, as + * specified by any {@link AssertCalled @AssertCalled} annotations. If no {@link + * AssertCalled @AssertCalled} annotations are found, wait until any method has been called. Only + * methods belonging to a GeckoSession callback are supported. + * + * @param callback Target callback object; must implement an interface under GeckoSession. + */ + public void waitUntilCalled(final @NonNull Object callback) { + waitUntilCalled(/* session */ null, callback); + } + + /** + * Wait until the specified methods have been called on the specified object, as specified by any + * {@link AssertCalled @AssertCalled} annotations. If no {@link AssertCalled @AssertCalled} + * annotations are found, wait until any method has been called. Only methods belonging to a + * GeckoSession callback are supported. + * + * @param session Session to wait on, or null to wait on any session. + * @param callback Target callback object; must implement an interface under GeckoSession. + */ + public void waitUntilCalled( + final @Nullable GeckoSession session, final @NonNull Object callback) { + if (callback instanceof Class) { + waitUntilCalled(session, (Class) callback, (String[]) null); + return; + } + + final List methodCalls = new ArrayList<>(); + boolean isSessionCallback = false; + + for (final Class ifce : getCurrentDelegates()) { + if (!ifce.isInstance(callback)) { + continue; + } + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final AssertCalled ac = getAssertCalled(callbackMethod, callback); + methodCalls.add(new MethodCall(session, method, ac, /* target */ null)); + } + isSessionCallback = true; + } + + assertThat( + "Delegate should implement a GeckoSession, GeckoRuntime delegate " + + "or registered external delegate", + isSessionCallback, + equalTo(true)); + + waitUntilCalled(session, callback.getClass(), methodCalls, callback); + } + + /** + * * Implement this interface in {@link #waitUntilCalled} to allow waiting until this method + * returns true. E.g. for when the test needs to wait for a specific value on a delegate call. + */ + public interface ShouldContinue { + /** + * Whether the test should keep waiting or not. + * + * @return true if the test should keep waiting. + */ + default boolean shouldContinue() { + return false; + } + } + + private void waitUntilCalled( + final @Nullable GeckoSession session, + final @NonNull Class delegate, + final @NonNull List methodCalls, + final @Nullable Object callback) { + ThreadUtils.assertOnUiThread(); + + if (session != null && !session.equals(mMainSession)) { + assertThat("Session should be wrapped through wrapSession", session, isIn(mSubSessions)); + } + + // Make sure all handlers are set though #delegateUntilTestEnd or #delegateDuringNextWait, + // instead of through GeckoSession directly, so that we can still record calls even with + // custom handlers set. + for (final Class ifce : DEFAULT_DELEGATES) { + final Object sessionDelegate; + try { + sessionDelegate = getDelegate(ifce, session == null ? mMainSession : session); + } catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } + if (mNullDelegates.contains(ifce)) { + // Null-delegates are initially null but are allowed to be any value. + continue; + } + assertThat( + ifce.getSimpleName() + + " callbacks should be " + + "accessed through GeckoSessionTestRule delegate methods", + sessionDelegate, + sameInstance(mCallbackProxy)); + } + + for (final Class ifce : DEFAULT_RUNTIME_DELEGATES) { + final Object runtimeDelegate = getRuntimeDelegate(ifce, getRuntime()); + if (mNullDelegates.contains(ifce)) { + // Null-delegates are initially null but are allowed to be any value. + continue; + } + assertThat( + ifce.getSimpleName() + + " callbacks should be " + + "accessed through GeckoSessionTestRule delegate methods", + runtimeDelegate, + sameInstance(mCallbackProxy)); + } + + if (methodCalls.isEmpty()) { + // Waiting for any call on `delegate`; make sure it doesn't contain any null-delegates. + for (final Class ifce : mNullDelegates) { + assertThat( + "Cannot wait on null-delegate callbacks", delegate, not(typeCompatibleWith(ifce))); + } + } else { + // Waiting for particular calls; make sure those calls aren't from a null-delegate. + for (final MethodCall call : methodCalls) { + assertThat( + "Cannot wait on null-delegate callbacks", + call.method.getDeclaringClass(), + not(isIn(mNullDelegates))); + } + } + + boolean calledAny = false; + int index = mLastWaitEnd; + final long startTime = SystemClock.uptimeMillis(); + + beforeWait(); + + ShouldContinue cont = new ShouldContinue() {}; + if (callback instanceof ShouldContinue) { + cont = (ShouldContinue) callback; + } + + List pendingMethodCalls = + methodCalls.stream() + .filter( + mc -> mc.requirement != null && mc.requirement.count != 0 && mc.requirement.allowed) + .collect(Collectors.toList()); + + int order = 0; + while (!calledAny || !pendingMethodCalls.isEmpty() || cont.shouldContinue()) { + final int currentIndex = index; + + // Let's wait for more messages if we reached the end + UiThreadUtils.waitForCondition(() -> (currentIndex < mCallRecords.size()), mTimeoutMillis); + + if (SystemClock.uptimeMillis() - startTime > mTimeoutMillis) { + throw new UiThreadUtils.TimeoutException("Timed out after " + mTimeoutMillis + "ms"); + } + + final CallRecord record = mCallRecords.get(index); + final MethodCall recorded = record.methodCall; + + final boolean isDelegate = recorded.method.getDeclaringClass().isAssignableFrom(delegate); + + calledAny |= isDelegate; + index++; + + final int i = methodCalls.indexOf(recorded); + if (i < 0) { + continue; + } + + final MethodCall methodCall = methodCalls.get(i); + assertAllowMoreCalls(methodCall); + + methodCall.incrementCounter(); + assertOrder(methodCall, order); + order = Math.max(methodCall.getOrder(), order); + + if (methodCall.allowUnlimitedCalls() || !methodCall.allowMoreCalls()) { + pendingMethodCalls.remove(methodCall); + } + + if (isDelegate && callback != null) { + try { + mCurrentMethodCall = methodCall; + record.method.invoke(callback, record.args); + } catch (IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + } + } + + afterWait(index); + } + + protected void beforeWait() { + mLastWaitStart = mLastWaitEnd; + } + + protected void afterWait(final int endCallIndex) { + mLastWaitEnd = endCallIndex; + mWaitScopeDelegates.clearAndAssert(); + + // Register any test-delegates that were not registered due to wait-delegates + // having precedence. + for (final ExternalDelegate delegate : mTestScopeDelegates.getExternalDelegates()) { + delegate.register(); + } + } + + /** + * Playback callbacks that were made on all sessions during the previous wait. For any methods + * annotated with {@link AssertCalled @AssertCalled}, assert that the callbacks satisfy the + * specified requirements. If no {@link AssertCalled @AssertCalled} annotations are found, assert + * any method has been called. Only methods belonging to a GeckoSession callback are supported. + * + * @param callback Target callback object; must implement one or more interfaces under + * GeckoSession. + */ + public void forCallbacksDuringWait(final @NonNull Object callback) { + forCallbacksDuringWait(/* session */ null, callback); + } + + /** + * Playback callbacks that were made during the previous wait. For any methods annotated with + * {@link AssertCalled @AssertCalled}, assert that the callbacks satisfy the specified + * requirements. If no {@link AssertCalled @AssertCalled} annotations are found, assert any method + * has been called. Only methods belonging to a GeckoSession callback are supported. + * + * @param session Target session object, or null to playback all sessions. + * @param callback Target callback object; must implement one or more interfaces under + * GeckoSession. + */ + public void forCallbacksDuringWait( + final @Nullable GeckoSession session, final @NonNull Object callback) { + final Method[] declaredMethods = callback.getClass().getDeclaredMethods(); + final List methodCalls = new ArrayList<>(declaredMethods.length); + boolean assertingAnyCall = true; + Class foundNullDelegate = null; + + for (final Class ifce : mAllDelegates) { + if (!ifce.isInstance(callback)) { + continue; + } + if (mNullDelegates.contains(ifce)) { + foundNullDelegate = ifce; + } + for (final Method method : ifce.getMethods()) { + final Method callbackMethod; + try { + callbackMethod = + callback.getClass().getMethod(method.getName(), method.getParameterTypes()); + } catch (final NoSuchMethodException e) { + throw new RuntimeException(e); + } + final MethodCall call = + new MethodCall( + session, + callbackMethod, + getAssertCalled(callbackMethod, callback), + /* target */ null); + methodCalls.add(call); + + if (call.requirement != null) { + if (foundNullDelegate == ifce) { + fail("Cannot assert on null-delegate " + ifce.getSimpleName()); + } + assertingAnyCall = false; + } + } + } + + if (assertingAnyCall && foundNullDelegate != null) { + fail("Cannot assert on null-delegate " + foundNullDelegate.getSimpleName()); + } + + int order = 0; + boolean calledAny = false; + + for (int index = mLastWaitStart; index < mLastWaitEnd; index++) { + final CallRecord record = mCallRecords.get(index); + + if (!record.method.getDeclaringClass().isInstance(callback) + || (session != null + && DEFAULT_DELEGATES.contains(record.method.getDeclaringClass()) + && !session.equals(record.args[0]))) { + continue; + } + + final int i = methodCalls.indexOf(record.methodCall); + checkThat(record.method.getName() + " should be found", i, greaterThanOrEqualTo(0)); + + final MethodCall methodCall = methodCalls.get(i); + assertAllowMoreCalls(methodCall); + methodCall.incrementCounter(); + assertOrder(methodCall, order); + order = Math.max(methodCall.getOrder(), order); + + try { + mCurrentMethodCall = methodCall; + record.method.invoke(callback, record.args); + } catch (final IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } finally { + mCurrentMethodCall = null; + } + calledAny = true; + } + + for (final MethodCall methodCall : methodCalls) { + assertMatchesCount(methodCall); + if (methodCall.requirement != null) { + calledAny = true; + } + } + + checkThat( + "Should have called one of " + Arrays.toString(callback.getClass().getInterfaces()), + calledAny, + equalTo(true)); + } + + /** + * Get information about the current call. Only valid during a {@link #forCallbacksDuringWait}, + * {@link #delegateDuringNextWait}, or {@link #delegateUntilTestEnd} callback. + * + * @return Call information + */ + public @NonNull CallInfo getCurrentCall() { + assertThat("Should be in a method call", mCurrentMethodCall, notNullValue()); + return mCurrentMethodCall.getInfo(); + } + + /** + * Delegate implemented interfaces to the specified callback object for all sessions, for the rest + * of the test. Only GeckoSession callback interfaces are supported. Delegates for {@code + * delegateUntilTestEnd} can be temporarily overridden by delegates for {@link + * #delegateDuringNextWait}. + * + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateUntilTestEnd(final @NonNull Object callback) { + delegateUntilTestEnd(/* session */ null, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object, for the rest of the test. + * Only GeckoSession callback interfaces are supported. Delegates for {@link + * #delegateUntilTestEnd} can be temporarily overridden by delegates for {@link + * #delegateDuringNextWait}. + * + * @param session Session to target, or null to target all sessions. + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateUntilTestEnd( + final @Nullable GeckoSession session, final @NonNull Object callback) { + mTestScopeDelegates.delegate(session, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object for all sessions, during the + * next wait. Only GeckoSession callback interfaces are supported. Delegates for {@code + * delegateDuringNextWait} can temporarily take precedence over delegates for {@link + * #delegateUntilTestEnd}. + * + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateDuringNextWait(final @NonNull Object callback) { + delegateDuringNextWait(/* session */ null, callback); + } + + /** + * Delegate implemented interfaces to the specified callback object, during the next wait. Only + * GeckoSession callback interfaces are supported. Delegates for {@link #delegateDuringNextWait} + * can temporarily take precedence over delegates for {@link #delegateUntilTestEnd}. + * + * @param session Session to target, or null to target all sessions. + * @param callback Callback object, or null to clear all previously-set delegates. + */ + public void delegateDuringNextWait( + final @Nullable GeckoSession session, final @NonNull Object callback) { + mWaitScopeDelegates.delegate(session, callback); + } + + /** + * Synthesize a tap event at the specified location using the main session. The session must have + * been created with a display. + * + * @param session Target session + * @param x X coordinate + * @param y Y coordinate + */ + public void synthesizeTap(final @NonNull GeckoSession session, final int x, final int y) { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent down = + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0); + session.getPanZoomController().onTouchEvent(down); + + final MotionEvent up = + MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0); + session.getPanZoomController().onTouchEvent(up); + } + + /** + * Synthesize a mouse move event at the specified location using the main session. The session + * must have been created with a display. + * + * @param session Target session + * @param x X coordinate + * @param y Y coordinate + */ + public void synthesizeMouseMove(final @NonNull GeckoSession session, final int x, final int y) { + final MotionEvent.PointerProperties pointerProperty = new MotionEvent.PointerProperties(); + pointerProperty.id = 0; + pointerProperty.toolType = MotionEvent.TOOL_TYPE_MOUSE; + + final MotionEvent.PointerCoords pointerCoord = new MotionEvent.PointerCoords(); + pointerCoord.x = x; + pointerCoord.y = y; + + final MotionEvent.PointerProperties[] pointerProperties = + new MotionEvent.PointerProperties[] {pointerProperty}; + final MotionEvent.PointerCoords[] pointerCoords = + new MotionEvent.PointerCoords[] {pointerCoord}; + + final long moveTime = SystemClock.uptimeMillis(); + final MotionEvent moveEvent = + MotionEvent.obtain( + moveTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_HOVER_MOVE, + 1, + pointerProperties, + pointerCoords, + 0, + 0, + 1.0f, + 1.0f, + 0, + 0, + InputDevice.SOURCE_MOUSE, + 0); + session.getPanZoomController().onTouchEvent(moveEvent); + } + + /** + * Simulates a press to the Home button, causing the application to go to onPause. NB: Some time + * must elapse for the event to fully occur. + * + * @param context starting the Home intent + */ + public void simulatePressHome(Context context) { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_HOME); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + /** + * Simulates returningGeckoViewTestActivity to the foreground. Activity must already be in use. + * NB: Some time must elapse for the event to fully occur. + * + * @param context starting the intent + */ + public void requestActivityToForeground(Context context) { + Intent notificationIntent = new Intent(context, GeckoViewTestActivity.class); + notificationIntent.setAction(Intent.ACTION_MAIN); + notificationIntent.addCategory(Intent.CATEGORY_LAUNCHER); + notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(notificationIntent); + } + + /** + * Mock Location Provider can be used in testing for creating mock locations. NB: Likely also need + * to set test setting geo.provider.testing to false to prevent network geolocation from + * interfering when using. + */ + public class MockLocationProvider { + + private final LocationManager locationManager; + private final String mockProviderName; + private boolean isActiveTestProvider = false; + private double mockLatitude; + private double mockLongitude; + private float mockAccuracy = .000001f; + private boolean doContinuallyPost; + + @Nullable private ScheduledExecutorService executor; + + /** + * Mock Location Provider adds a test provider to the location manager and controls sending mock + * locations. Use @{@link #postLocation()} to post the location to the location manager. + * Use @{@link #removeMockLocationProvider()} to remove the location provider to clean-up the + * test harness. Default accuracy is .000001f. + * + * @param locationManager location manager to accept the locations + * @param mockProviderName location provider that will use this location + * @param mockLatitude initial latitude in degrees that @{@link #postLocation()} will use + * @param mockLongitude initial longitude in degrees that @{@link #postLocation()} will use + * @param doContinuallyPost when posting a location, continue to post every 3s to keep location + * current + */ + public MockLocationProvider( + LocationManager locationManager, + String mockProviderName, + double mockLatitude, + double mockLongitude, + boolean doContinuallyPost) { + this.locationManager = locationManager; + this.mockProviderName = mockProviderName; + this.mockLatitude = mockLatitude; + this.mockLongitude = mockLongitude; + this.doContinuallyPost = doContinuallyPost; + addMockLocationProvider(); + } + + /** Adds a mock location provider that can have locations manually set. */ + private void addMockLocationProvider() { + // Ensures that only one location provider with this name exists + removeMockLocationProvider(); + locationManager.addTestProvider( + mockProviderName, + false, + false, + false, + false, + false, + false, + false, + Criteria.POWER_LOW, + Criteria.ACCURACY_FINE); + locationManager.setTestProviderEnabled(mockProviderName, true); + isActiveTestProvider = true; + } + + /** + * Removes the location provider. Recommend calling when ending test to prevent the mock + * provider remaining as a test provider. + */ + public void removeMockLocationProvider() { + stopPostingLocation(); + try { + locationManager.removeTestProvider(mockProviderName); + } catch (Exception e) { + // Throws an exception if there is no provider with that name + } + isActiveTestProvider = false; + } + + /** + * Sets the mock location on MockLocationProvider, that will be used by @{@link #postLocation()} + * + * @param latitude latitude in degrees to mock + * @param longitude longitude in degrees to mock + */ + public void setMockLocation(double latitude, double longitude) { + mockLatitude = latitude; + mockLongitude = longitude; + } + + /** + * Sets the mock location on a MockLocationProvider, that will be used by @{@link + * #postLocation()} . Note, changing the accuracy can affect the importance of the mock provider + * compared to other location providers. + * + * @param latitude latitude in degrees to mock + * @param longitude longitude in degrees to mock + * @param accuracy horizontal accuracy in meters to mock + */ + public void setMockLocation(double latitude, double longitude, float accuracy) { + mockLatitude = latitude; + mockLongitude = longitude; + mockAccuracy = accuracy; + } + + /** + * When doContinuallyPost is set to true, @{@link #postLocation()} will post the location to the + * location manager every 3s. When set to false, @{@link #postLocation()} will only post the + * location once. Purpose is to prevent the location from becoming stale. + * + * @param doContinuallyPost setting for continually posting the location after calling @{@link + * #postLocation()} + */ + public void setDoContinuallyPost(boolean doContinuallyPost) { + this.doContinuallyPost = doContinuallyPost; + } + + /** + * Shutsdown and removes the executor created by @{@link #postLocation()} when @{@link + * #doContinuallyPost is true} to stop posting the location. + */ + public void stopPostingLocation() { + if (executor != null) { + executor.shutdown(); + executor = null; + } + } + + /** + * Posts the set location to the system location manager. If @{@link #doContinuallyPost} is + * true, the location will be posted every 3s by an executor, otherwise will post once. + */ + public void postLocation() { + if (!isActiveTestProvider) { + throw new IllegalStateException("The mock test provider is not active."); + } + + // Ensure the thread that was posting a location (if applicable) is stopped. + stopPostingLocation(); + + // Set Location + Location location = new Location(mockProviderName); + location.setAccuracy(mockAccuracy); + location.setLatitude(mockLatitude); + location.setLongitude(mockLongitude); + location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + location.setTime(System.currentTimeMillis()); + locationManager.setTestProviderLocation(mockProviderName, location); + Log.i( + LOGTAG, + mockProviderName + + " is posting location, lat: " + + mockLatitude + + " lon: " + + mockLongitude + + " acc: " + + mockAccuracy); + // Continually post location + if (doContinuallyPost) { + executor = Executors.newScheduledThreadPool(1); + executor.scheduleAtFixedRate( + new Runnable() { + @Override + public void run() { + location.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); + location.setTime(System.currentTimeMillis()); + locationManager.setTestProviderLocation(mockProviderName, location); + Log.i( + LOGTAG, + mockProviderName + + " is posting location, lat: " + + mockLatitude + + " lon: " + + mockLongitude + + " acc: " + + mockAccuracy); + } + }, + 0, + 3, + TimeUnit.SECONDS); + } + } + } + + Map mPorts = new HashMap<>(); + + private class MessageDelegate implements WebExtension.MessageDelegate, WebExtension.PortDelegate { + @Override + public void onConnect(final @NonNull WebExtension.Port port) { + // Sometimes we get a new onConnect call _before_ onDisconnect, so we might + // have to detach the port here before we attach to a new one + detach(mPorts.remove(port.sender.session)); + attach(port); + } + + private void attach(WebExtension.Port port) { + mPorts.put(port.sender.session, port); + port.setDelegate(mMessageDelegate); + } + + private void detach(WebExtension.Port port) { + // If there are pending messages for this port we need to resolve them with an exception + // otherwise the test will wait for them indefinitely. + for (final String id : mPendingResponses.get(port)) { + final EvalJSResult result = new EvalJSResult(); + result.exception = new PortDisconnectException(); + mPendingMessages.put(id, result); + } + mPendingResponses.remove(port); + } + + @Override + public void onPortMessage( + @NonNull final Object message, @NonNull final WebExtension.Port port) { + final JSONObject response = (JSONObject) message; + + final String id; + try { + id = response.getString("id"); + final EvalJSResult result = new EvalJSResult(); + + final Object exception = response.get("exception"); + if (exception != JSONObject.NULL) { + result.exception = exception; + } + + final Object value = response.get("response"); + if (value != JSONObject.NULL) { + result.value = value; + } + + mPendingMessages.put(id, result); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + } + + @Override + public void onDisconnect(final @NonNull WebExtension.Port port) { + detach(port); + // Sometimes the onDisconnect call comes _after_ the new onConnect so we need to check + // here whether this port is still in use. + if (mPorts.get(port.sender.session) == port) { + mPorts.remove(port.sender.session); + } + } + + public class PortDisconnectException extends RuntimeException { + public PortDisconnectException() { + super( + "The port disconnected before a message could be received." + + "Usually this happens when the page navigates away while " + + "waiting for a message."); + } + } + } + + private MessageDelegate mMessageDelegate = new MessageDelegate(); + + private static class EvalJSResult { + Object value; + Object exception; + } + + Map mPendingMessages = new HashMap<>(); + MultiMap mPendingResponses = new MultiMap<>(); + + public class ExtensionPromise { + private UUID mUuid; + private GeckoSession mSession; + + protected ExtensionPromise(final UUID uuid, final GeckoSession session, final String js) { + mUuid = uuid; + mSession = session; + evaluateJS(session, "this['" + uuid + "'] = " + js + "; true"); + } + + public Object getValue() { + return evaluateJS(mSession, "this['" + mUuid + "']"); + } + } + + public ExtensionPromise evaluatePromiseJS( + final @NonNull GeckoSession session, final @NonNull String js) { + return new ExtensionPromise(UUID.randomUUID(), session, js); + } + + public Object evaluateExtensionJS(final @NonNull String js) { + return webExtensionApiCall( + "Eval", + args -> { + args.put("code", js); + }); + } + + public Object evaluateJS(final @NonNull GeckoSession session, final @NonNull String js) { + // Let's make sure we have the port already + UiThreadUtils.waitForCondition(() -> mPorts.containsKey(session), mTimeoutMillis); + + final JSONObject message = new JSONObject(); + final String id = UUID.randomUUID().toString(); + try { + message.put("id", id); + message.put("eval", js); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + final WebExtension.Port port = mPorts.get(session); + port.postMessage(message); + + return waitForMessage(port, id); + } + + public int getSessionPid(final @NonNull GeckoSession session) { + final Double dblPid = (Double) webExtensionApiCall(session, "GetPidForTab", null); + return dblPid.intValue(); + } + + public String getProfilePath() { + return (String) webExtensionApiCall("GetProfilePath", null); + } + + public int[] getAllSessionPids() { + final JSONArray jsonPids = (JSONArray) webExtensionApiCall("GetAllBrowserPids", null); + final int[] pids = new int[jsonPids.length()]; + for (int i = 0; i < jsonPids.length(); i++) { + try { + pids[i] = jsonPids.getInt(i); + } catch (final JSONException e) { + throw new RuntimeException(e); + } + } + return pids; + } + + public void killContentProcess(final int pid) { + webExtensionApiCall( + "KillContentProcess", + args -> { + args.put("pid", pid); + }); + } + + public boolean getActive(final @NonNull GeckoSession session) { + final Boolean isActive = (Boolean) webExtensionApiCall(session, "GetActive", null); + return isActive; + } + + public void triggerCookieBannerDetected(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerDetected", null); + } + + public void triggerCookieBannerHandled(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerHandled", null); + } + + private Object waitForMessage(final WebExtension.Port port, final String id) { + mPendingResponses.add(port, id); + UiThreadUtils.waitForCondition(() -> mPendingMessages.containsKey(id), mTimeoutMillis); + mPendingResponses.remove(port); + + final EvalJSResult result = mPendingMessages.get(id); + mPendingMessages.remove(id); + + if (result.exception != null) { + throw new RejectedPromiseException(result.exception); + } + + if (result.value == null) { + return null; + } + + Object value; + try { + value = new JSONTokener((String) result.value).nextValue(); + } catch (final JSONException ex) { + value = result.value; + } + + if (value instanceof Integer) { + return ((Integer) value).doubleValue(); + } + return value; + } + + /** + * Initialize and keep track of the specified session within the test rule. The session is + * automatically cleaned up at the end of the test. + * + * @param session Session to keep track of. + * @return Same session + */ + public GeckoSession wrapSession(final GeckoSession session) { + try { + mSubSessions.add(session); + prepareSession(session); + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } + return session; + } + + private GeckoSession createSession(final GeckoSessionSettings settings, final boolean open) { + final GeckoSession session = wrapSession(new GeckoSession(settings)); + if (open) { + openSession(session); + } + return session; + } + + /** + * Create a new, opened session using the main session settings. + * + * @return New session. + */ + public GeckoSession createOpenSession() { + return createSession(mMainSession.getSettings(), /* open */ true); + } + + /** + * Create a new, opened session using the specified settings. + * + * @param settings Settings for the new session. + * @return New session. + */ + public GeckoSession createOpenSession(final GeckoSessionSettings settings) { + return createSession(settings, /* open */ true); + } + + /** + * Create a new, closed session using the specified settings. + * + * @return New session. + */ + public GeckoSession createClosedSession() { + return createSession(mMainSession.getSettings(), /* open */ false); + } + + /** + * Create a new, closed session using the specified settings. + * + * @param settings Settings for the new session. + * @return New session. + */ + public GeckoSession createClosedSession(final GeckoSessionSettings settings) { + return createSession(settings, /* open */ false); + } + + /** + * Return a value from the given array indexed by the current call counter. Only valid during a + * {@link #forCallbacksDuringWait}, {@link #delegateDuringNextWait}, or {@link + * #delegateUntilTestEnd} callback. + * + *

    + * + *

    Asserts that {@code foo} is equal to {@code "bar"} during the first call and {@code "baz"} + * during the second call: + * + *

    {@code assertThat("Foo should match", foo, equalTo(forEachCall("bar",
    +   * "baz")));}
    + * + * @param values Input array + * @return Value from input array indexed by the current call counter. + */ + @SafeVarargs + public final T forEachCall(final T... values) { + assertThat("Should be in a method call", mCurrentMethodCall, notNullValue()); + return values[Math.min(mCurrentMethodCall.getCurrentCount(), values.length) - 1]; + } + + /** + * Evaluate a JavaScript expression and return the result, similar to {@link #evaluateJS}. In + * addition, treat the evaluation as a wait event, which will affect other calls such as {@link + * #forCallbacksDuringWait}. If the result is a Promise, wait on the Promise to settle and return + * or throw based on the outcome. + * + * @param session Session containing the target page. + * @param js JavaScript expression. + * @return Result of the expression or value of the resolved Promise. + * @see #evaluateJS + */ + public @Nullable Object waitForJS(final @NonNull GeckoSession session, final @NonNull String js) { + try { + beforeWait(); + return evaluateJS(session, js); + } finally { + afterWait(mCallRecords.size()); + } + } + + /** + * Get a list of Gecko prefs. Undefined prefs will return as null. + * + * @param prefs List of pref names. + * @return Pref values as a list of values. + */ + public JSONArray getPrefs(final @NonNull String... prefs) { + return (JSONArray) + webExtensionApiCall( + "GetPrefs", + args -> { + args.put("prefs", new JSONArray(Arrays.asList(prefs))); + }); + } + + /** + * Gets the color of a link for a given selector. + * + * @param selector Selector that matches the link + * @return String representing the color, e.g. rgb(0, 0, 255) + */ + public String getLinkColor(final GeckoSession session, final String selector) { + return (String) + webExtensionApiCall( + session, + "GetLinkColor", + args -> { + args.put("selector", selector); + }); + } + + public List getRequestedLocales() { + try { + final JSONArray locales = (JSONArray) webExtensionApiCall("GetRequestedLocales", null); + final List result = new ArrayList<>(); + + for (int i = 0; i < locales.length(); i++) { + result.add(locales.getString(i)); + } + + return result; + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + } + + /** + * Adds value to the given histogram. + * + * @param id the histogram id to increment. + * @param value to add to the histogram. + */ + public void addHistogram(final String id, final long value) { + webExtensionApiCall( + "AddHistogram", + args -> { + args.put("id", id); + args.put("value", value); + }); + } + + /** Revokes all SSL overrides */ + public void removeAllCertOverrides() { + webExtensionApiCall("RemoveAllCertOverrides", null); + } + + private interface SetArgs { + void setArgs(JSONObject object) throws JSONException; + } + + /** + * Sets value to the given scalar. + * + * @param id the scalar to be set. + * @param value the value to set. + */ + public void setScalar(final String id, final T value) { + webExtensionApiCall( + "SetScalar", + args -> { + args.put("id", id); + args.put("value", value); + }); + } + + /** Invokes nsIDOMWindowUtils.setResolutionAndScaleTo. */ + public void setResolutionAndScaleTo(final GeckoSession session, final float resolution) { + webExtensionApiCall( + session, + "SetResolutionAndScaleTo", + args -> { + args.put("resolution", resolution); + }); + } + + /** Invokes nsIDOMWindowUtils.flushApzRepaints. */ + public void flushApzRepaints(final GeckoSession session) { + webExtensionApiCall(session, "FlushApzRepaints", null); + } + + /** Invokes a simplified version of promiseAllPaintsDone in paint_listener.js. */ + public void promiseAllPaintsDone(final GeckoSession session) { + webExtensionApiCall(session, "PromiseAllPaintsDone", null); + } + + /** Returns true if Gecko is using a GPU process. */ + public boolean usingGpuProcess() { + return (Boolean) webExtensionApiCall("UsingGpuProcess", null); + } + + /** Kills the GPU process cleanly with generating a crash report. */ + public void killGpuProcess() { + webExtensionApiCall("KillGpuProcess", null); + } + + /** Causes the GPU process to crash. */ + public void crashGpuProcess() { + webExtensionApiCall("CrashGpuProcess", null); + } + + /** Clears sites from the HSTS list. */ + public void clearHSTSState() { + webExtensionApiCall("ClearHSTSState", null); + } + + private Object webExtensionApiCall( + final @NonNull String apiName, final @NonNull SetArgs argsSetter) { + return webExtensionApiCall(null, apiName, argsSetter); + } + + private Object webExtensionApiCall( + final GeckoSession session, + final @NonNull String apiName, + final @NonNull SetArgs argsSetter) { + // Ensure background script is connected + UiThreadUtils.waitForCondition(() -> RuntimeCreator.backgroundPort() != null, mTimeoutMillis); + + if (session != null) { + // Ensure content script is connected + UiThreadUtils.waitForCondition(() -> mPorts.get(session) != null, mTimeoutMillis); + } + + final String id = UUID.randomUUID().toString(); + + final JSONObject message = new JSONObject(); + + try { + final JSONObject args = new JSONObject(); + if (argsSetter != null) { + argsSetter.setArgs(args); + } + + message.put("id", id); + message.put("type", apiName); + message.put("args", args); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + final WebExtension.Port port; + if (session == null) { + port = RuntimeCreator.backgroundPort(); + } else { + // We post the message using session's port instead of the background port. By routing + // the message through the extension's content script, we are able to obtain and attach + // the session's WebExtension tab as a `tab` argument to the API. + port = mPorts.get(session); + } + + port.postMessage(message); + return waitForMessage(port, id); + } + + /** + * Set a list of Gecko prefs for the rest of the test. Prefs set in {@link + * #setPrefsDuringNextWait} can temporarily take precedence over prefs set in {@code + * setPrefsUntilTestEnd}. + * + * @param prefs Map of pref names to values. + * @see #setPrefsDuringNextWait + */ + public void setPrefsUntilTestEnd(final @NonNull Map prefs) { + mTestScopeDelegates.setPrefs(prefs); + } + + /** + * Set a list of Gecko prefs during the next wait. Prefs set in {@code setPrefsDuringNextWait} can + * temporarily take precedence over prefs set in {@link #setPrefsUntilTestEnd}. + * + * @param prefs Map of pref names to values. + * @see #setPrefsUntilTestEnd + */ + public void setPrefsDuringNextWait(final @NonNull Map prefs) { + mWaitScopeDelegates.setPrefs(prefs); + } + + /** + * Register an external, non-GeckoSession delegate, and start recording the delegate calls until + * the end of the test. The delegate can then be used with methods such as {@link + * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. At the end of + * the test, the delegate is automatically unregistered. Delegates added by {@link + * #addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added by + * {@code delegateUntilTestEnd}. + * + * @param delegate Delegate instance to register. + * @param register DelegateRegistrar instance that represents a function to register the delegate. + * @param unregister DelegateRegistrar instance that represents a function to unregister the + * delegate. + * @param impl Default delegate implementation. Its methods may be annotated with {@link + * AssertCalled} annotations to assert expected behavior. + * @see #addExternalDelegateDuringNextWait + */ + public void addExternalDelegateUntilTestEnd( + @NonNull final Class delegate, + @NonNull final DelegateRegistrar register, + @NonNull final DelegateRegistrar unregister, + @NonNull final T impl) { + final ExternalDelegate externalDelegate = + mTestScopeDelegates.addExternalDelegate(delegate, register, unregister, impl); + + // Register if there is not a wait delegate to take precedence over this call. + if (!mWaitScopeDelegates.getExternalDelegates().contains(externalDelegate)) { + externalDelegate.register(); + } + } + + /** + * @see #addExternalDelegateUntilTestEnd(Class, DelegateRegistrar, DelegateRegistrar, Object) + */ + public void addExternalDelegateUntilTestEnd( + @NonNull final KClass delegate, + @NonNull final DelegateRegistrar register, + @NonNull final DelegateRegistrar unregister, + @NonNull final T impl) { + addExternalDelegateUntilTestEnd( + JvmClassMappingKt.getJavaClass(delegate), register, unregister, impl); + } + + /** + * Register an external, non-GeckoSession delegate, and start recording the delegate calls during + * the next wait. The delegate can then be used with methods such as {@link + * #waitUntilCalled(Class, String...)} and {@link #forCallbacksDuringWait(Object)}. After the next + * wait, the delegate is automatically unregistered. Delegates added by {@code + * addExternalDelegateDuringNextWait} can temporarily take precedence over delegates added by + * {@link #delegateUntilTestEnd}. + * + * @param delegate Delegate instance to register. + * @param register DelegateRegistrar instance that represents a function to register the delegate. + * @param unregister DelegateRegistrar instance that represents a function to unregister the + * delegate. + * @param impl Default delegate implementation. Its methods may be annotated with {@link + * AssertCalled} annotations to assert expected behavior. + * @see #addExternalDelegateDuringNextWait + */ + public void addExternalDelegateDuringNextWait( + @NonNull final Class delegate, + @NonNull final DelegateRegistrar register, + @NonNull final DelegateRegistrar unregister, + @NonNull final T impl) { + final ExternalDelegate externalDelegate = + mWaitScopeDelegates.addExternalDelegate(delegate, register, unregister, impl); + + // Always register because this call always takes precedence, but make sure to unregister + // any test-delegates first. + final int index = mTestScopeDelegates.getExternalDelegates().indexOf(externalDelegate); + if (index >= 0) { + mTestScopeDelegates.getExternalDelegates().get(index).unregister(); + } + externalDelegate.register(); + } + + /** + * @see #addExternalDelegateDuringNextWait(Class, DelegateRegistrar, DelegateRegistrar, Object) + */ + public void addExternalDelegateDuringNextWait( + @NonNull final KClass delegate, + @NonNull final DelegateRegistrar register, + @NonNull final DelegateRegistrar unregister, + @NonNull final T impl) { + addExternalDelegateDuringNextWait( + JvmClassMappingKt.getJavaClass(delegate), register, unregister, impl); + } + + /** + * This waits for the given result and returns it's value. If the result failed with an exception, + * it is rethrown. + * + * @param result A {@link GeckoResult} instance. + * @param The type of the value held by the {@link GeckoResult} + * @return The value of the completed {@link GeckoResult}. + */ + public T waitForResult(@NonNull final GeckoResult result) throws Throwable { + return waitForResult(result, mTimeoutMillis); + } + + /** + * This is similar to waitForResult with specific timeout. + * + * @param result A {@link GeckoResult} instance. + * @param timeout timeout in milliseconds + * @param The type of the value held by the {@link GeckoResult} + * @return The value of the completed {@link GeckoResult}. + */ + private T waitForResult(@NonNull final GeckoResult result, final long timeout) + throws Throwable { + beforeWait(); + try { + return UiThreadUtils.waitForResult(result, timeout); + } catch (final Throwable e) { + throw unwrapRuntimeException(e); + } finally { + afterWait(mCallRecords.size()); + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java new file mode 100644 index 0000000000..b496ae41fa --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.rule; + +/** Exception thrown when an error occurs in the test harness itself and not in a specific test */ +public class TestHarnessException extends RuntimeException { + public TestHarnessException(final Throwable cause) { + super(cause); + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java new file mode 100644 index 0000000000..b2ed9df4d5 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.util; + +import android.os.Build; +import android.os.Bundle; +import android.os.Debug; +import androidx.test.platform.app.InstrumentationRegistry; +import org.mozilla.geckoview.BuildConfig; + +public class Environment { + public static final long DEFAULT_TIMEOUT_MILLIS = 30000; + public static final long DEFAULT_ARM_DEVICE_TIMEOUT_MILLIS = 30000; + public static final long DEFAULT_ARM_EMULATOR_TIMEOUT_MILLIS = 120000; + public static final long DEFAULT_X86_DEVICE_TIMEOUT_MILLIS = 30000; + public static final long DEFAULT_X86_EMULATOR_TIMEOUT_MILLIS = 30000; + public static final long DEFAULT_IDE_DEBUG_TIMEOUT_MILLIS = 86400000; + + private String getEnvVar(final String name) { + final int nameLen = name.length(); + final Bundle args = InstrumentationRegistry.getArguments(); + String env = args.getString("env0", null); + for (int i = 1; env != null; i++) { + if (env.length() >= nameLen + 1 && env.startsWith(name) && env.charAt(nameLen) == '=') { + return env.substring(nameLen + 1); + } + env = args.getString("env" + i, null); + } + return ""; + } + + public boolean isAutomation() { + return !getEnvVar("MOZ_IN_AUTOMATION").isEmpty(); + } + + public boolean shouldShutdownOnCrash() { + return !getEnvVar("MOZ_CRASHREPORTER_SHUTDOWN").isEmpty(); + } + + public boolean isDebugging() { + return Debug.isDebuggerConnected(); + } + + public boolean isEmulator() { + return "generic".equals(Build.DEVICE) || Build.DEVICE.startsWith("generic_"); + } + + public boolean isDebugBuild() { + return BuildConfig.DEBUG_BUILD; + } + + public boolean isX86() { + final String abi; + if (Build.VERSION.SDK_INT >= 21) { + abi = Build.SUPPORTED_ABIS[0]; + } else { + abi = Build.CPU_ABI; + } + + return abi.startsWith("x86"); + } + + public boolean isFission() { + // NOTE: This isn't accurate, as it doesn't take into account the default + // value of the pref or environment variables like + // `MOZ_FORCE_DISABLE_FISSION`. + return getEnvVar("MOZ_FORCE_ENABLE_FISSION").equals("1"); + } + + public boolean isWebrender() { + return getEnvVar("MOZ_WEBRENDER").equals("1"); + } + + public boolean isIsolatedProcess() { + return BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS; + } + + public long getScaledTimeoutMillis() { + if (isX86()) { + return isEmulator() ? DEFAULT_X86_EMULATOR_TIMEOUT_MILLIS : DEFAULT_X86_DEVICE_TIMEOUT_MILLIS; + } + return isEmulator() ? DEFAULT_ARM_EMULATOR_TIMEOUT_MILLIS : DEFAULT_ARM_DEVICE_TIMEOUT_MILLIS; + } + + public long getDefaultTimeoutMillis() { + return isDebugging() ? DEFAULT_IDE_DEBUG_TIMEOUT_MILLIS : getScaledTimeoutMillis(); + } + + public boolean isNightly() { + return BuildConfig.NIGHTLY_BUILD; + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java new file mode 100644 index 0000000000..5431719bc9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java @@ -0,0 +1,175 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.util; + +import static org.mozilla.geckoview.ContentBlocking.SafeBrowsingProvider; + +import android.os.Process; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.test.platform.app.InstrumentationRegistry; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.RuntimeTelemetry; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.test.TestCrashHandler; + +public class RuntimeCreator { + public static final int TEST_SUPPORT_INITIAL = 0; + public static final int TEST_SUPPORT_OK = 1; + public static final int TEST_SUPPORT_ERROR = 2; + public static final String TEST_SUPPORT_EXTENSION_ID = "test-support@tests.mozilla.org"; + private static final String LOGTAG = "RuntimeCreator"; + + private static final Environment env = new Environment(); + private static GeckoRuntime sRuntime; + public static AtomicInteger sTestSupport = new AtomicInteger(0); + public static WebExtension sTestSupportExtension; + + // The RuntimeTelemetry.Delegate can only be set when creating the RuntimeCreator, to + // let tests set their own Delegate we need to create a proxy here. + public static class RuntimeTelemetryDelegate implements RuntimeTelemetry.Delegate { + public RuntimeTelemetry.Delegate delegate = null; + + @Override + public void onHistogram(@NonNull final RuntimeTelemetry.Histogram metric) { + if (delegate != null) { + delegate.onHistogram(metric); + } + } + + @Override + public void onBooleanScalar(@NonNull final RuntimeTelemetry.Metric metric) { + if (delegate != null) { + delegate.onBooleanScalar(metric); + } + } + + @Override + public void onStringScalar(@NonNull final RuntimeTelemetry.Metric metric) { + if (delegate != null) { + delegate.onStringScalar(metric); + } + } + + @Override + public void onLongScalar(@NonNull final RuntimeTelemetry.Metric metric) { + if (delegate != null) { + delegate.onLongScalar(metric); + } + } + } + + public static final RuntimeTelemetryDelegate sRuntimeTelemetryProxy = + new RuntimeTelemetryDelegate(); + + private static WebExtension.Port sBackgroundPort; + + private static WebExtension.PortDelegate sPortDelegate; + + private static WebExtension.MessageDelegate sMessageDelegate = + new WebExtension.MessageDelegate() { + @Nullable + @Override + public void onConnect(@NonNull final WebExtension.Port port) { + sBackgroundPort = port; + port.setDelegate(sWrapperPortDelegate); + } + }; + + private static WebExtension.PortDelegate sWrapperPortDelegate = + new WebExtension.PortDelegate() { + @Override + public void onPortMessage( + @NonNull final Object message, @NonNull final WebExtension.Port port) { + if (sPortDelegate != null) { + sPortDelegate.onPortMessage(message, port); + } + } + }; + + public static WebExtension.Port backgroundPort() { + return sBackgroundPort; + } + + public static void registerTestSupport() { + sTestSupport.set(0); + + sRuntime + .getWebExtensionController() + .installBuiltIn("resource://android/assets/web_extensions/test-support/") + .accept( + extension -> { + extension.setMessageDelegate(sMessageDelegate, "browser"); + sTestSupportExtension = extension; + sTestSupport.set(TEST_SUPPORT_OK); + }, + exception -> { + Log.e(LOGTAG, "Could not register TestSupport", exception); + sTestSupport.set(TEST_SUPPORT_ERROR); + }); + } + + /** + * Set the {@link RuntimeTelemetry.Delegate} instance for this test. Application code can only + * register this delegate when the {@link GeckoRuntime} is created, so we need to proxy it for + * test code. + * + * @param delegate the {@link RuntimeTelemetry.Delegate} for this test run. + */ + public static void setTelemetryDelegate(final RuntimeTelemetry.Delegate delegate) { + sRuntimeTelemetryProxy.delegate = delegate; + } + + public static void setPortDelegate(final WebExtension.PortDelegate portDelegate) { + sPortDelegate = portDelegate; + } + + @UiThread + public static GeckoRuntime getRuntime() { + if (sRuntime != null) { + return sRuntime; + } + + final SafeBrowsingProvider googleLegacy = + SafeBrowsingProvider.from(ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER) + .getHashUrl("http://mochi.test:8888/safebrowsing-dummy/gethash") + .updateUrl("http://mochi.test:8888/safebrowsing-dummy/update") + .build(); + + final SafeBrowsingProvider google = + SafeBrowsingProvider.from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER) + .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash") + .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update") + .build(); + + final GeckoRuntimeSettings runtimeSettings = + new GeckoRuntimeSettings.Builder() + .contentBlocking( + new ContentBlocking.Settings.Builder() + .safeBrowsingProviders(googleLegacy, google) + .build()) + .arguments(new String[] {"-purgecaches"}) + .extras(InstrumentationRegistry.getArguments()) + .remoteDebuggingEnabled(true) + .consoleOutput(true) + .crashHandler(TestCrashHandler.class) + .telemetryDelegate(sRuntimeTelemetryProxy) + .build(); + + sRuntime = + GeckoRuntime.create( + InstrumentationRegistry.getInstrumentation().getTargetContext(), runtimeSettings); + + registerTestSupport(); + + sRuntime.setDelegate(() -> Process.killProcess(Process.myPid())); + + return sRuntime; + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt new file mode 100644 index 0000000000..b842a58c2f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt @@ -0,0 +1,167 @@ +package org.mozilla.geckoview.test.util + +import android.content.Context +import android.content.res.AssetManager +import android.os.SystemClock +import android.webkit.MimeTypeMap +import com.koushikdutta.async.ByteBufferList +import com.koushikdutta.async.http.server.AsyncHttpServer +import com.koushikdutta.async.http.server.AsyncHttpServerRequest +import com.koushikdutta.async.http.server.AsyncHttpServerResponse +import com.koushikdutta.async.util.TaggedList +import org.json.JSONObject +import java.io.FileNotFoundException +import java.math.BigInteger +import java.security.MessageDigest +import java.util.* // ktlint-disable no-wildcard-imports + +class TestServer { + private val server = AsyncHttpServer() + private val assets: AssetManager + private val stallingResponses = Vector() + + constructor(context: Context) { + assets = context.resources.assets + + val anything = { request: AsyncHttpServerRequest, response: AsyncHttpServerResponse -> + val obj = JSONObject() + + obj.put("method", request.method) + + val headers = JSONObject() + for (key in request.headers.multiMap.keys) { + val values = request.headers.multiMap.get(key) as TaggedList + headers.put(values.tag(), values.joinToString(", ")) + } + + obj.put("headers", headers) + + if (request.method == "POST") { + obj.put("data", request.getBody()) + } + + response.send(obj) + } + + server.post("/anything", anything) + server.get("/anything", anything) + + server.get("/assets/.*") { request, response -> + try { + val mimeType = MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(request.path)) + val name = request.path.substring("/assets/".count()) + val asset = assets.open(name).readBytes() + + response.send(mimeType, asset) + } catch (e: FileNotFoundException) { + response.code(404) + response.end() + } + } + + server.get("/status/.*") { request, response -> + val statusCode = request.path.substring("/status/".count()).toInt() + response.code(statusCode) + response.end() + } + + server.get("/redirect-to.*") { request, response -> + response.redirect(request.query.getString("url")) + } + + server.get("/redirect/.*") { request, response -> + val count = request.path.split('/').last().toInt() - 1 + if (count > 0) { + response.redirect("/redirect/$count") + } + + response.end() + } + + server.get("/basic-auth/.*") { _, response -> + response.code(401) + response.headers.set("WWW-Authenticate", "Basic realm=\"Fake Realm\"") + response.end() + } + + server.get("/cookies") { request, response -> + val cookiesObj = JSONObject() + + request.headers.get("cookie")?.split(";")?.forEach { + val parts = it.trim().split('=') + cookiesObj.put(parts[0], parts[1]) + } + + val obj = JSONObject() + obj.put("cookies", cookiesObj) + response.send(obj) + } + + server.get("/cookies/set/.*") { request, response -> + val parts = request.path.substring("/cookies/set/".count()).split('/') + + response.headers.set("Set-Cookie", "${parts[0]}=${parts[1]}; Path=/") + response.headers.set("Location", "/cookies") + response.code(302) + response.end() + } + + server.get("/bytes/.*") { request, response -> + val count = request.path.split("/").last().toInt() + val random = Random(System.currentTimeMillis()) + val payload = ByteArray(count) + random.nextBytes(payload) + + val digest = MessageDigest.getInstance("SHA-256").digest(payload) + response.headers.set("X-SHA-256", String.format("%064x", BigInteger(1, digest))) + response.send("application/octet-stream", payload) + } + + server.get("/trickle/.*") { request, response -> + val count = request.path.split("/").last().toInt() + + response.setContentType("application/octet-stream") + response.headers.set("Content-Length", "$count") + response.writeHead() + + val payload = byteArrayOf(1) + for (i in 1..count) { + response.write(ByteBufferList(payload)) + SystemClock.sleep(250) + } + + response.end() + } + + server.get("/stall/.*") { _, response -> + // keep trickling data for a long time (until we are stopped) + stallingResponses.add(response) + + val count = 100 + response.setContentType("InstallException") + response.headers.set("Content-Length", "$count") + response.writeHead() + + val payload = byteArrayOf(1) + for (i in 1..count - 1) { + response.write(ByteBufferList(payload)) + SystemClock.sleep(250) + } + + stallingResponses.remove(response) + response.end() + } + } + + fun start(port: Int) { + server.listen(port) + } + + fun stop() { + for (response in stallingResponses) { + response.end() + } + server.stop() + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java new file mode 100644 index 0000000000..f5aee4db3b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test.util; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import androidx.annotation.NonNull; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicBoolean; +import org.mozilla.geckoview.GeckoResult; + +public class UiThreadUtils { + private static Method sGetNextMessage = null; + + static { + try { + sGetNextMessage = MessageQueue.class.getDeclaredMethod("next"); + sGetNextMessage.setAccessible(true); + } catch (final NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + public static class TimeoutException extends RuntimeException { + public TimeoutException(final String detailMessage) { + super(detailMessage); + } + } + + private static final class TimeoutRunnable implements Runnable { + private long timeout; + + public void set(final long timeout) { + this.timeout = timeout; + cancel(); + HANDLER.postDelayed(this, timeout); + } + + public void cancel() { + HANDLER.removeCallbacks(this); + } + + @Override + public void run() { + throw new TimeoutException("Timed out after " + timeout + "ms"); + } + } + + public static final Handler HANDLER = new Handler(Looper.getMainLooper()); + private static final TimeoutRunnable TIMEOUT_RUNNABLE = new TimeoutRunnable(); + + private static RuntimeException unwrapRuntimeException(final Throwable e) { + final Throwable cause = e.getCause(); + if (cause != null && cause instanceof RuntimeException) { + return (RuntimeException) cause; + } else if (e instanceof RuntimeException) { + return (RuntimeException) e; + } + + return new RuntimeException(cause != null ? cause : e); + } + + /** + * This waits for the given result and returns it's value. If the result failed with an exception, + * it is rethrown. + * + * @param result A {@link GeckoResult} instance. + * @param The type of the value held by the {@link GeckoResult} + * @return The value of the completed {@link GeckoResult}. + */ + public static T waitForResult(@NonNull final GeckoResult result, final long timeout) + throws Throwable { + final ResultHolder holder = new ResultHolder<>(result); + + waitForCondition(() -> holder.isComplete, timeout); + + if (holder.error != null) { + throw holder.error; + } + + return holder.value; + } + + private static class ResultHolder { + public T value; + public Throwable error; + public boolean isComplete; + + public ResultHolder(final GeckoResult result) { + result.accept( + value -> { + ResultHolder.this.value = value; + isComplete = true; + }, + error -> { + ResultHolder.this.error = error; + isComplete = true; + }); + } + } + + public interface Condition { + boolean test(); + } + + public static void loopUntilIdle(final long timeout) { + final AtomicBoolean idle = new AtomicBoolean(false); + + MessageQueue.IdleHandler handler = null; + try { + handler = + () -> { + idle.set(true); + // Remove handler + return false; + }; + + HANDLER.getLooper().getQueue().addIdleHandler(handler); + + waitForCondition(() -> idle.get(), timeout); + } finally { + if (handler != null) { + HANDLER.getLooper().getQueue().removeIdleHandler(handler); + } + } + } + + public static void waitForCondition(final Condition condition, final long timeout) { + // Adapted from GeckoThread.pumpMessageLoop. + final MessageQueue queue = HANDLER.getLooper().getQueue(); + + TIMEOUT_RUNNABLE.set(timeout); + + MessageQueue.IdleHandler handler = null; + try { + handler = + () -> { + HANDLER.postDelayed(() -> {}, 100); + return true; + }; + + HANDLER.getLooper().getQueue().addIdleHandler(handler); + while (!condition.test()) { + final Message msg; + try { + msg = (Message) sGetNextMessage.invoke(queue); + } catch (final IllegalAccessException | InvocationTargetException e) { + throw unwrapRuntimeException(e); + } + if (msg.getTarget() == null) { + HANDLER.getLooper().quit(); + return; + } + msg.getTarget().dispatchMessage(msg); + } + } finally { + TIMEOUT_RUNNABLE.cancel(); + if (handler != null) { + HANDLER.getLooper().getQueue().removeIdleHandler(handler); + } + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png new file mode 100644 index 0000000000..c9a2788e53 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png differ diff --git a/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png new file mode 100644 index 0000000000..da4eba73b3 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png differ diff --git a/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png new file mode 100644 index 0000000000..c402e73bb6 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png differ diff --git a/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png new file mode 100644 index 0000000000..eda5c5ebf0 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png differ diff --git a/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png new file mode 100644 index 0000000000..0ce5a631c4 Binary files /dev/null and b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png differ diff --git a/mobile/android/geckoview/src/androidTest/res/values/colors.xml b/mobile/android/geckoview/src/androidTest/res/values/colors.xml new file mode 100644 index 0000000000..3a96673022 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/values/colors.xml @@ -0,0 +1,9 @@ + + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/mobile/android/geckoview/src/androidTest/res/values/strings.xml b/mobile/android/geckoview/src/androidTest/res/values/strings.xml new file mode 100644 index 0000000000..7831a536eb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/values/strings.xml @@ -0,0 +1,7 @@ + + + + GeckoView Test Runner + diff --git a/mobile/android/geckoview/src/androidTest/res/values/styles.xml b/mobile/android/geckoview/src/androidTest/res/values/styles.xml new file mode 100644 index 0000000000..60abe4bf63 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/mobile/android/geckoview/src/asan/resources/lib/arm64-v8a/wrap.sh b/mobile/android/geckoview/src/asan/resources/lib/arm64-v8a/wrap.sh new file mode 100644 index 0000000000..65a466973b --- /dev/null +++ b/mobile/android/geckoview/src/asan/resources/lib/arm64-v8a/wrap.sh @@ -0,0 +1,52 @@ +#!/system/bin/sh +# shellcheck shell=ksh + +# call getprop before setting LD_PRELOAD +os_version=$(getprop ro.build.version.sdk) + +# These options mirror those in mozglue/build/AsanOptions.cpp +# except for fast_unwind_* which are only needed on Android +options=( + allow_user_segv_handler=1 + alloc_dealloc_mismatch=0 + detect_leaks=0 + fast_unwind_on_check=1 + fast_unwind_on_fatal=1 + max_free_fill_size=268435456 + max_malloc_fill_size=268435456 + malloc_fill_byte=228 + free_fill_byte=229 + handle_sigill=1 + allocator_may_return_null=1 +) +if [ -e "/data/local/tmp/asan.options.gecko" ]; then + options+=("$(tr -d '\n' < /data/local/tmp/asan.options.gecko)") +fi + +# : is the usual separator for ASAN options +# save and reset IFS so it doesn't interfere with later commands +old_ifs="$IFS" +IFS=: +ASAN_OPTIONS="${options[*]}" +export ASAN_OPTIONS +IFS="$old_ifs" + +LIB_PATH="$(cd "$(dirname "$0")" && pwd)" +LD_PRELOAD="$(ls "$LIB_PATH"/libclang_rt.asan-*-android.so)" +export LD_PRELOAD + +cmd="$1" +shift + +# enable debugging +# https://developer.android.com/ndk/guides/wrap-script#debugging_when_using_wrapsh +# note that wrap.sh is not supported before android 8.1 (API 27) +if [ "$os_version" -eq "27" ]; then + args=("-Xrunjdwp:transport=dt_android_adb,suspend=n,server=y" -Xcompiler-option --debuggable) +elif [ "$os_version" -eq "28" ]; then + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y" -Xcompiler-option --debuggable) +else + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y") +fi + +exec "$cmd" "${args[@]}" "$@" diff --git a/mobile/android/geckoview/src/asan/resources/lib/armeabi-v7a/wrap.sh b/mobile/android/geckoview/src/asan/resources/lib/armeabi-v7a/wrap.sh new file mode 100644 index 0000000000..65a466973b --- /dev/null +++ b/mobile/android/geckoview/src/asan/resources/lib/armeabi-v7a/wrap.sh @@ -0,0 +1,52 @@ +#!/system/bin/sh +# shellcheck shell=ksh + +# call getprop before setting LD_PRELOAD +os_version=$(getprop ro.build.version.sdk) + +# These options mirror those in mozglue/build/AsanOptions.cpp +# except for fast_unwind_* which are only needed on Android +options=( + allow_user_segv_handler=1 + alloc_dealloc_mismatch=0 + detect_leaks=0 + fast_unwind_on_check=1 + fast_unwind_on_fatal=1 + max_free_fill_size=268435456 + max_malloc_fill_size=268435456 + malloc_fill_byte=228 + free_fill_byte=229 + handle_sigill=1 + allocator_may_return_null=1 +) +if [ -e "/data/local/tmp/asan.options.gecko" ]; then + options+=("$(tr -d '\n' < /data/local/tmp/asan.options.gecko)") +fi + +# : is the usual separator for ASAN options +# save and reset IFS so it doesn't interfere with later commands +old_ifs="$IFS" +IFS=: +ASAN_OPTIONS="${options[*]}" +export ASAN_OPTIONS +IFS="$old_ifs" + +LIB_PATH="$(cd "$(dirname "$0")" && pwd)" +LD_PRELOAD="$(ls "$LIB_PATH"/libclang_rt.asan-*-android.so)" +export LD_PRELOAD + +cmd="$1" +shift + +# enable debugging +# https://developer.android.com/ndk/guides/wrap-script#debugging_when_using_wrapsh +# note that wrap.sh is not supported before android 8.1 (API 27) +if [ "$os_version" -eq "27" ]; then + args=("-Xrunjdwp:transport=dt_android_adb,suspend=n,server=y" -Xcompiler-option --debuggable) +elif [ "$os_version" -eq "28" ]; then + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y" -Xcompiler-option --debuggable) +else + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y") +fi + +exec "$cmd" "${args[@]}" "$@" diff --git a/mobile/android/geckoview/src/asan/resources/lib/x86/wrap.sh b/mobile/android/geckoview/src/asan/resources/lib/x86/wrap.sh new file mode 100644 index 0000000000..65a466973b --- /dev/null +++ b/mobile/android/geckoview/src/asan/resources/lib/x86/wrap.sh @@ -0,0 +1,52 @@ +#!/system/bin/sh +# shellcheck shell=ksh + +# call getprop before setting LD_PRELOAD +os_version=$(getprop ro.build.version.sdk) + +# These options mirror those in mozglue/build/AsanOptions.cpp +# except for fast_unwind_* which are only needed on Android +options=( + allow_user_segv_handler=1 + alloc_dealloc_mismatch=0 + detect_leaks=0 + fast_unwind_on_check=1 + fast_unwind_on_fatal=1 + max_free_fill_size=268435456 + max_malloc_fill_size=268435456 + malloc_fill_byte=228 + free_fill_byte=229 + handle_sigill=1 + allocator_may_return_null=1 +) +if [ -e "/data/local/tmp/asan.options.gecko" ]; then + options+=("$(tr -d '\n' < /data/local/tmp/asan.options.gecko)") +fi + +# : is the usual separator for ASAN options +# save and reset IFS so it doesn't interfere with later commands +old_ifs="$IFS" +IFS=: +ASAN_OPTIONS="${options[*]}" +export ASAN_OPTIONS +IFS="$old_ifs" + +LIB_PATH="$(cd "$(dirname "$0")" && pwd)" +LD_PRELOAD="$(ls "$LIB_PATH"/libclang_rt.asan-*-android.so)" +export LD_PRELOAD + +cmd="$1" +shift + +# enable debugging +# https://developer.android.com/ndk/guides/wrap-script#debugging_when_using_wrapsh +# note that wrap.sh is not supported before android 8.1 (API 27) +if [ "$os_version" -eq "27" ]; then + args=("-Xrunjdwp:transport=dt_android_adb,suspend=n,server=y" -Xcompiler-option --debuggable) +elif [ "$os_version" -eq "28" ]; then + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y" -Xcompiler-option --debuggable) +else + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y") +fi + +exec "$cmd" "${args[@]}" "$@" diff --git a/mobile/android/geckoview/src/asan/resources/lib/x86_64/wrap.sh b/mobile/android/geckoview/src/asan/resources/lib/x86_64/wrap.sh new file mode 100644 index 0000000000..65a466973b --- /dev/null +++ b/mobile/android/geckoview/src/asan/resources/lib/x86_64/wrap.sh @@ -0,0 +1,52 @@ +#!/system/bin/sh +# shellcheck shell=ksh + +# call getprop before setting LD_PRELOAD +os_version=$(getprop ro.build.version.sdk) + +# These options mirror those in mozglue/build/AsanOptions.cpp +# except for fast_unwind_* which are only needed on Android +options=( + allow_user_segv_handler=1 + alloc_dealloc_mismatch=0 + detect_leaks=0 + fast_unwind_on_check=1 + fast_unwind_on_fatal=1 + max_free_fill_size=268435456 + max_malloc_fill_size=268435456 + malloc_fill_byte=228 + free_fill_byte=229 + handle_sigill=1 + allocator_may_return_null=1 +) +if [ -e "/data/local/tmp/asan.options.gecko" ]; then + options+=("$(tr -d '\n' < /data/local/tmp/asan.options.gecko)") +fi + +# : is the usual separator for ASAN options +# save and reset IFS so it doesn't interfere with later commands +old_ifs="$IFS" +IFS=: +ASAN_OPTIONS="${options[*]}" +export ASAN_OPTIONS +IFS="$old_ifs" + +LIB_PATH="$(cd "$(dirname "$0")" && pwd)" +LD_PRELOAD="$(ls "$LIB_PATH"/libclang_rt.asan-*-android.so)" +export LD_PRELOAD + +cmd="$1" +shift + +# enable debugging +# https://developer.android.com/ndk/guides/wrap-script#debugging_when_using_wrapsh +# note that wrap.sh is not supported before android 8.1 (API 27) +if [ "$os_version" -eq "27" ]; then + args=("-Xrunjdwp:transport=dt_android_adb,suspend=n,server=y" -Xcompiler-option --debuggable) +elif [ "$os_version" -eq "28" ]; then + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y" -Xcompiler-option --debuggable) +else + args=(-XjdwpProvider:adbconnection "-XjdwpOptions:suspend=n,server=y") +fi + +exec "$cmd" "${args[@]}" "$@" diff --git a/mobile/android/geckoview/src/main/AndroidManifest.xml b/mobile/android/geckoview/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..aa3fe978ec --- /dev/null +++ b/mobile/android/geckoview/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + {% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %} + + + {% endfor %} + + 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..2f40a9ae9a --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl @@ -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; + +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(); + + // Insert requested image. + void onImeInsertImage(in byte[] data, in String mimeType); +} 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..8b0ec3dbb6 --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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, boolean causedOnlyByComposition); + + // Notify a change in editor text. + void onTextChange(IBinder token, in CharSequence text, + int start, int unboundedOldEnd, + boolean causedOnlyByComposition); + + // 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, in RectF caretRect); +} 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/ICompositorSurfaceManager.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ICompositorSurfaceManager.aidl new file mode 100644 index 0000000000..f8d399d121 --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ICompositorSurfaceManager.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.gfx; + +import android.view.Surface; + +interface ICompositorSurfaceManager { + void onSurfaceChanged(int widgetId, in Surface surface); +} 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..e9d63c379c --- /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 long handle); + void configureSync(in SyncConfig config); + void sync(in long 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..407ffd7ba9 --- /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(inout 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..0ae346e3b8 --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.gfx.ICompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.process.IProcessManager; + +import android.os.Bundle; +import android.os.ParcelFileDescriptor; + +interface IChildProcess { + /** The process started correctly. */ + const int STARTED_OK = 0; + /** An error occurred when trying to start this process. */ + const int STARTED_FAIL = 1; + /** This process is being used elsewhere and cannot start. */ + const int STARTED_BUSY = 2; + + int getPid(); + int start(in IProcessManager procMan, + in String mainProcessId, + in String[] args, + in Bundle extras, + int flags, + in String userSerialNumber, + in String crashHandlerService, + in ParcelFileDescriptor prefsPfd, + in ParcelFileDescriptor prefMapPfd, + in ParcelFileDescriptor ipcPfd, + in ParcelFileDescriptor crashReporterPfd, + in ParcelFileDescriptor crashAnnotationPfd); + + void crash(); + + /** Must only be called for a GPU child process type. */ + ICompositorSurfaceManager getCompositorSurfaceManager(); + + /** + * Returns the interface that other processes should use to allocate Surfaces to be + * consumed by the GPU process. Must only be called for a GPU child process type. + * @param allocatorId A unique ID used to identify the GPU process instance the allocator + * belongs to. + */ + ISurfaceAllocator getSurfaceAllocator(int allocatorId); +} 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..b75f317124 --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl @@ -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.process; + +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.gfx.ISurfaceAllocator; + +interface IProcessManager { + void getEditableParent(in IGeckoEditableChild child, long contentId, long tabId); + // Returns the interface that child processes should use to allocate Surfaces. + ISurfaceAllocator getSurfaceAllocator(); +} 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..44aa7bc461 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java @@ -0,0 +1,415 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.hardware.input.InputManager; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Timer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +public class AndroidGamepadManager { + // This is completely arbitrary. + private static final float TRIGGER_PRESSED_THRESHOLD = 0.25f; + private static final long POLL_TIMER_PERIOD = 1000; // milliseconds + + private static enum Axis { + X(MotionEvent.AXIS_X), + Y(MotionEvent.AXIS_Y), + Z(MotionEvent.AXIS_Z), + RZ(MotionEvent.AXIS_RZ); + + public final int axis; + + private Axis(final int axis) { + this.axis = axis; + } + } + + // A list of gamepad button mappings. Axes are determined at + // runtime, as they vary by Android version. + private static enum Trigger { + Left(6), + Right(7); + + public final int button; + + private Trigger(final int button) { + this.button = button; + } + } + + private static final int FIRST_DPAD_BUTTON = 12; + + // A list of axis number, gamepad button mappings for negative, positive. + // Button mappings are added to FIRST_DPAD_BUTTON. + private static enum DpadAxis { + UpDown(MotionEvent.AXIS_HAT_Y, 0, 1), + LeftRight(MotionEvent.AXIS_HAT_X, 2, 3); + + public final int axis; + public final int negativeButton; + public final int positiveButton; + + private DpadAxis(final int axis, final int negativeButton, final int positiveButton) { + this.axis = axis; + this.negativeButton = negativeButton; + this.positiveButton = positiveButton; + } + } + + private static enum Button { + A(KeyEvent.KEYCODE_BUTTON_A), + B(KeyEvent.KEYCODE_BUTTON_B), + X(KeyEvent.KEYCODE_BUTTON_X), + Y(KeyEvent.KEYCODE_BUTTON_Y), + L1(KeyEvent.KEYCODE_BUTTON_L1), + R1(KeyEvent.KEYCODE_BUTTON_R1), + L2(KeyEvent.KEYCODE_BUTTON_L2), + R2(KeyEvent.KEYCODE_BUTTON_R2), + SELECT(KeyEvent.KEYCODE_BUTTON_SELECT), + START(KeyEvent.KEYCODE_BUTTON_START), + THUMBL(KeyEvent.KEYCODE_BUTTON_THUMBL), + THUMBR(KeyEvent.KEYCODE_BUTTON_THUMBR), + DPAD_UP(KeyEvent.KEYCODE_DPAD_UP), + DPAD_DOWN(KeyEvent.KEYCODE_DPAD_DOWN), + DPAD_LEFT(KeyEvent.KEYCODE_DPAD_LEFT), + DPAD_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT); + + public final int button; + + private Button(final int button) { + this.button = button; + } + } + + private static class Gamepad { + // ID from GamepadService + public byte[] handle; + // Retain axis state so we can determine changes. + public float axes[]; + public boolean dpad[]; + public int triggerAxes[]; + public float triggers[]; + + public Gamepad(final byte[] handle, final int deviceId) { + this.handle = handle; + axes = new float[Axis.values().length]; + dpad = new boolean[4]; + triggers = new float[2]; + + final InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + // LTRIGGER/RTRIGGER don't seem to be exposed on older + // versions of Android. + if (device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null + && device.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null) { + triggerAxes = new int[] {MotionEvent.AXIS_LTRIGGER, MotionEvent.AXIS_RTRIGGER}; + } else if (device.getMotionRange(MotionEvent.AXIS_BRAKE) != null + && device.getMotionRange(MotionEvent.AXIS_GAS) != null) { + triggerAxes = new int[] {MotionEvent.AXIS_BRAKE, MotionEvent.AXIS_GAS}; + } else { + triggerAxes = null; + } + } + } + } + + @WrapForJNI(calledFrom = "ui") + private static native byte[] nativeAddGamepad(); + + @WrapForJNI(calledFrom = "ui") + private static native void nativeRemoveGamepad(byte[] aGamepadHandle); + + @WrapForJNI(calledFrom = "ui") + private static native void onButtonChange( + byte[] aGamepadHandle, int aButton, boolean aPressed, float aValue); + + @WrapForJNI(calledFrom = "ui") + private static native void onAxisChange(byte[] aGamepadHandle, boolean[] aValid, float[] aValues); + + private static boolean sStarted; + private static final SparseArray sGamepads = new SparseArray<>(); + private static final SparseArray> 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 pending = sPendingGamepads.get(deviceId); + if (pending == null) { + removeGamepad(deviceId); + return; + } + + sPendingGamepads.remove(deviceId); + sGamepads.put(deviceId, new Gamepad(gamepadHandle, deviceId)); + // Handle queued KeyEvents + for (final KeyEvent ev : pending) { + handleKeyEvent(ev); + } + } + + private static float sDeadZoneThresholdOverride = 1e-2f; + + private static boolean isValueInDeadZone(final MotionEvent event, final int axis) { + final float threshold; + if (sDeadZoneThresholdOverride >= 0) { + threshold = sDeadZoneThresholdOverride; + } else { + final InputDevice.MotionRange range = event.getDevice().getMotionRange(axis); + threshold = range.getFlat() + range.getFuzz(); + } + final float value = event.getAxisValue(axis); + return (Math.abs(value) < threshold); + } + + private static float deadZone(final MotionEvent ev, final int axis) { + if (isValueInDeadZone(ev, axis)) { + return 0.0f; + } + return ev.getAxisValue(axis); + } + + private static void mapDpadAxis( + final Gamepad gamepad, final boolean pressed, final float value, final int which) { + if (pressed != gamepad.dpad[which]) { + gamepad.dpad[which] = pressed; + onButtonChange(gamepad.handle, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value)); + } + } + + public static boolean handleMotionEvent(final MotionEvent ev) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return false; + } + + final Gamepad gamepad = sGamepads.get(ev.getDeviceId()); + if (gamepad == null) { + // Not a device we care about. + return false; + } + + // First check the analog stick axes + final boolean[] valid = new boolean[Axis.values().length]; + final float[] axes = new float[Axis.values().length]; + boolean anyValidAxes = false; + for (final Axis axis : Axis.values()) { + final float value = deadZone(ev, axis.axis); + final int i = axis.ordinal(); + if (value != gamepad.axes[i]) { + axes[i] = value; + gamepad.axes[i] = value; + valid[i] = true; + anyValidAxes = true; + } + } + if (anyValidAxes) { + // Send an axismove event. + onAxisChange(gamepad.handle, valid, axes); + } + + // Map triggers to buttons. + if (gamepad.triggerAxes != null) { + for (final Trigger trigger : Trigger.values()) { + final int i = trigger.ordinal(); + final int axis = gamepad.triggerAxes[i]; + final float value = deadZone(ev, axis); + if (value != gamepad.triggers[i]) { + gamepad.triggers[i] = value; + final boolean pressed = value > TRIGGER_PRESSED_THRESHOLD; + onButtonChange(gamepad.handle, trigger.button, pressed, value); + } + } + } + // Map d-pad to buttons. + for (final DpadAxis dpadaxis : DpadAxis.values()) { + final float value = deadZone(ev, dpadaxis.axis); + mapDpadAxis(gamepad, value < 0.0f, value, dpadaxis.negativeButton); + mapDpadAxis(gamepad, value > 0.0f, value, dpadaxis.positiveButton); + } + return true; + } + + public static boolean handleKeyEvent(final KeyEvent ev) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return false; + } + + final int deviceId = ev.getDeviceId(); + final List pendingGamepad = sPendingGamepads.get(deviceId); + if (pendingGamepad != null) { + // Queue up key events for pending devices. + pendingGamepad.add(ev); + return true; + } + + if (sGamepads.get(deviceId) == null) { + final InputDevice device = ev.getDevice(); + if (device != null + && (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + // This is a gamepad we haven't seen yet. + addGamepad(device); + sPendingGamepads.get(deviceId).add(ev); + return true; + } + // Not a device we care about. + return false; + } + + int key = -1; + for (final Button button : Button.values()) { + if (button.button == ev.getKeyCode()) { + key = button.ordinal(); + break; + } + } + if (key == -1) { + // Not a key we know how to handle. + return false; + } + if (ev.getRepeatCount() > 0) { + // We would handle this key, but we're not interested in + // repeats. Eat it. + return true; + } + + final Gamepad gamepad = sGamepads.get(deviceId); + final boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN; + onButtonChange(gamepad.handle, key, pressed, pressed ? 1.0f : 0.0f); + return true; + } + + private static void scanForGamepads() { + final int[] deviceIds = InputDevice.getDeviceIds(); + if (deviceIds == null) { + return; + } + for (int i = 0; i < deviceIds.length; i++) { + final InputDevice device = InputDevice.getDevice(deviceIds[i]); + if (device == null) { + continue; + } + if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD) { + continue; + } + addGamepad(device); + } + } + + private static void addGamepad(final InputDevice device) { + sPendingGamepads.put(device.getId(), new ArrayList()); + final byte[] gamepadId = nativeAddGamepad(); + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + handleGamepadAdded(device.getId(), gamepadId); + } + }); + } + + private static void removeGamepad(final int deviceId) { + final Gamepad gamepad = sGamepads.get(deviceId); + nativeRemoveGamepad(gamepad.handle); + sGamepads.remove(deviceId); + } + + private static void addDeviceListener(final Context context) { + sListener = + new InputManager.InputDeviceListener() { + @Override + public void onInputDeviceAdded(final int deviceId) { + final InputDevice device = InputDevice.getDevice(deviceId); + if (device == null) { + return; + } + if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + addGamepad(device); + } + } + + @Override + public void onInputDeviceRemoved(final int deviceId) { + if (sPendingGamepads.get(deviceId) != null) { + // Got removed before Gecko's ack reached us. + // gamepadAdded will deal with it. + sPendingGamepads.remove(deviceId); + return; + } + if (sGamepads.get(deviceId) != null) { + removeGamepad(deviceId); + } + } + + @Override + public void onInputDeviceChanged(final int deviceId) {} + }; + final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + im.registerInputDeviceListener(sListener, ThreadUtils.getUiHandler()); + } + + private static void removeDeviceListener(final Context context) { + final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + im.unregisterInputDeviceListener(sListener); + sListener = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java new file mode 100644 index 0000000000..525a85f4da --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class Clipboard { + private static final String HTML_MIME = "text/html"; + private static final String PLAINTEXT_MIME = "text/plain"; + private static final String LOGTAG = "GeckoClipboard"; + + private Clipboard() {} + + /** + * Get the text on the primary clip on Android clipboard + * + * @param context application context. + * @return a plain text string of clipboard data. + */ + public static String getText(final Context context) { + return getData(context, PLAINTEXT_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/plain only. If other + * type, we do nothing. + * @return a string into clipboard. + */ + @WrapForJNI(calledFrom = "gecko") + public static String getData(final Context context, final String mimeType) { + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm.hasPrimaryClip()) { + final ClipData clip = cm.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return null; + } + + final ClipDescription description = clip.getDescription(); + if (HTML_MIME.equals(mimeType) + && description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) { + final CharSequence data = clip.getItemAt(0).getHtmlText(); + if (data == null) { + return null; + } + return data.toString(); + } + if (PLAINTEXT_MIME.equals(mimeType)) { + try { + return clip.getItemAt(0).coerceToText(context).toString(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Couldn't get clip data from clipboard", e); + } + } + } + return null; + } + + /** + * Set plain text to clipboard + * + * @param context application context + * @param text a plain text to set to clipboard + * @return true if copy is successful. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean setText(final Context context, final CharSequence text) { + return setData(context, ClipData.newPlainText("text", text)); + } + + /** + * Store HTML to clipboard + * + * @param context application context + * @param text a plain text to set to clipboard + * @param html a html text to set to clipboard + * @return true if copy is successful. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean setHTML( + final Context context, final CharSequence text, final String htmlText) { + return setData(context, ClipData.newHtmlText("html", text, htmlText)); + } + + /** + * Store {@link android.content.ClipData} to clipboard + * + * @param context application context + * @param clipData a {@link android.content.ClipData} to set to clipboard + * @return true if copy is successful. + */ + private static boolean setData(final Context context, final ClipData clipData) { + // In API Level 11 and above, CLIPBOARD_SERVICE returns android.content.ClipboardManager, + // which is a subclass of android.text.ClipboardManager. + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + try { + cm.setPrimaryClip(clipData); + } catch (final NullPointerException e) { + // Bug 776223: This is a Samsung clipboard bug. setPrimaryClip() can throw + // a NullPointerException if Samsung's /data/clipboard directory is full. + // Fortunately, the text is still successfully copied to the clipboard. + } catch (final RuntimeException e) { + // If clipData is too large, TransactionTooLargeException occurs. + Log.e(LOGTAG, "Couldn't set clip data to clipboard", e); + return false; + } + return true; + } + + /** + * Check whether primary clipboard has given MIME type. + * + * @param context application context + * @param mimeType MIME type + * @return true if the clipboard is nonempty, false otherwise. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean hasData(final Context context, final String mimeType) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + if (HTML_MIME.equals(mimeType) || PLAINTEXT_MIME.equals(mimeType)) { + return !TextUtils.isEmpty(getData(context, mimeType)); + } + return false; + } + + // Calling getPrimaryClip causes a toast message from Android 12. + // https://developer.android.com/about/versions/12/behavior-changes-all#clipboard-access-notifications + + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (!cm.hasPrimaryClip()) { + return false; + } + + final ClipDescription description = cm.getPrimaryClipDescription(); + if (description == null) { + return false; + } + + if (HTML_MIME.equals(mimeType)) { + return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML); + } + + if (PLAINTEXT_MIME.equals(mimeType)) { + // We cannot check content in data at this time to avoid toast message. + return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) + || description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); + } + + return false; + } + + /** Deletes all text from the clipboard. */ + @WrapForJNI(calledFrom = "gecko") + public static void clearText(final Context context) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + setText(context, null); + return; + } + // Although we don't know more details of https://crbug.com/1203377, Blink doesn't use + // clearPrimaryClip on Android P since this may throw an exception, even if it is supported + // on Android P. + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + cm.clearPrimaryClip(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java new file mode 100644 index 0000000000..91bd44b552 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java @@ -0,0 +1,537 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.annotation.SuppressLint; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Process; +import android.util.Log; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.UUID; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoRuntime; + +public class CrashHandler implements Thread.UncaughtExceptionHandler { + + private static final String LOGTAG = "GeckoCrashHandler"; + private static final Thread MAIN_THREAD = Thread.currentThread(); + private static final String DEFAULT_SERVER_URL = + "https://crash-reports.mozilla.com/submit?id=%1$s&version=%2$s&buildid=%3$s"; + + // Context for getting device information + protected final Context appContext; + // Thread that this handler applies to, or null for a global handler + protected final Thread handlerThread; + protected final Thread.UncaughtExceptionHandler systemUncaughtHandler; + + protected boolean crashing; + protected boolean unregistered; + + protected final Class handlerService; + + /** + * Get the root exception from the 'cause' chain of an exception. + * + * @param exc An exception + * @return The root exception + */ + public static Throwable getRootException(final Throwable exc) { + Throwable cause; + Throwable result = exc; + for (cause = exc; cause != null; cause = cause.getCause()) { + result = cause; + } + + return result; + } + + /** + * Get the standard stack trace string of an exception. + * + * @param exc An exception + * @return The exception stack trace. + */ + public static String getExceptionStackTrace(final Throwable exc) { + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw); + exc.printStackTrace(pw); + pw.flush(); + return sw.toString(); + } + + /** Terminate the current process. */ + public static void terminateProcess() { + Process.killProcess(Process.myPid()); + } + + /** Create and register a CrashHandler for all threads and thread groups. */ + public CrashHandler(final Class 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 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 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 handlerService) { + this.appContext = appContext; + this.handlerThread = thread; + this.handlerService = handlerService; + this.systemUncaughtHandler = thread.getUncaughtExceptionHandler(); + thread.setUncaughtExceptionHandler(this); + } + + /** Unregister this CrashHandler for exception handling. */ + public void unregister() { + unregistered = true; + + // Restore the previous handler if we are still the topmost handler. + // If not, we are part of a chain of handlers, and we cannot just restore the previous + // handler, because that would replace whatever handler that's above us in the chain. + + if (handlerThread != null) { + if (handlerThread.getUncaughtExceptionHandler() == this) { + handlerThread.setUncaughtExceptionHandler(systemUncaughtHandler); + } + } else { + if (Thread.getDefaultUncaughtExceptionHandler() == this) { + Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler); + } + } + } + + /** + * Record an exception stack in logs. + * + * @param thread The exception thread + * @param exc An exception + */ + public static void logException(final Thread thread, final Throwable exc) { + try { + Log.e( + LOGTAG, + ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD " + + thread.getId() + + " (\"" + + thread.getName() + + "\")", + exc); + + if (MAIN_THREAD != thread) { + Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:"); + for (final StackTraceElement ste : MAIN_THREAD.getStackTrace()) { + Log.e(LOGTAG, " " + ste.toString()); + } + } + } catch (final Throwable e) { + // If something throws here, we want to continue to report the exception, + // so we catch all exceptions and ignore them. + } + } + + private static long getCrashTime() { + return System.currentTimeMillis() / 1000; + } + + private static long getStartupTime() { + // Process start time is also the proc file modified time. + final long uptimeMins = (new File("/proc/self/cmdline")).lastModified(); + if (uptimeMins == 0L) { + return getCrashTime(); + } + return uptimeMins / 1000; + } + + private static String getJavaPackageName() { + return CrashHandler.class.getPackage().getName(); + } + + private static String getProcessName() { + try { + final FileReader reader = new FileReader("/proc/self/cmdline"); + final char[] buffer = new char[64]; + try { + if (reader.read(buffer) > 0) { + // cmdline is delimited by '\0', and we want the first token. + final int nul = Arrays.asList(buffer).indexOf('\0'); + return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim(); + } + } finally { + reader.close(); + } + } catch (final IOException e) { + } + + return null; + } + + protected String getAppPackageName() { + final Context context = getAppContext(); + + if (context != null) { + return context.getPackageName(); + } + + // Package name is also the process name in most cases. + final String processName = getProcessName(); + if (processName != null) { + return processName; + } + + // Fallback to using CrashHandler's package name. + return getJavaPackageName(); + } + + protected Context getAppContext() { + return appContext; + } + + /** + * Get the crash "extras" to be reported. + * + * @param thread The exception thread + * @param exc An exception + * @return "Extras" in the from of a Bundle + */ + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Context context = getAppContext(); + final Bundle extras = new Bundle(); + final String pkgName = getAppPackageName(); + + extras.putLong("CrashTime", getCrashTime()); + extras.putLong("StartupTime", getStartupTime()); + extras.putString("Android_ProcessName", getProcessName()); + extras.putString("Android_PackageName", pkgName); + + final String notes = GeckoAppShell.getAppNotes(); + if (notes != null) { + extras.putString("Notes", notes); + } + + if (context != null) { + final PackageManager pkgMgr = context.getPackageManager(); + try { + final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0); + extras.putString("Version", pkgInfo.versionName); + extras.putInt("BuildID", pkgInfo.versionCode); + extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000); + } catch (final PackageManager.NameNotFoundException e) { + Log.i(LOGTAG, "Error getting package info", e); + } + } + + extras.putString("JavaStackTrace", getExceptionStackTrace(exc)); + return extras; + } + + /** + * Get the crash minidump content to be reported. + * + * @param thread The exception thread + * @param exc An exception + * @return Minidump content + */ + protected byte[] getCrashDump(final Thread thread, final Throwable exc) { + return new byte[0]; // No minidump. + } + + protected static String normalizeUrlString(final String str) { + if (str == null) { + return ""; + } + return Uri.encode(str); + } + + /** + * Get the server URL to send the crash report to. + * + * @param extras The crash extras Bundle + */ + protected String getServerUrl(final Bundle extras) { + return String.format( + DEFAULT_SERVER_URL, + normalizeUrlString(extras.getString("ProductID")), + normalizeUrlString(extras.getString("Version")), + normalizeUrlString(extras.getString("BuildID"))); + } + + /** + * Launch the crash reporter activity that sends the crash report to the server. + * + * @param dumpFile Path for the minidump file + * @param extraFile Path for the crash extra file + * @return Whether the crash reporter was successfully launched + */ + protected boolean launchCrashReporter(final String dumpFile, final String extraFile) { + try { + final Context context = getAppContext(); + final ProcessBuilder pb; + + if (handlerService == null) { + Log.w(LOGTAG, "No crash handler service defined, unable to report crash"); + return false; + } + + if (context != null) { + final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED); + intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile); + intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile); + intent.putExtra( + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + intent.setClass(context, handlerService); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + return true; + } + + final int deviceSdkVersion = Build.VERSION.SDK_INT; + if (deviceSdkVersion < 17) { + pb = + new ProcessBuilder( + "/system/bin/am", + "startservice", + "-a", + GeckoRuntime.ACTION_CRASHED, + "-n", + getAppPackageName() + '/' + handlerService.getName(), + "--es", + GeckoRuntime.EXTRA_MINIDUMP_PATH, + dumpFile, + "--es", + GeckoRuntime.EXTRA_EXTRAS_PATH, + extraFile, + "--es", + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, + GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + } else { + final String startServiceCommand; + if (deviceSdkVersion >= 26) { + startServiceCommand = "start-foreground-service"; + } else { + startServiceCommand = "startservice"; + } + + pb = + new ProcessBuilder( + "/system/bin/am", + startServiceCommand, + "--user", /* USER_CURRENT_OR_SELF */ + "-3", + "-a", + GeckoRuntime.ACTION_CRASHED, + "-n", + getAppPackageName() + '/' + handlerService.getName(), + "--es", + GeckoRuntime.EXTRA_MINIDUMP_PATH, + dumpFile, + "--es", + GeckoRuntime.EXTRA_EXTRAS_PATH, + extraFile, + "--es", + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, + GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + } + + pb.start().waitFor(); + + } catch (final IOException e) { + Log.e(LOGTAG, "Error launching crash reporter", e); + return false; + + } catch (final InterruptedException e) { + Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e); + // Fall-through + } + return true; + } + + /** + * Report an exception to Socorro. + * + * @param thread The exception thread + * @param exc An exception + * @return Whether the exception was successfully reported + */ + @SuppressLint("SdCardPath") + protected boolean reportException(final Thread thread, final Throwable exc) { + final Context context = getAppContext(); + final String id = UUID.randomUUID().toString(); + + // Use the cache directory under the app directory to store crash files. + final File dir; + if (context != null) { + dir = context.getCacheDir(); + } else { + dir = new File("/data/data/" + getAppPackageName() + "/cache"); + } + + dir.mkdirs(); + if (!dir.exists()) { + return false; + } + + final File dmpFile = new File(dir, id + ".dmp"); + final File extraFile = new File(dir, id + ".extra"); + + try { + // Write out minidump file as binary. + + final byte[] minidump = getCrashDump(thread, exc); + final FileOutputStream dmpStream = new FileOutputStream(dmpFile); + try { + dmpStream.write(minidump); + } finally { + dmpStream.close(); + } + + } catch (final IOException e) { + Log.e(LOGTAG, "Error writing minidump file", e); + return false; + } + + try { + // Write out crash extra file as text. + + final Bundle extras = getCrashExtras(thread, exc); + final String url = getServerUrl(extras); + extras.putString("ServerURL", url); + + final JSONObject json = new JSONObject(); + for (final String key : extras.keySet()) { + json.put(key, extras.get(key)); + } + + final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile)); + try { + extraWriter.write(json.toString()); + } finally { + extraWriter.close(); + } + } catch (final IOException | JSONException e) { + Log.e(LOGTAG, "Error writing extra file", e); + return false; + } + + return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath()); + } + + /** + * Implements the default behavior for handling uncaught exceptions. + * + * @param thread The exception thread + * @param exc An uncaught exception + */ + @Override + public void uncaughtException(final Thread thread, final Throwable exc) { + if (this.crashing) { + // Prevent possible infinite recusions. + return; + } + + Thread resolvedThread = thread; + if (resolvedThread == null) { + // Gecko may pass in null for thread to denote the current thread. + resolvedThread = Thread.currentThread(); + } + + try { + Throwable rootException = exc; + if (!this.unregistered) { + // Only process crash ourselves if we have not been unregistered. + + this.crashing = true; + rootException = getRootException(exc); + logException(resolvedThread, rootException); + + if (reportException(resolvedThread, rootException)) { + // Reporting succeeded; we can terminate our process now. + return; + } + } + + if (systemUncaughtHandler != null) { + // Follow the chain of uncaught handlers. + systemUncaughtHandler.uncaughtException(resolvedThread, rootException); + } + } finally { + terminateProcess(); + } + } + + public static CrashHandler createDefaultCrashHandler(final Context context) { + return new CrashHandler(context, null) { + @Override + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Bundle extras = super.getCrashExtras(thread, exc); + + extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME); + extras.putString("ProductID", BuildConfig.MOZ_APP_ID); + extras.putString("Version", BuildConfig.MOZ_APP_VERSION); + extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID); + extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR); + extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL); + return extras; + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) { + // Only use Java crash reporter if enabled on official build. + return super.reportException(thread, exc); + } + return false; + } + }; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java new file mode 100644 index 0000000000..0aacef39a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java @@ -0,0 +1,96 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.util.Log; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Enumeration; +import org.mozilla.gecko.annotation.WrapForJNI; + +// This class implements the functionality needed to find third-party root +// certificates that have been added to the android CA store. +public class EnterpriseRoots { + private static final String LOGTAG = "EnterpriseRoots"; + + // Gecko calls this function from C++ to find third-party root certificates + // it can use as trust anchors for TLS connections. + @WrapForJNI + private static byte[][] gatherEnterpriseRoots() { + + // The KeyStore "AndroidCAStore" contains the certificates we're + // interested in. + final KeyStore ks; + try { + ks = KeyStore.getInstance("AndroidCAStore"); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "getInstance() failed", kse); + return new byte[0][0]; + } + try { + ks.load(null); + } catch (final CertificateException ce) { + Log.e(LOGTAG, "load() failed", ce); + return new byte[0][0]; + } catch (final IOException ioe) { + Log.e(LOGTAG, "load() failed", ioe); + return new byte[0][0]; + } catch (final NoSuchAlgorithmException nsae) { + Log.e(LOGTAG, "load() failed", nsae); + return new byte[0][0]; + } + // Given the KeyStore, we get an identifier for each object in it. For + // each one that is a Certificate, we try to distinguish between + // entries that shipped with the OS and entries that were added by the + // user or an administrator. The former we ignore and the latter we + // collect in an array of byte arrays and return. + final Enumeration aliases; + try { + aliases = ks.aliases(); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "aliases() failed", kse); + return new byte[0][0]; + } + final ArrayList roots = new ArrayList(); + while (aliases.hasMoreElements()) { + final String alias = aliases.nextElement(); + final boolean isCertificate; + try { + isCertificate = ks.isCertificateEntry(alias); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "isCertificateEntry() failed", kse); + continue; + } + // Built-in certificate aliases start with "system:", whereas + // 3rd-party certificate aliases start with "user:". It's + // unfortunate to be relying on this implementation detail, but + // there appears to be no other way to differentiate between the + // two. + if (isCertificate && alias.startsWith("user:")) { + final Certificate certificate; + try { + certificate = ks.getCertificate(alias); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "getCertificate() failed", kse); + continue; + } + try { + roots.add(certificate.getEncoded()); + } catch (final CertificateEncodingException cee) { + Log.e(LOGTAG, "getEncoded() failed", cee); + } + } + } + Log.d(LOGTAG, "found " + roots.size() + " enterprise roots"); + return roots.toArray(new byte[0][0]); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java new file mode 100644 index 0000000000..647ac5bc09 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java @@ -0,0 +1,588 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.os.Handler; +import android.util.Log; +import androidx.annotation.AnyThread; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; + +@RobocopTarget +public final class EventDispatcher extends JNIObject { + private static final String LOGTAG = "GeckoEventDispatcher"; + + private static final EventDispatcher INSTANCE = new EventDispatcher(); + + /** + * The capacity of a HashMap is rounded up to the next power-of-2. Every time the size of the map + * goes beyond 75% of the capacity, the map is rehashed. Therefore, to empirically determine the + * initial capacity that avoids rehashing, we need to determine the initial size, divide it by + * 75%, and round up to the next power-of-2. + */ + private static final int DEFAULT_UI_EVENTS_COUNT = 128; // Empirically measured + + private static class Message { + final String type; + final GeckoBundle bundle; + final EventCallback callback; + + Message(final String type, final GeckoBundle bundle, final EventCallback callback) { + this.type = type; + this.bundle = bundle; + this.callback = callback; + } + } + + // GeckoBundle-based events. + private final MultiMap mListeners = + new MultiMap<>(DEFAULT_UI_EVENTS_COUNT); + private Deque mPendingMessages = new ArrayDeque<>(); + + private boolean mAttachedToGecko; + private final NativeQueue mNativeQueue; + private final String mName; + + private static Map 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 extends GeckoResult 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 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 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 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 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 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 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 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 queryBundle(final String type, final GeckoBundle message) { + return query(type, message); + } + + private GeckoResult query(final String type, final GeckoBundle message) { + final CallbackResult result = + new CallbackResult() { + @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 typeSet = new HashSet<>(Arrays.asList(types)); + + final Deque pendingMessages; + synchronized (mPendingMessages) { + pendingMessages = mPendingMessages; + mPendingMessages = new ArrayDeque<>(pendingMessages.size()); + } + + Message message; + while (!pendingMessages.isEmpty()) { + message = pendingMessages.removeFirst(); + if (typeSet.contains(message.type)) { + dispatchToThreads(message.type, message.bundle, message.callback); + } else { + synchronized (mPendingMessages) { + mPendingMessages.addLast(message); + } + } + } + } + + /** + * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners). + * + * @param type Event type + * @param message Bundle message + * @param callback Optional object for callbacks from events. + */ + @AnyThread + private void dispatch( + final String type, final GeckoBundle message, final EventCallback callback) { + final boolean isGeckoReady; + synchronized (this) { + isGeckoReady = isReadyForDispatchingToGecko(); + if (isGeckoReady && mAttachedToGecko && hasGeckoListener(type)) { + dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback)); + return; + } + } + + dispatchToThreads(type, message, callback, isGeckoReady); + } + + @WrapForJNI(calledFrom = "gecko") + private boolean dispatchToThreads( + final String type, final GeckoBundle message, final EventCallback callback) { + return dispatchToThreads(type, message, callback, /* isGeckoReady */ true); + } + + private boolean dispatchToThreads( + final String type, + final GeckoBundle message, + final EventCallback callback, + final boolean isGeckoReady) { + // We need to hold the lock throughout dispatching, to ensure the listeners list + // is consistent, while we iterate over it. We don't have to worry about listeners + // running for a long time while we have the lock, because the listeners will run + // on a separate thread. + synchronized (mListeners) { + if (mListeners.containsKey(type)) { + // Use a delegate to make sure callbacks happen on a specific thread. + final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback); + + // Event listeners will call | callback.sendError | if applicable. + for (final BundleEventListener listener : mListeners.get(type)) { + ThreadUtils.getUiHandler() + .post( + new Runnable() { + @Override + public void run() { + final Double startTime = GeckoJavaSampler.tryToGetProfilerTime(); + listener.handleMessage(type, message, wrappedCallback); + GeckoJavaSampler.addMarker( + "EventDispatcher handleMessage", startTime, null, type); + } + }); + } + return true; + } + } + + if (!isGeckoReady) { + // Usually, we discard an event if there is no listeners for it by + // the time of the dispatch. However, if Gecko(View) is not ready and + // there is no listener for this event that's possibly headed to + // Gecko, we make a special exception to queue this event until + // Gecko(View) is ready. This way, Gecko can first register its + // listeners, and accept the event when it is ready. + mNativeQueue.queueUntilReady( + this, + "dispatchToGecko", + String.class, + type, + GeckoBundle.class, + message, + EventCallback.class, + JavaCallbackDelegate.wrap(callback)); + return true; + } + + // Named EventDispatchers use pending messages + if (mName != null) { + synchronized (mPendingMessages) { + mPendingMessages.addLast(new Message(type, message, callback)); + } + return true; + } + + final String error = "No listener for " + type; + if (callback != null) { + callback.sendError(error); + } + + Log.w(LOGTAG, error); + return false; + } + + @WrapForJNI + public boolean hasListener(final String event) { + synchronized (mListeners) { + return mListeners.containsKey(event); + } + } + + @Override + protected void finalize() throws Throwable { + dispose(true); + } + + private static class NativeCallbackDelegate extends JNIObject implements EventCallback { + @WrapForJNI(calledFrom = "gecko") + private NativeCallbackDelegate() {} + + @Override // JNIObject + protected void disposeNative() { + // We dispose in finalize(). + throw new UnsupportedOperationException(); + } + + @WrapForJNI(dispatchTo = "proxy") + @Override // EventCallback + public native void sendSuccess(Object response); + + @WrapForJNI(dispatchTo = "proxy") + @Override // EventCallback + public native void sendError(Object response); + + @WrapForJNI(dispatchTo = "gecko") + @Override // Object + protected native void finalize(); + } + + private static class JavaCallbackDelegate implements EventCallback { + private final Thread mOriginalThread = Thread.currentThread(); + private final EventCallback mCallback; + + public static EventCallback wrap(final EventCallback callback) { + if (callback == null) { + return null; + } + if (callback instanceof NativeCallbackDelegate) { + // NativeCallbackDelegate always posts to Gecko thread if needed. + return callback; + } + return new JavaCallbackDelegate(callback); + } + + JavaCallbackDelegate(final EventCallback callback) { + mCallback = callback; + } + + private void makeCallback(final boolean callSuccess, final Object rawResponse) { + final Object response; + if (rawResponse instanceof Number) { + // There is ambiguity because a number can be converted to either int or + // double, so e.g. the user can be expecting a double when we give it an + // int. To avoid these pitfalls, we disallow all numbers. The workaround + // is to wrap the number in a JS object / GeckoBundle, which supports + // type coersion for numbers. + throw new UnsupportedOperationException("Cannot use number as Java callback result"); + } else if (rawResponse != null && rawResponse.getClass().isArray()) { + // Same with arrays. + throw new UnsupportedOperationException("Cannot use arrays as Java callback result"); + } else if (rawResponse instanceof Character) { + response = rawResponse.toString(); + } else { + response = rawResponse; + } + + // Call back synchronously if we happen to be on the same thread as the thread + // making the original request. + if (ThreadUtils.isOnThread(mOriginalThread)) { + if (callSuccess) { + mCallback.sendSuccess(response); + } else { + mCallback.sendError(response); + } + return; + } + + // Make callback on the thread of the original request, if the original thread + // is the UI or Gecko thread. Otherwise default to the background thread. + final Handler handler = + mOriginalThread == ThreadUtils.getUiThread() + ? ThreadUtils.getUiHandler() + : mOriginalThread == ThreadUtils.sGeckoThread + ? ThreadUtils.sGeckoHandler + : ThreadUtils.getBackgroundHandler(); + final EventCallback callback = mCallback; + + handler.post( + new Runnable() { + @Override + public void run() { + if (callSuccess) { + callback.sendSuccess(response); + } else { + callback.sendError(response); + } + } + }); + } + + @Override // EventCallback + public void sendSuccess(final Object response) { + makeCallback(/* success */ true, response); + } + + @Override // EventCallback + public void sendError(final Object response) { + makeCallback(/* success */ false, response); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java new file mode 100644 index 0000000000..d0d77d6c49 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java @@ -0,0 +1,1641 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.hardware.display.DisplayManager; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.Debug; +import android.os.LocaleList; +import android.os.Looper; +import android.os.PowerManager; +import android.os.Vibrator; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.Display; +import android.view.InputDevice; +import android.view.WindowManager; +import android.webkit.MimeTypeMap; +import androidx.annotation.Nullable; +import androidx.collection.SimpleArrayMap; +import androidx.core.content.res.ResourcesCompat; +import java.net.Proxy; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Locale; +import java.util.StringTokenizer; +import org.jetbrains.annotations.NotNull; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.InputDeviceUtils; +import org.mozilla.gecko.util.ProxySelector; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.R; + +public class GeckoAppShell { + private static final String LOGTAG = "GeckoAppShell"; + + /* + * Keep these values consistent with |SensorType| in HalSensor.h + */ + public static final int SENSOR_ORIENTATION = 0; + public static final int SENSOR_ACCELERATION = 1; + public static final int SENSOR_PROXIMITY = 2; + public static final int SENSOR_LINEAR_ACCELERATION = 3; + public static final int SENSOR_GYROSCOPE = 4; + public static final int SENSOR_LIGHT = 5; + public static final int SENSOR_ROTATION_VECTOR = 6; + public static final int SENSOR_GAME_ROTATION_VECTOR = 7; + + // We have static members only. + private GeckoAppShell() {} + + // Name for app-scoped prefs + public static final String APP_PREFS_NAME = "GeckoApp"; + + private static class GeckoCrashHandler extends CrashHandler { + + public GeckoCrashHandler(final Class handlerService) { + super(handlerService); + } + + @Override + protected String getAppPackageName() { + final Context appContext = getAppContext(); + if (appContext == null) { + return ""; + } + return appContext.getPackageName(); + } + + @Override + protected Context getAppContext() { + return getApplicationContext(); + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + try { + if (exc instanceof OutOfMemoryError) { + final SharedPreferences prefs = + getApplicationContext().getSharedPreferences(APP_PREFS_NAME, 0); + final SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PREFS_OOM_EXCEPTION, true); + + // Synchronously write to disk so we know it's done before we + // shutdown + editor.commit(); + } + + reportJavaCrash(exc, getExceptionStackTrace(exc)); + + } catch (final Throwable e) { + } + + // reportJavaCrash should have caused us to hard crash. If we're still here, + // it probably means Gecko is not loaded, and we should do something else. + if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) { + // Only use Java crash reporter if enabled on official build. + return super.reportException(thread, exc); + } + return false; + } + } + + private static String sAppNotes; + private static CrashHandler sCrashHandler; + + public static synchronized CrashHandler ensureCrashHandling( + final Class handler) { + if (sCrashHandler == null) { + sCrashHandler = new GeckoCrashHandler(handler); + } + + return sCrashHandler; + } + + private static Class sCrashHandlerService; + + public static synchronized void setCrashHandlerService( + final Class handlerService) { + sCrashHandlerService = handlerService; + } + + public static synchronized Class getCrashHandlerService() { + return sCrashHandlerService; + } + + @WrapForJNI(exceptionMode = "ignore") + /* package */ static synchronized String getAppNotes() { + return sAppNotes; + } + + public static synchronized void appendAppNotesToCrashReport(final String notes) { + if (sAppNotes == null) { + sAppNotes = notes; + } else { + sAppNotes += '\n' + notes; + } + } + + private static volatile boolean locationHighAccuracyEnabled; + private static volatile boolean locationListeningRequested = false; + private static volatile boolean locationPaused = false; + + // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB. + private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768; + + private static int sDensityDpi; + private static Float sDensity; + private static int sScreenDepth; + private static boolean sUseMaxScreenDepth; + private static Float sScreenRefreshRate; + + /* Is the value in sVibrationEndTime valid? */ + private static boolean sVibrationMaybePlaying; + + /* Time (in System.nanoTime() units) when the currently-playing vibration + * is scheduled to end. This value is valid only when + * sVibrationMaybePlaying is true. */ + private static long sVibrationEndTime; + + private static Sensor gAccelerometerSensor; + private static Sensor gLinearAccelerometerSensor; + private static Sensor gGyroscopeSensor; + private static Sensor gOrientationSensor; + private static Sensor gLightSensor; + private static Sensor gRotationVectorSensor; + private static Sensor gGameRotationVectorSensor; + + /* + * Keep in sync with constants found here: + * http://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl + */ + public static final int WPL_STATE_START = 0x00000001; + public static final int WPL_STATE_STOP = 0x00000010; + public static final int WPL_STATE_IS_DOCUMENT = 0x00020000; + public static final int WPL_STATE_IS_NETWORK = 0x00040000; + + /* Keep in sync with constants found here: + http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public static final int LINK_TYPE_UNKNOWN = 0; + public static final int LINK_TYPE_ETHERNET = 1; + public static final int LINK_TYPE_USB = 2; + public static final int LINK_TYPE_WIFI = 3; + public static final int LINK_TYPE_WIMAX = 4; + public static final int LINK_TYPE_MOBILE = 9; + + public static final String PREFS_OOM_EXCEPTION = "OOMException"; + + /* The Android-side API: API methods that Android calls */ + + // helper methods + @WrapForJNI + /* package */ static native void reportJavaCrash(Throwable exc, String stackTrace); + + private static Rect sScreenSizeOverride; + + @WrapForJNI(stubName = "NotifyObservers", dispatchTo = "gecko") + private static native void nativeNotifyObservers(String topic, String data); + + @WrapForJNI(stubName = "AppendAppNotesToCrashReport", dispatchTo = "gecko") + public static native void nativeAppendAppNotesToCrashReport(final String notes); + + @RobocopTarget + public static void notifyObservers(final String topic, final String data) { + notifyObservers(topic, data, GeckoThread.State.RUNNING); + } + + public static void notifyObservers( + final String topic, final String data, final GeckoThread.State state) { + if (GeckoThread.isStateAtLeast(state)) { + nativeNotifyObservers(topic, data); + } else { + GeckoThread.queueNativeCallUntil( + state, + GeckoAppShell.class, + "nativeNotifyObservers", + String.class, + topic, + String.class, + data); + } + } + + /* + * The Gecko-side API: API methods that Gecko calls + */ + + @WrapForJNI(exceptionMode = "ignore") + private static String getExceptionStackTrace(final Throwable e) { + return CrashHandler.getExceptionStackTrace(CrashHandler.getRootException(e)); + } + + @WrapForJNI(exceptionMode = "ignore") + private static synchronized void handleUncaughtException(final Throwable e) { + if (sCrashHandler != null) { + sCrashHandler.uncaughtException(null, e); + } + } + + private static float getLocationAccuracy(final Location location) { + final float radius = location.getAccuracy(); + return (location.hasAccuracy() && radius > 0) ? radius : 1001; + } + + private static Location determineReliableLocation( + @NotNull final Location locA, @NotNull final Location locB) { + // The 6 seconds were chosen arbitrarily + final long closeTime = 6000000000L; + final boolean isNearSameTime = + Math.abs((locA.getElapsedRealtimeNanos() - locB.getElapsedRealtimeNanos())) <= closeTime; + final boolean isAMoreAccurate = getLocationAccuracy(locA) < getLocationAccuracy(locB); + final boolean isAMoreRecent = locA.getElapsedRealtimeNanos() > locB.getElapsedRealtimeNanos(); + if (isNearSameTime) { + return isAMoreAccurate ? locA : locB; + } + return isAMoreRecent ? locA : locB; + } + + // Permissions are explicitly checked when requesting content permission. + @SuppressLint("MissingPermission") + private static @Nullable Location getLastKnownLocation(final LocationManager lm) { + Location lastKnownLocation = null; + final List providers = lm.getAllProviders(); + + for (final String provider : providers) { + final Location location = lm.getLastKnownLocation(provider); + if (location == null) { + continue; + } + + if (lastKnownLocation == null) { + lastKnownLocation = location; + continue; + } + lastKnownLocation = determineReliableLocation(lastKnownLocation, location); + } + return lastKnownLocation; + } + + // Toggles the location listeners on/off, which will then provide/stop location information + @WrapForJNI(calledFrom = "gecko") + private static synchronized boolean enableLocationUpdates(final boolean enable) { + locationListeningRequested = enable; + final boolean canListen = updateLocationListeners(); + if (!canListen && locationListeningRequested) { + // Didn't successfully start listener when requested + locationListeningRequested = false; + } + return canListen; + } + + // Permissions are explicitly checked when requesting content permission. + @SuppressLint("MissingPermission") + private static synchronized boolean updateLocationListeners() { + final boolean shouldListen = locationListeningRequested && !locationPaused; + final LocationManager lm = getLocationManager(getApplicationContext()); + if (lm == null) { + return false; + } + + if (!shouldListen) { + // Could not complete request, because paused + if (locationListeningRequested) { + return false; + } + lm.removeUpdates(sAndroidListeners); + return true; + } + + if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER) + && !lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + return false; + } + + final Location lastKnownLocation = getLastKnownLocation(lm); + if (lastKnownLocation != null) { + sAndroidListeners.onLocationChanged(lastKnownLocation); + } + + final Criteria criteria = new Criteria(); + criteria.setSpeedRequired(false); + criteria.setBearingRequired(false); + criteria.setAltitudeRequired(false); + if (locationHighAccuracyEnabled) { + criteria.setAccuracy(Criteria.ACCURACY_FINE); + } else { + criteria.setAccuracy(Criteria.ACCURACY_COARSE); + } + + final String provider = lm.getBestProvider(criteria, true); + if (provider == null) { + return false; + } + + final Looper l = Looper.getMainLooper(); + lm.requestLocationUpdates(provider, 100, 0.5f, sAndroidListeners, l); + return true; + } + + public static void pauseLocation() { + locationPaused = true; + updateLocationListeners(); + } + + public static void resumeLocation() { + locationPaused = false; + updateLocationListeners(); + } + + private static LocationManager getLocationManager(final Context context) { + try { + return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + } catch (final NoSuchFieldError e) { + // Some Tegras throw exceptions about missing the CONTROL_LOCATION_UPDATES permission, + // which allows enabling/disabling location update notifications from the cell radio. + // CONTROL_LOCATION_UPDATES is not for use by normal applications, but we might be + // hitting this problem if the Tegras are confused about missing cell radios. + Log.e(LOGTAG, "LOCATION_SERVICE not found?!", e); + return null; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableLocationHighAccuracy(final boolean enable) { + locationHighAccuracyEnabled = enable; + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + /* package */ static native void onSensorChanged( + int halType, float x, float y, float z, float w, long time); + + @WrapForJNI(calledFrom = "any", dispatchTo = "gecko") + /* package */ static native void onLocationChanged( + double latitude, + double longitude, + double altitude, + float accuracy, + float altitudeAccuracy, + float heading, + float speed); + + private static class AndroidListeners implements SensorEventListener, LocationListener { + @Override + public void onAccuracyChanged(final Sensor sensor, final int accuracy) {} + + @Override + public void onSensorChanged(final SensorEvent s) { + final int sensorType = s.sensor.getType(); + int halType = 0; + float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f; + // SensorEvent timestamp is in nanoseconds, Gecko expects microseconds. + final long time = s.timestamp / 1000; + + switch (sensorType) { + case Sensor.TYPE_ACCELEROMETER: + case Sensor.TYPE_LINEAR_ACCELERATION: + case Sensor.TYPE_ORIENTATION: + if (sensorType == Sensor.TYPE_ACCELEROMETER) { + halType = SENSOR_ACCELERATION; + } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION) { + halType = SENSOR_LINEAR_ACCELERATION; + } else { + halType = SENSOR_ORIENTATION; + } + x = s.values[0]; + y = s.values[1]; + z = s.values[2]; + break; + + case Sensor.TYPE_GYROSCOPE: + halType = SENSOR_GYROSCOPE; + x = (float) Math.toDegrees(s.values[0]); + y = (float) Math.toDegrees(s.values[1]); + z = (float) Math.toDegrees(s.values[2]); + break; + + case Sensor.TYPE_LIGHT: + halType = SENSOR_LIGHT; + x = s.values[0]; + break; + + case Sensor.TYPE_ROTATION_VECTOR: + case Sensor.TYPE_GAME_ROTATION_VECTOR: // API >= 18 + halType = + (sensorType == Sensor.TYPE_ROTATION_VECTOR + ? SENSOR_ROTATION_VECTOR + : SENSOR_GAME_ROTATION_VECTOR); + x = s.values[0]; + y = s.values[1]; + z = s.values[2]; + if (s.values.length >= 4) { + w = s.values[3]; + } else { + // s.values[3] was optional in API <= 18, so we need to compute it + // The values form a unit quaternion, so we can compute the angle of + // rotation purely based on the given 3 values. + w = + 1.0f + - s.values[0] * s.values[0] + - s.values[1] * s.values[1] + - s.values[2] * s.values[2]; + w = (w > 0.0f) ? (float) Math.sqrt(w) : 0.0f; + } + break; + } + + GeckoAppShell.onSensorChanged(halType, x, y, z, w, time); + } + + // Geolocation. + @Override + public void onLocationChanged(final Location location) { + // No logging here: user-identifying information. + + final double altitude = location.hasAltitude() ? location.getAltitude() : Double.NaN; + + final float accuracy = location.hasAccuracy() ? location.getAccuracy() : Float.NaN; + + final float altitudeAccuracy = + Build.VERSION.SDK_INT >= 26 && location.hasVerticalAccuracy() + ? location.getVerticalAccuracyMeters() + : Float.NaN; + + final float speed = location.hasSpeed() ? location.getSpeed() : Float.NaN; + + final float heading = location.hasBearing() ? location.getBearing() : Float.NaN; + + // nsGeoPositionCoords will convert NaNs to null for optional + // properties of the JavaScript Coordinates object. + GeckoAppShell.onLocationChanged( + location.getLatitude(), + location.getLongitude(), + altitude, + accuracy, + altitudeAccuracy, + heading, + speed); + } + + @Override + public void onProviderDisabled(final String provider) {} + + @Override + public void onProviderEnabled(final String provider) {} + + @Override + public void onStatusChanged(final String provider, final int status, final Bundle extras) {} + } + + private static final AndroidListeners sAndroidListeners = new AndroidListeners(); + + private static SimpleArrayMap sWakeLocks; + + /** Wake-lock for the CPU. */ + static final String WAKE_LOCK_CPU = "cpu"; + + /** Wake-lock for the screen. */ + static final String WAKE_LOCK_SCREEN = "screen"; + + /** Wake-lock for the audio-playing, eqaul to LOCK_CPU. */ + static final String WAKE_LOCK_AUDIO_PLAYING = "audio-playing"; + + /** Wake-lock for the video-playing, eqaul to LOCK_SCREEN.. */ + static final String WAKE_LOCK_VIDEO_PLAYING = "video-playing"; + + static final int WAKE_LOCKS_COUNT = 2; + + /** No one holds the wake-lock. */ + static final int WAKE_LOCK_STATE_UNLOCKED = 0; + + /** The wake-lock is held by a foreground window. */ + static final int WAKE_LOCK_STATE_LOCKED_FOREGROUND = 1; + + /** The wake-lock is held by a background window. */ + static final int WAKE_LOCK_STATE_LOCKED_BACKGROUND = 2; + + @SuppressLint("Wakelock") // We keep the wake lock independent from the function + // scope, so we need to suppress the linter warning. + private static void setWakeLockState(final String lock, final int state) { + if (sWakeLocks == null) { + sWakeLocks = new SimpleArrayMap<>(WAKE_LOCKS_COUNT); + } + + PowerManager.WakeLock wl = sWakeLocks.get(lock); + + // we should still hold the lock for background audio. + if (WAKE_LOCK_AUDIO_PLAYING.equals(lock) && state == WAKE_LOCK_STATE_LOCKED_BACKGROUND) { + return; + } + + if (state == WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl == null) { + final PowerManager pm = + (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE); + + if (WAKE_LOCK_CPU.equals(lock) || WAKE_LOCK_AUDIO_PLAYING.equals(lock)) { + wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, lock); + } else if (WAKE_LOCK_SCREEN.equals(lock) || WAKE_LOCK_VIDEO_PLAYING.equals(lock)) { + // ON_AFTER_RELEASE is set, the user activity timer will be reset when the + // WakeLock is released, causing the illumination to remain on a bit longer. + wl = + pm.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, lock); + } else { + Log.w(LOGTAG, "Unsupported wake-lock: " + lock); + return; + } + + wl.acquire(); + sWakeLocks.put(lock, wl); + } else if (state != WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl != null) { + wl.release(); + sWakeLocks.remove(lock); + } + } + + @SuppressWarnings("fallthrough") + @WrapForJNI(calledFrom = "gecko") + private static void enableSensor(final int aSensortype) { + final SensorManager sm = + (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor == null) { + gGameRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR); + } + if (gGameRotationVectorSensor != null) { + sm.registerListener( + sAndroidListeners, gGameRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + if (gGameRotationVectorSensor != null) { + break; + } + // Fallthrough + + case SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor == null) { + gRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR); + } + if (gRotationVectorSensor != null) { + sm.registerListener( + sAndroidListeners, gRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + if (gRotationVectorSensor != null) { + break; + } + // Fallthrough + + case SENSOR_ORIENTATION: + if (gOrientationSensor == null) { + gOrientationSensor = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION); + } + if (gOrientationSensor != null) { + sm.registerListener( + sAndroidListeners, gOrientationSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_ACCELERATION: + if (gAccelerometerSensor == null) { + gAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + if (gAccelerometerSensor != null) { + sm.registerListener( + sAndroidListeners, gAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_LIGHT: + if (gLightSensor == null) { + gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT); + } + if (gLightSensor != null) { + sm.registerListener(sAndroidListeners, gLightSensor, SensorManager.SENSOR_DELAY_NORMAL); + } + break; + + case SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor == null) { + gLinearAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION); + } + if (gLinearAccelerometerSensor != null) { + sm.registerListener( + sAndroidListeners, gLinearAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_GYROSCOPE: + if (gGyroscopeSensor == null) { + gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + } + if (gGyroscopeSensor != null) { + sm.registerListener( + sAndroidListeners, gGyroscopeSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + default: + Log.w(LOGTAG, "Error! Can't enable unknown SENSOR type " + aSensortype); + } + } + + @SuppressWarnings("fallthrough") + @WrapForJNI(calledFrom = "gecko") + private static void disableSensor(final int aSensortype) { + final SensorManager sm = + (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor != null) { + sm.unregisterListener(sAndroidListeners, gGameRotationVectorSensor); + break; + } + // Fallthrough + + case SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor != null) { + sm.unregisterListener(sAndroidListeners, gRotationVectorSensor); + break; + } + // Fallthrough + + case SENSOR_ORIENTATION: + if (gOrientationSensor != null) { + sm.unregisterListener(sAndroidListeners, gOrientationSensor); + } + break; + + case SENSOR_ACCELERATION: + if (gAccelerometerSensor != null) { + sm.unregisterListener(sAndroidListeners, gAccelerometerSensor); + } + break; + + case SENSOR_LIGHT: + if (gLightSensor != null) { + sm.unregisterListener(sAndroidListeners, gLightSensor); + } + break; + + case SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor != null) { + sm.unregisterListener(sAndroidListeners, gLinearAccelerometerSensor); + } + break; + + case SENSOR_GYROSCOPE: + if (gGyroscopeSensor != null) { + sm.unregisterListener(sAndroidListeners, gGyroscopeSensor); + } + break; + default: + Log.w(LOGTAG, "Error! Can't disable unknown SENSOR type " + aSensortype); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void moveTaskToBack() { + // This is a vestige, to be removed as full-screen support for GeckoView is implemented. + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean hasHWVP8Encoder() { + return HardwareCodecCapabilityUtils.hasHWVP8(true /* aIsEncoder */); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean hasHWVP8Decoder() { + return HardwareCodecCapabilityUtils.hasHWVP8(false /* aIsEncoder */); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getExtensionFromMimeType(final String aMimeType) { + return MimeTypeMap.getSingleton().getExtensionFromMimeType(aMimeType); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getMimeTypeFromExtensions(final String aFileExt) { + final StringTokenizer st = new StringTokenizer(aFileExt, ".,; "); + String type = null; + String subType = null; + while (st.hasMoreElements()) { + final String ext = st.nextToken(); + final String mt = getMimeTypeFromExtension(ext); + if (mt == null) continue; + final int slash = mt.indexOf('/'); + final String tmpType = mt.substring(0, slash); + if (!tmpType.equalsIgnoreCase(type)) type = type == null ? tmpType : "*"; + final String tmpSubType = mt.substring(slash + 1); + if (!tmpSubType.equalsIgnoreCase(subType)) subType = subType == null ? tmpSubType : "*"; + } + if (type == null) type = "*"; + if (subType == null) subType = "*"; + return type + "/" + subType; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void notifyAlertListener(String name, String topic, String cookie); + + /** + * Called by the NotificationListener to notify Gecko that a previously shown notification has + * been closed. + */ + public static void onNotificationClose(final String name, final String cookie) { + if (GeckoThread.isRunning()) { + notifyAlertListener(name, "alertfinished", cookie); + } + } + + /** + * Called by the NotificationListener to notify Gecko that a previously shown notification has + * been clicked on. + */ + public static void onNotificationClick(final String name, final String cookie) { + if (GeckoThread.isRunning()) { + notifyAlertListener(name, "alertclickcallback", cookie); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + GeckoAppShell.class, + "notifyAlertListener", + name, + "alertclickcallback", + cookie); + } + } + + public static synchronized void setDisplayDpiOverride(@Nullable final Integer dpi) { + if (dpi == null) { + return; + } + if (sDensityDpi != 0) { + Log.e(LOGTAG, "Tried to override screen DPI after it's already been set"); + return; + } + sDensityDpi = dpi; + } + + @WrapForJNI(calledFrom = "gecko") + public static synchronized int getDpi() { + if (sDensityDpi == 0) { + sDensityDpi = getApplicationContext().getResources().getDisplayMetrics().densityDpi; + } + return sDensityDpi; + } + + public static synchronized void setDisplayDensityOverride(@Nullable final Float density) { + if (density == null) { + return; + } + if (sDensity != null) { + Log.e(LOGTAG, "Tried to override screen density after it's already been set"); + return; + } + sDensity = density; + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized float getDensity() { + if (sDensity == null) { + sDensity = Float.valueOf(getApplicationContext().getResources().getDisplayMetrics().density); + } + + return sDensity; + } + + private static int sTotalRam; + + private static int getTotalRam(final Context context) { + if (sTotalRam == 0) { + final ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); + final ActivityManager am = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + am.getMemoryInfo(memInfo); // `getMemoryInfo()` returns a value in B. Convert to MB. + sTotalRam = (int) (memInfo.totalMem / (1024 * 1024)); + Log.d(LOGTAG, "System memory: " + sTotalRam + "MB."); + } + + return sTotalRam; + } + + private static boolean isHighMemoryDevice(final Context context) { + return getTotalRam(context) > HIGH_MEMORY_DEVICE_THRESHOLD_MB; + } + + public static synchronized void useMaxScreenDepth(final boolean enable) { + sUseMaxScreenDepth = enable; + } + + /** Returns the colour depth of the default screen. This will either be 32, 24 or 16. */ + @WrapForJNI(calledFrom = "gecko") + public static synchronized int getScreenDepth() { + if (sScreenDepth == 0) { + sScreenDepth = 16; + final Context applicationContext = getApplicationContext(); + final PixelFormat info = new PixelFormat(); + final WindowManager wm = + (WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE); + PixelFormat.getPixelFormatInfo(wm.getDefaultDisplay().getPixelFormat(), info); + if (info.bitsPerPixel >= 24 && isHighMemoryDevice(applicationContext)) { + sScreenDepth = sUseMaxScreenDepth ? info.bitsPerPixel : 24; + } + } + + return sScreenDepth; + } + + @WrapForJNI(calledFrom = "gecko") + public static synchronized float getScreenRefreshRate() { + if (sScreenRefreshRate != null) { + return sScreenRefreshRate; + } + + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final float refreshRate = wm.getDefaultDisplay().getRefreshRate(); + // Android 11+ supports multiple refresh rate. So we have to get refresh rate per call. + // https://source.android.com/docs/core/graphics/multiple-refresh-rate + if (Build.VERSION.SDK_INT < 30) { + // Until Android 10, refresh rate is fixed, so we can cache it. + sScreenRefreshRate = Float.valueOf(refreshRate); + } + return refreshRate; + } + + @WrapForJNI(calledFrom = "gecko") + private static void performHapticFeedback(final boolean aIsLongPress) { + // Don't perform haptic feedback if a vibration is currently playing, + // because the haptic feedback will nuke the vibration. + if (!sVibrationMaybePlaying || System.nanoTime() >= sVibrationEndTime) { + final int[] pattern; + if (aIsLongPress) { + pattern = new int[] {0, 1, 20, 21}; + } else { + pattern = new int[] {0, 10, 20, 30}; + } + vibrateOnHapticFeedbackEnabled(pattern); + sVibrationMaybePlaying = false; + sVibrationEndTime = 0; + } + } + + private static Vibrator vibrator() { + return (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE); + } + + // Helper method to convert integer array to long array. + private static long[] convertIntToLongArray(final int[] input) { + final long[] output = new long[input.length]; + for (int i = 0; i < input.length; i++) { + output[i] = input[i]; + } + return output; + } + + // Vibrate only if haptic feedback is enabled. + private static void vibrateOnHapticFeedbackEnabled(final int[] milliseconds) { + if (Settings.System.getInt( + getApplicationContext().getContentResolver(), + Settings.System.HAPTIC_FEEDBACK_ENABLED, + 0) + > 0) { + if (milliseconds.length == 1) { + vibrate(milliseconds[0]); + } else { + vibrate(convertIntToLongArray(milliseconds), -1); + } + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(final long milliseconds) { + sVibrationEndTime = System.nanoTime() + milliseconds * 1000000; + sVibrationMaybePlaying = true; + try { + vibrator().vibrate(milliseconds); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(final long[] pattern, final int repeat) { + // If pattern.length is odd, the last element in the pattern is a + // meaningless delay, so don't include it in vibrationDuration. + long vibrationDuration = 0; + final int iterLen = pattern.length & ~1; + for (int i = 0; i < iterLen; i++) { + vibrationDuration += pattern[i]; + } + + sVibrationEndTime = System.nanoTime() + vibrationDuration * 1000000; + sVibrationMaybePlaying = true; + try { + vibrator().vibrate(pattern, repeat); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void cancelVibrate() { + sVibrationMaybePlaying = false; + sVibrationEndTime = 0; + try { + vibrator().cancel(); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + private static ConnectivityManager sConnectivityManager; + + private static void ensureConnectivityManager() { + if (sConnectivityManager == null) { + sConnectivityManager = + (ConnectivityManager) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkUp() { + ensureConnectivityManager(); + try { + final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo(); + if (info == null || !info.isConnected()) return false; + } catch (final SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkKnown() { + ensureConnectivityManager(); + try { + if (sConnectivityManager.getActiveNetworkInfo() == null) return false; + } catch (final SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static int getNetworkLinkType() { + ensureConnectivityManager(); + final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo(); + if (info == null) { + return LINK_TYPE_UNKNOWN; + } + + switch (info.getType()) { + case ConnectivityManager.TYPE_ETHERNET: + return LINK_TYPE_ETHERNET; + case ConnectivityManager.TYPE_WIFI: + return LINK_TYPE_WIFI; + case ConnectivityManager.TYPE_WIMAX: + return LINK_TYPE_WIMAX; + case ConnectivityManager.TYPE_MOBILE: + return LINK_TYPE_MOBILE; + default: + Log.w(LOGTAG, "Ignoring the current network type."); + return LINK_TYPE_UNKNOWN; + } + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult") + private static String getDNSDomains() { + if (Build.VERSION.SDK_INT < 23) { + return ""; + } + + ensureConnectivityManager(); + final Network net = sConnectivityManager.getActiveNetwork(); + if (net == null) { + return ""; + } + + final LinkProperties lp = sConnectivityManager.getLinkProperties(net); + if (lp == null) { + return ""; + } + + return lp.getDomains(); + } + + @SuppressLint("ResourceType") + @WrapForJNI(calledFrom = "gecko") + private static int[] getSystemColors() { + // attrsAppearance[] must correspond to AndroidSystemColors structure in android/nsLookAndFeel.h + final int[] attrsAppearance = { + android.R.attr.textColorPrimary, + android.R.attr.textColorPrimaryInverse, + android.R.attr.textColorSecondary, + android.R.attr.textColorSecondaryInverse, + android.R.attr.textColorTertiary, + android.R.attr.textColorTertiaryInverse, + android.R.attr.textColorHighlight, + android.R.attr.colorForeground, + android.R.attr.colorBackground, + android.R.attr.panelColorForeground, + android.R.attr.panelColorBackground, + Build.VERSION.SDK_INT >= 21 ? android.R.attr.colorAccent : 0, + }; + + final int[] result = new int[attrsAppearance.length]; + + final ContextThemeWrapper contextThemeWrapper = + new ContextThemeWrapper(getApplicationContext(), android.R.style.TextAppearance); + + final TypedArray appearance = contextThemeWrapper.obtainStyledAttributes(attrsAppearance); + + if (appearance != null) { + for (int i = 0; i < appearance.getIndexCount(); i++) { + final int idx = appearance.getIndex(i); + final int color = appearance.getColor(idx, 0); + result[idx] = color; + } + appearance.recycle(); + } + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static byte[] getIconForExtension(final String aExt, final int iconSize) { + try { + int resolvedIconSize = iconSize; + if (iconSize <= 0) { + resolvedIconSize = 16; + } + + String resolvedExt = aExt; + if (aExt != null && aExt.length() > 1 && aExt.charAt(0) == '.') { + resolvedExt = aExt.substring(1); + } + + final PackageManager pm = getApplicationContext().getPackageManager(); + Drawable icon = getDrawableForExtension(pm, resolvedExt); + if (icon == null) { + // Use a generic icon. + icon = + ResourcesCompat.getDrawable( + getApplicationContext().getResources(), + R.drawable.ic_generic_file, + getApplicationContext().getTheme()); + } + + Bitmap bitmap = getBitmapFromDrawable(icon); + if (bitmap.getWidth() != resolvedIconSize || bitmap.getHeight() != resolvedIconSize) { + bitmap = Bitmap.createScaledBitmap(bitmap, resolvedIconSize, resolvedIconSize, true); + } + + final ByteBuffer buf = ByteBuffer.allocate(resolvedIconSize * resolvedIconSize * 4); + bitmap.copyPixelsToBuffer(buf); + + return buf.array(); + } catch (final Exception e) { + Log.w(LOGTAG, "getIconForExtension failed.", e); + return null; + } + } + + private static Bitmap getBitmapFromDrawable(final Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + int width = drawable.getIntrinsicWidth(); + width = width > 0 ? width : 1; + int height = drawable.getIntrinsicHeight(); + height = height > 0 ? height : 1; + + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + public static String getMimeTypeFromExtension(final String ext) { + final MimeTypeMap mtm = MimeTypeMap.getSingleton(); + return mtm.getMimeTypeFromExtension(ext); + } + + private static Drawable getDrawableForExtension(final PackageManager pm, final String aExt) { + final Intent intent = new Intent(Intent.ACTION_VIEW); + final String mimeType = getMimeTypeFromExtension(aExt); + if (mimeType != null && mimeType.length() > 0) intent.setType(mimeType); + else return null; + + final List list = pm.queryIntentActivities(intent, 0); + if (list.size() == 0) return null; + + final ResolveInfo resolveInfo = list.get(0); + + if (resolveInfo == null) return null; + + final ActivityInfo activityInfo = resolveInfo.activityInfo; + + return activityInfo.loadIcon(pm); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean getShowPasswordSetting() { + try { + final int showPassword = + Settings.System.getInt( + getApplicationContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1); + return (showPassword > 0); + } catch (final Exception e) { + return true; + } + } + + private static Context sApplicationContext; + private static Boolean sIs24HourFormat = true; + + @WrapForJNI + public static Context getApplicationContext() { + return sApplicationContext; + } + + public static void setApplicationContext(final Context context) { + sApplicationContext = context; + } + + /* + * Battery API related methods. + */ + @WrapForJNI(calledFrom = "gecko") + private static void enableBatteryNotifications() { + GeckoBatteryManager.enableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableBatteryNotifications() { + GeckoBatteryManager.disableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentBatteryInformation() { + return GeckoBatteryManager.getCurrentInformation(); + } + + /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */ + @WrapForJNI(calledFrom = "gecko") + @RobocopTarget + public static boolean isTablet() { + return HardwareUtils.isTablet(getApplicationContext()); + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentNetworkInformation() { + return GeckoNetworkManager.getInstance().getCurrentInformation(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableNetworkNotifications() { + ThreadUtils.runOnUiThread(() -> GeckoNetworkManager.getInstance().enableNotifications()); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableNetworkNotifications() { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + GeckoNetworkManager.getInstance().disableNotifications(); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private static short getScreenOrientation() { + return GeckoScreenOrientation.getInstance().getScreenOrientation().value; + } + + /* package */ static int getRotation() { + return sScreenCompat.getRotation(); + } + + @WrapForJNI(calledFrom = "gecko") + private static int getScreenAngle() { + return GeckoScreenOrientation.getInstance().getAngle(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void notifyWakeLockChanged(final String topic, final String state) { + final int intState; + if ("unlocked".equals(state)) { + intState = WAKE_LOCK_STATE_UNLOCKED; + } else if ("locked-foreground".equals(state)) { + intState = WAKE_LOCK_STATE_LOCKED_FOREGROUND; + } else if ("locked-background".equals(state)) { + intState = WAKE_LOCK_STATE_LOCKED_BACKGROUND; + } else { + throw new IllegalArgumentException(); + } + setWakeLockState(topic, intState); + } + + @WrapForJNI(calledFrom = "gecko") + private static String getProxyForURI( + final String spec, final String scheme, final String host, final int port) { + final ProxySelector ps = new ProxySelector(); + + final Proxy proxy = ps.select(scheme, host); + if (Proxy.NO_PROXY.equals(proxy)) { + return "DIRECT"; + } + + switch (proxy.type()) { + case HTTP: + return "PROXY " + proxy.address().toString(); + case SOCKS: + return "SOCKS " + proxy.address().toString(); + } + + return "DIRECT"; + } + + @WrapForJNI(calledFrom = "gecko") + private static int getMaxTouchPoints() { + final PackageManager pm = getApplicationContext().getPackageManager(); + if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_JAZZHAND)) { + // at least, 5+ fingers. + return 5; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT)) { + // at least, 2+ fingers. + return 2; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH)) { + // 2 fingers + return 2; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + // 1 finger + return 1; + } + return 0; + } + + /* + * Keep in sync with PointerCapabilities in ServoTypes.h + */ + private static final int NO_POINTER = 0x00000000; + private static final int COARSE_POINTER = 0x00000001; + private static final int FINE_POINTER = 0x00000002; + private static final int HOVER_CAPABLE_POINTER = 0x00000004; + + private static int getPointerCapabilities(final InputDevice inputDevice) { + int result = NO_POINTER; + final int sources = inputDevice.getSources(); + + // Blink checks fine pointer at first, then it check coarse pointer. + // So, we should use same order for compatibility. + // Also, if using Chrome OS, source may be SOURCE_MOUSE | SOURCE_TOUCHSCREEN | SOURCE_STYLUS + // even if no touch screen. So we shouldn't check TOUCHSCREEN at first. + + if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE) + || hasInputDeviceSource(sources, InputDevice.SOURCE_STYLUS) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)) { + result |= FINE_POINTER; + } else if (hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHSCREEN) + || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) { + result |= COARSE_POINTER; + } + + if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL) + || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) { + result |= HOVER_CAPABLE_POINTER; + } + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + // For any-pointer and any-hover media queries features. + private static int getAllPointerCapabilities() { + int result = NO_POINTER; + + for (final int deviceId : InputDevice.getDeviceIds()) { + final InputDevice inputDevice = InputDevice.getDevice(deviceId); + if (inputDevice == null || !InputDeviceUtils.isPointerTypeDevice(inputDevice)) { + continue; + } + + result |= getPointerCapabilities(inputDevice); + } + + return result; + } + + private static boolean hasInputDeviceSource(final int sources, final int inputDeviceSource) { + return (sources & inputDeviceSource) == inputDeviceSource; + } + + public static synchronized void setScreenSizeOverride(final Rect size) { + sScreenSizeOverride = size; + } + + static final ScreenCompat sScreenCompat; + + private interface ScreenCompat { + Rect getScreenSize(); + + int getRotation(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private static class JellyBeanScreenCompat implements ScreenCompat { + public Rect getScreenSize() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Display disp = wm.getDefaultDisplay(); + return new Rect(0, 0, disp.getWidth(), disp.getHeight()); + } + + public int getRotation() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + return wm.getDefaultDisplay().getRotation(); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + private static class JellyBeanMR1ScreenCompat implements ScreenCompat { + public Rect getScreenSize() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Display disp = wm.getDefaultDisplay(); + final Point size = new Point(); + disp.getRealSize(size); + return new Rect(0, 0, size.x, size.y); + } + + public int getRotation() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + return wm.getDefaultDisplay().getRotation(); + } + } + + @TargetApi(Build.VERSION_CODES.S) + private static class AndroidSScreenCompat implements ScreenCompat { + @SuppressLint("StaticFieldLeak") + private static Context sWindowContext; + + private static Context getWindowContext() { + if (sWindowContext == null) { + final DisplayManager displayManager = + (DisplayManager) getApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + final Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + sWindowContext = + getApplicationContext() + .createWindowContext(display, WindowManager.LayoutParams.TYPE_APPLICATION, null); + } + return sWindowContext; + } + + public Rect getScreenSize() { + final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class); + return windowManager.getCurrentWindowMetrics().getBounds(); + } + + public int getRotation() { + final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class); + return windowManager.getDefaultDisplay().getRotation(); + } + } + + static { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + sScreenCompat = new AndroidSScreenCompat(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + sScreenCompat = new JellyBeanMR1ScreenCompat(); + } else { + sScreenCompat = new JellyBeanScreenCompat(); + } + } + + /* package */ static Rect getScreenSizeIgnoreOverride() { + return sScreenCompat.getScreenSize(); + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized Rect getScreenSize() { + if (sScreenSizeOverride != null) { + return sScreenSizeOverride; + } + + return getScreenSizeIgnoreOverride(); + } + + @WrapForJNI(calledFrom = "any") + public static int getAudioOutputFramesPerBuffer() { + final int DEFAULT = 512; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return DEFAULT; + } + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return DEFAULT; + } + final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); + if (prop == null) { + return DEFAULT; + } + return Integer.parseInt(prop); + } + + @WrapForJNI(calledFrom = "any") + public static int getAudioOutputSampleRate() { + final int DEFAULT = 44100; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return DEFAULT; + } + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return DEFAULT; + } + final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE); + if (prop == null) { + return DEFAULT; + } + return Integer.parseInt(prop); + } + + @WrapForJNI(calledFrom = "any") + public static void setCommunicationAudioModeOn(final boolean on) { + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return; + } + + try { + if (on) { + Log.e(LOGTAG, "Setting communication mode ON"); + // This shouldn't throw, but does throw NullPointerException on a very + // small number of devices. + am.startBluetoothSco(); + am.setBluetoothScoOn(true); + } else { + Log.e(LOGTAG, "Setting communication mode OFF"); + am.stopBluetoothSco(); + am.setBluetoothScoOn(false); + } + } catch (final SecurityException | NullPointerException e) { + Log.e(LOGTAG, "could not set communication mode", e); + } + } + + private static String getLanguageTag(final Locale locale) { + final StringBuilder out = new StringBuilder(locale.getLanguage()); + final String country = locale.getCountry(); + final String variant = locale.getVariant(); + if (!TextUtils.isEmpty(country)) { + out.append('-').append(country); + } + if (!TextUtils.isEmpty(variant)) { + out.append('-').append(variant); + } + // e.g. "en", "en-US", or "en-US-POSIX". + return out.toString(); + } + + @WrapForJNI + public static String[] getDefaultLocales() { + // XXX We may have to convert some language codes such as "id" vs "in". + if (Build.VERSION.SDK_INT >= 24) { + final LocaleList localeList = LocaleList.getDefault(); + final String[] locales = new String[localeList.size()]; + for (int i = 0; i < localeList.size(); i++) { + locales[i] = localeList.get(i).toLanguageTag(); + } + return locales; + } + final String[] locales = new String[1]; + final Locale locale = Locale.getDefault(); + if (Build.VERSION.SDK_INT >= 21) { + locales[0] = locale.toLanguageTag(); + return locales; + } + + locales[0] = getLanguageTag(locale); + return locales; + } + + public static void setIs24HourFormat(final Boolean is24HourFormat) { + sIs24HourFormat = is24HourFormat; + } + + @WrapForJNI + public static boolean getIs24HourFormat() { + return sIs24HourFormat; + } + + @WrapForJNI + public static String getAppName() { + final Context context = getApplicationContext(); + final ApplicationInfo info = context.getApplicationInfo(); + final int id = info.labelRes; + return id == 0 ? info.nonLocalizedLabel.toString() : context.getString(id); + } + + @WrapForJNI(calledFrom = "gecko") + private static int getMemoryUsage(final String stateName) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // No API to get Java heap usages. + return -1; + } + + final Debug.MemoryInfo memInfo = new Debug.MemoryInfo(); + Debug.getMemoryInfo(memInfo); + final String usage = memInfo.getMemoryStat(stateName); + if (usage == null) { + return -1; + } + try { + return Integer.parseInt(usage); + } catch (final NumberFormatException e) { + return -1; + } + } + + @WrapForJNI + public static native boolean isParentProcess(); + + /** + * Returns a GeckoResult that will be completed to true if the GPU process is enabled and false if + * it is disabled. + */ + @WrapForJNI + public static native GeckoResult isGpuProcessEnabled(); + + @SuppressLint("NewApi") + public static boolean isIsolatedProcess() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + return false; + } + // This method was added in SDK 16 but remained hidden until SDK 28, meaning we are okay to call + // this on any SDK level but must suppress the new API lint. + return android.os.Process.isIsolated(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java new file mode 100644 index 0000000000..19f489b399 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java @@ -0,0 +1,200 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; + +public class GeckoBatteryManager extends BroadcastReceiver { + private static final String LOGTAG = "GeckoBatteryManager"; + + // Those constants should be keep in sync with the ones in: + // dom/battery/Constants.h + private static final double kDefaultLevel = 1.0; + private static final boolean kDefaultCharging = true; + private static final double kDefaultRemainingTime = 0.0; + private static final double kUnknownRemainingTime = -1.0; + + private static long sLastLevelChange; + private static boolean sNotificationsEnabled; + private static double sLevel = kDefaultLevel; + private static boolean sCharging = kDefaultCharging; + private static double sRemainingTime = kDefaultRemainingTime; + + private static final GeckoBatteryManager sInstance = new GeckoBatteryManager(); + + private final IntentFilter mFilter; + private Context mApplicationContext; + private boolean mIsEnabled; + + public static GeckoBatteryManager getInstance() { + return sInstance; + } + + private GeckoBatteryManager() { + mFilter = new IntentFilter(); + mFilter.addAction(Intent.ACTION_BATTERY_CHANGED); + } + + public synchronized void start(final Context context) { + if (mIsEnabled) { + Log.w(LOGTAG, "Already started!"); + return; + } + + mApplicationContext = context.getApplicationContext(); + // registerReceiver will return null if registering fails. + if (mApplicationContext.registerReceiver(this, mFilter) == null) { + Log.e(LOGTAG, "Registering receiver failed"); + } else { + mIsEnabled = true; + } + } + + public synchronized void stop() { + if (!mIsEnabled) { + Log.w(LOGTAG, "Already stopped!"); + return; + } + + mApplicationContext.unregisterReceiver(this); + mApplicationContext = null; + mIsEnabled = false; + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void onBatteryChange(double level, boolean charging, double remainingTime); + + @Override + public void onReceive(final Context context, final Intent intent) { + if (!intent.getAction().equals(Intent.ACTION_BATTERY_CHANGED)) { + Log.e(LOGTAG, "Got an unexpected intent!"); + return; + } + + final boolean previousCharging = isCharging(); + final double previousLevel = getLevel(); + + // NOTE: it might not be common (in 2012) but technically, Android can run + // on a device that has no battery so we want to make sure it's not the case + // before bothering checking for battery state. + // However, the Galaxy Nexus phone advertises itself as battery-less which + // force us to special-case the logic. + // See the Google bug: https://code.google.com/p/android/issues/detail?id=22035 + if (intent.getBooleanExtra(BatteryManager.EXTRA_PRESENT, false) + || Build.MODEL.equals("Galaxy Nexus")) { + final int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); + if (plugged == -1) { + sCharging = kDefaultCharging; + Log.e(LOGTAG, "Failed to get the plugged status!"); + } else { + // Likely, if plugged > 0, it's likely plugged and charging but the doc + // isn't clear about that. + sCharging = plugged != 0; + } + + if (sCharging != previousCharging) { + sRemainingTime = kUnknownRemainingTime; + // The new remaining time is going to take some time to show up but + // it's the best way to show a not too wrong value. + sLastLevelChange = 0; + } + + // We need two doubles because sLevel is a double. + final double current = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + final double max = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (current == -1 || max == -1) { + Log.e(LOGTAG, "Failed to get battery level!"); + sLevel = kDefaultLevel; + } else { + sLevel = current / max; + } + + if (sLevel == 1.0 && sCharging) { + sRemainingTime = kDefaultRemainingTime; + } else if (sLevel != previousLevel) { + // Estimate remaining time. + if (sLastLevelChange != 0) { + // Use elapsedRealtime() because we want to track time across device sleeps. + final long currentTime = SystemClock.elapsedRealtime(); + final long dt = (currentTime - sLastLevelChange) / 1000; + final double dLevel = sLevel - previousLevel; + + if (sCharging) { + if (dLevel < 0) { + sRemainingTime = kUnknownRemainingTime; + } else { + sRemainingTime = Math.round(dt / dLevel * (1.0 - sLevel)); + } + } else { + if (dLevel > 0) { + Log.w(LOGTAG, "When discharging, level should decrease!"); + sRemainingTime = kUnknownRemainingTime; + } else { + sRemainingTime = Math.round(dt / -dLevel * sLevel); + } + } + + sLastLevelChange = currentTime; + } else { + // That's the first time we got an update, we can't do anything. + sLastLevelChange = SystemClock.elapsedRealtime(); + } + } + } else { + sLevel = kDefaultLevel; + sCharging = kDefaultCharging; + sRemainingTime = kDefaultRemainingTime; + } + + /* + * We want to inform listeners if the following conditions are fulfilled: + * - we have at least one observer; + * - the charging state or the level has changed. + * + * Note: no need to check for a remaining time change given that it's only + * updated if there is a level change or a charging change. + * + * The idea is to prevent doing all the way to the DOM code in the child + * process to finally not send an event. + */ + if (sNotificationsEnabled + && (previousCharging != isCharging() || previousLevel != getLevel())) { + onBatteryChange(getLevel(), isCharging(), getRemainingTime()); + } + } + + public static boolean isCharging() { + return sCharging; + } + + public static double getLevel() { + return sLevel; + } + + public static double getRemainingTime() { + return sRemainingTime; + } + + public static void enableNotifications() { + sNotificationsEnabled = true; + } + + public static void disableNotifications() { + sNotificationsEnabled = false; + } + + public static double[] getCurrentInformation() { + return new double[] {getLevel(), isCharging() ? 1.0 : 0.0, getRemainingTime()}; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java new file mode 100644 index 0000000000..8a76548c1d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java @@ -0,0 +1,456 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.graphics.RectF; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * GeckoEditableChild implements the Gecko-facing side of IME operation. Each nsWindow in the main + * process and each PuppetWidget in each child content process has an instance of + * GeckoEditableChild, which communicates with the GeckoEditableParent instance in the main process. + */ +public final class GeckoEditableChild extends JNIObject implements IGeckoEditableChild { + + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoEditableChild"; + + private static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9; + + private final class RemoteChild extends IGeckoEditableChild.Stub { + @Override // IGeckoEditableChild + public void transferParent(final IGeckoEditableParent editableParent) { + GeckoEditableChild.this.transferParent(editableParent); + } + + @Override // IGeckoEditableChild + public void onKeyEvent( + final int action, + final int keyCode, + final int scanCode, + final int metaState, + final int keyPressMetaState, + final long time, + final int domPrintableKeyValue, + final int repeatCount, + final int flags, + final boolean isSynthesizedImeKey, + final KeyEvent event) { + GeckoEditableChild.this.onKeyEvent( + action, + keyCode, + scanCode, + metaState, + keyPressMetaState, + time, + domPrintableKeyValue, + repeatCount, + flags, + isSynthesizedImeKey, + event); + } + + @Override // IGeckoEditableChild + public void onImeSynchronize() { + GeckoEditableChild.this.onImeSynchronize(); + } + + @Override // IGeckoEditableChild + public void onImeReplaceText(final int start, final int end, final String text) { + GeckoEditableChild.this.onImeReplaceText(start, end, text); + } + + @Override // IGeckoEditableChild + public void onImeInsertImage(final byte[] data, final String mimeType) { + GeckoEditableChild.this.onImeInsertImage(data, mimeType); + } + + @Override // IGeckoEditableChild + public void onImeAddCompositionRange( + final int start, + final int end, + final int rangeType, + final int rangeStyles, + final int rangeLineStyle, + final boolean rangeBoldLine, + final int rangeForeColor, + final int rangeBackColor, + final int rangeLineColor) { + GeckoEditableChild.this.onImeAddCompositionRange( + start, + end, + rangeType, + rangeStyles, + rangeLineStyle, + rangeBoldLine, + rangeForeColor, + rangeBackColor, + rangeLineColor); + } + + @Override // IGeckoEditableChild + public void onImeUpdateComposition(final int start, final int end, final int flags) { + GeckoEditableChild.this.onImeUpdateComposition(start, end, flags); + } + + @Override // IGeckoEditableChild + public void onImeRequestCursorUpdates(final int requestMode) { + GeckoEditableChild.this.onImeRequestCursorUpdates(requestMode); + } + + @Override // IGeckoEditableChild + public void onImeRequestCommit() { + GeckoEditableChild.this.onImeRequestCommit(); + } + } + + private final IGeckoEditableChild mEditableChild; + private final boolean mIsDefault; + + private IGeckoEditableParent mEditableParent; + private int mCurrentTextLength; // Used by Gecko thread + + @WrapForJNI(calledFrom = "gecko") + private GeckoEditableChild( + @Nullable final IGeckoEditableParent editableParent, final boolean isDefault) { + mIsDefault = isDefault; + + if (editableParent != null + && editableParent.asBinder().queryLocalInterface(IGeckoEditableParent.class.getName()) + != null) { + // IGeckoEditableParent is local; i.e. we're in the main process. + mEditableChild = this; + } else { + // IGeckoEditableParent is remote; i.e. we're in a content process. + mEditableChild = new RemoteChild(); + } + + if (editableParent != null) { + setParent(editableParent); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void setParent(final IGeckoEditableParent editableParent) { + mEditableParent = editableParent; + + if (mIsDefault) { + // Tell the parent we're the default child. + try { + editableParent.setDefaultChild(mEditableChild); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Failed to set default child", e); + } + } + } + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void transferParent(IGeckoEditableParent editableParent); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onKeyEvent( + int action, + int keyCode, + int scanCode, + int metaState, + int keyPressMetaState, + long time, + int domPrintableKeyValue, + int repeatCount, + int flags, + boolean isSynthesizedImeKey, + KeyEvent event); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeSynchronize(); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeReplaceText(int start, int end, String text); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeAddCompositionRange( + int start, + int end, + int rangeType, + int rangeStyles, + int rangeLineStyle, + boolean rangeBoldLine, + int rangeForeColor, + int rangeBackColor, + int rangeLineColor); + + // Don't update to the new composition if it's different than the current composition. + @WrapForJNI public static final int FLAG_KEEP_CURRENT_COMPOSITION = 1; + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeUpdateComposition(int start, int end, int flags); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeRequestCursorUpdates(int requestMode); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeRequestCommit(); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeInsertImage(byte[] data, String mimeType); + + @Override // JNIObject + protected void disposeNative() { + // Disposal happens in native code. + throw new UnsupportedOperationException(); + } + + @WrapForJNI(calledFrom = "gecko") + private boolean hasEditableParent() { + if (mEditableParent != null) { + return true; + } + Log.w(LOGTAG, "No editable parent"); + return false; + } + + @Override // IInterface + public IBinder asBinder() { + // Return the GeckoEditableParent's binder as fallback for comparison purposes. + return mEditableChild != this + ? mEditableChild.asBinder() + : hasEditableParent() ? mEditableParent.asBinder() : null; + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIME(final int type) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "notifyIME(" + type + ")"); + } + if (!hasEditableParent()) { + return; + } + if (type == NOTIFY_IME_TO_CANCEL_COMPOSITION) { + // Composition should have been canceled on the parent side through text + // update notifications. We cannot verify that here because we don't + // keep track of spans on the child side, but it's simple to add the + // check to the parent side if ever needed. + return; + } + + try { + mEditableParent.notifyIME(mEditableChild, type); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + return; + } + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIMEContext( + final int state, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + final int flags) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("notifyIMEContext("); + sb.append(state) + .append(", \"") + .append(typeHint) + .append("\", \"") + .append(modeHint) + .append("\", \"") + .append(actionHint) + .append("\", \"") + .append(autocapitalize) + .append("\", 0x") + .append(Integer.toHexString(flags)) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.notifyIMEContext( + mEditableChild.asBinder(), state, typeHint, modeHint, actionHint, autocapitalize, flags); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onSelectionChange( + final int start, final int end, final boolean causedOnlyByComposition) + throws RemoteException { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("onSelectionChange("); + sb.append(start) + .append(", ") + .append(end) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + final int currentLength = mCurrentTextLength; + if (start < 0 || start > currentLength || end < 0 || end > currentLength) { + Log.e( + LOGTAG, + "invalid selection notification range: " + + start + + " to " + + end + + ", length: " + + currentLength); + throw new IllegalArgumentException("invalid selection notification range"); + } + + mEditableParent.onSelectionChange( + mEditableChild.asBinder(), start, end, causedOnlyByComposition); + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onTextChange( + final CharSequence text, + final int start, + final int unboundedOldEnd, + final int unboundedNewEnd, + final boolean causedOnlyByComposition) + throws RemoteException { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("onTextChange("); + sb.append(text) + .append(", ") + .append(start) + .append(", ") + .append(unboundedOldEnd) + .append(", ") + .append(unboundedNewEnd) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + if (start < 0 || start > unboundedOldEnd) { + Log.e(LOGTAG, "invalid text notification range: " + start + " to " + unboundedOldEnd); + throw new IllegalArgumentException("invalid text notification range"); + } + + /* For the "end" parameters, Gecko can pass in a large + number to denote "end of the text". Fix that here */ + final int currentLength = mCurrentTextLength; + final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd; + // new end should always match text + if (unboundedOldEnd <= currentLength && unboundedNewEnd != (start + text.length())) { + Log.e( + LOGTAG, + "newEnd does not match text: " + unboundedNewEnd + " vs " + (start + text.length())); + throw new IllegalArgumentException("newEnd does not match text"); + } + + mCurrentTextLength += start + text.length() - oldEnd; + // Need unboundedOldEnd so GeckoEditable can distinguish changed text vs cleared text. + if (text.length() == 0) { + // Remove text in range. + mEditableParent.onTextChange( + mEditableChild.asBinder(), text, start, unboundedOldEnd, causedOnlyByComposition); + return; + } + // Using large text causes TransactionTooLargeException, so split text data. + int offset = 0; + int newUnboundedOldEnd = unboundedOldEnd; + while (offset < text.length()) { + final int end = Math.min(offset + 1024 * 64 /* 64KB */, text.length()); + mEditableParent.onTextChange( + mEditableChild.asBinder(), + text.subSequence(offset, end), + start + offset, + newUnboundedOldEnd, + causedOnlyByComposition); + offset = end; + newUnboundedOldEnd = start + offset; + } + } + + @WrapForJNI(calledFrom = "gecko") + private void onDefaultKeyEvent(final KeyEvent event) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("onDefaultKeyEvent("); + sb.append("action=") + .append(event.getAction()) + .append(", ") + .append("keyCode=") + .append(event.getKeyCode()) + .append(", ") + .append("metaState=") + .append(event.getMetaState()) + .append(", ") + .append("time=") + .append(event.getEventTime()) + .append(", ") + .append("repeatCount=") + .append(event.getRepeatCount()) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.onDefaultKeyEvent(mEditableChild.asBinder(), event); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void updateCompositionRects(final RectF[] rects, final RectF caretRect) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")"); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.updateCompositionRects(mEditableChild.asBinder(), rects, caretRect); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java new file mode 100644 index 0000000000..0e18cec515 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java @@ -0,0 +1,807 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.os.Build; +import android.os.Looper; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.GeckoResult; + +/** + * Takes samples and adds markers for Java threads for the Gecko profiler. + * + *

    This class is thread safe because it uses synchronized on accesses to its mutable state. One + * exception is {@link #isProfilerActive()}: see the javadoc for details. + */ +public class GeckoJavaSampler { + private static final String LOGTAG = "GeckoJavaSampler"; + + /** + * The thread ID to use for the main thread instead of its true thread ID. + * + *

    The main thread is sampled twice: once for native code and once on the JVM. The native + * version uses the thread's id so we replace it to avoid a collision. We use this thread ID + * because it's unlikely any other thread currently has it. We can't use 0 because 0 is considered + * "unspecified" in native code: + * https://searchfox.org/mozilla-central/rev/d4ebb53e719b913afdbcf7c00e162f0e96574701/mozglue/baseprofiler/public/BaseProfilerUtils.h#194 + */ + private static final long REPLACEMENT_MAIN_THREAD_ID = 1; + + /** + * The thread name to use for the main thread instead of its true thread name. The name is "main", + * which is ambiguous with the JS main thread, so we rename it to match the C++ replacement. We + * expect our code to later add a suffix to avoid a collision with the C++ thread name. See {@link + * #REPLACEMENT_MAIN_THREAD_ID} for related details. + */ + private static final String REPLACEMENT_MAIN_THREAD_NAME = "AndroidUI"; + + @GuardedBy("GeckoJavaSampler.class") + private static SamplingRunnable sSamplingRunnable; + + @GuardedBy("GeckoJavaSampler.class") + private static ScheduledExecutorService sSamplingScheduler; + + // See isProfilerActive for details on the AtomicReference. + @GuardedBy("GeckoJavaSampler.class") + private static final AtomicReference> sSamplingFuture = + new AtomicReference<>(); + + private static final MarkerStorage sMarkerStorage = new MarkerStorage(); + + /** + * Returns true if profiler is running and unpaused at the moment which means it's allowed to add + * a marker. + * + *

    Thread policy: we want this method to be inexpensive (i.e. non-blocking) because we want to + * be able to use it in performance-sensitive code. That's why we rely on an AtomicReference. If + * this requirement didn't exist, the AtomicReference could be removed because the class thread + * policy is to call synchronized on mutable state access. + */ + public static boolean isProfilerActive() { + // This value will only be present if the profiler is started and not paused. + return sSamplingFuture.get() != null; + } + + // Use the same timer primitive as the profiler + // to get a perfect sample syncing. + @WrapForJNI + private static native double getProfilerTime(); + + /** Try to get the profiler time. Returns null if profiler is not running. */ + public static @Nullable Double tryToGetProfilerTime() { + if (!isProfilerActive()) { + // Android profiler hasn't started yet. + return null; + } + if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) { + // getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + return null; + } + + return getProfilerTime(); + } + + /** + * A data container for a profiler sample. This class is effectively immutable (i.e. technically + * mutable but never mutated after construction) so is thread safe *if it is safely published* + * (see Java Concurrency in Practice, 2nd Ed., Section 3.5.3 for safe publication idioms). + */ + private static class Sample { + public final long mThreadId; + public final Frame[] mFrames; + public final double mTime; + public final long mJavaTime; // non-zero if Android system time is used + + public Sample(final long aThreadId, final StackTraceElement[] aStack) { + mThreadId = aThreadId; + mFrames = new Frame[aStack.length]; + mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + + for (int i = 0; i < aStack.length; i++) { + mFrames[aStack.length - 1 - i] = + new Frame(aStack[i].getMethodName(), aStack[i].getClassName()); + } + } + } + + /** + * A container for the metadata around a call in a stack. This class is thread safe by being + * immutable. + */ + private static class Frame { + public final String methodName; + public final String className; + + private Frame(final String methodName, final String className) { + this.methodName = methodName; + this.className = className; + } + } + + /** A data container for thread metadata. */ + private static class ThreadInfo { + private final long mId; + private final String mName; + + public ThreadInfo(final long mId, final String mName) { + this.mId = mId; + this.mName = mName; + } + + @WrapForJNI + public long getId() { + return mId; + } + + @WrapForJNI + public String getName() { + return mName; + } + } + + /** + * A data container for metadata around a marker. This class is thread safe by being immutable. + */ + private static class Marker extends JNIObject { + /** The id of the thread this marker was captured on. */ + private final long mThreadId; + + /** Name of the marker */ + private final String mMarkerName; + + /** Either start time for the duration markers or time for a point-in-time markers. */ + private final double mTime; + + /** + * A fallback field of {@link #mTime} but it only exists when {@link #getProfilerTime()} is + * failed. It is non-zero if Android time is used. + */ + private final long mJavaTime; + + /** End time for the duration markers. It's zero for point-in-time markers. */ + private final double mEndTime; + + /** + * A fallback field of {@link #mEndTime} but it only exists when {@link #getProfilerTime()} is + * failed. It is non-zero if Android time is used. + */ + private final long mEndJavaTime; + + /** A nullable additional information field for the marker. */ + private @Nullable final String mText; + + /** + * Constructor for the Marker class. It initializes different kinds of markers depending on the + * parameters. Here are some combinations to create different kinds of markers: + * + *

    If you want to create a marker that points a single point in time: + * new Marker("name", null, null, null) to implicitly get the time when this marker is + * added, or new Marker("name", null, endTime, null) 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: + * new Marker("name", startTime, null, null) to implicitly get the end time when this + * marker is added, or new Marker("name", startTime, endTime, null) 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 aThreadId The id of the thread this marker was captured on. + * @param aMarkerName Identifier of the marker as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time. + * @param aText An optional string field for more information about the marker. + */ + public Marker( + final long aThreadId, + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + mThreadId = getAdjustedThreadId(aThreadId); + mMarkerName = aMarkerName; + mText = aText; + + if (aStartTime != null) { + // Start time is provided. This is an interval marker. + mTime = aStartTime; + mJavaTime = 0; + if (aEndTime != null) { + // End time is also provided. + mEndTime = aEndTime; + mEndJavaTime = 0; + } else { + // End time is not provided. Get the profiler time now and use it. + mEndTime = + GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mEndTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mEndJavaTime = mEndTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + } + + } else { + // Start time is not provided. This is point-in-time marker. + mEndTime = 0; + mEndJavaTime = 0; + + if (aEndTime != null) { + // End time is also provided. Use that to point the time. + mTime = aEndTime; + mJavaTime = 0; + } else { + mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + } + } + } + + @WrapForJNI + @Override // JNIObject + protected native void disposeNative(); + + @WrapForJNI + public double getStartTime() { + if (mJavaTime != 0) { + return (mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return mTime; + } + + @WrapForJNI + public double getEndTime() { + if (mEndJavaTime != 0) { + return (mEndJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return mEndTime; + } + + @WrapForJNI + public long getThreadId() { + return mThreadId; + } + + @WrapForJNI + public @NonNull String getMarkerName() { + return mMarkerName; + } + + @WrapForJNI + public @Nullable String getMarkerText() { + return mText; + } + } + + /** + * Public method to add a new marker to Gecko profiler. This can be used to add a marker *inside* + * the geckoview code, but ideally ProfilerController methods should be used instead. + * + * @see Marker#Marker(long, String, Double, Double, String) for information about the parameter + * options. + */ + public static void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + sMarkerStorage.addMarker(aMarkerName, aStartTime, aEndTime, aText); + } + + /** + * A routine to store profiler samples. This class is thread safe because it synchronizes access + * to its mutable state. + */ + private static class SamplingRunnable implements Runnable { + private final long mMainThreadId = Looper.getMainLooper().getThread().getId(); + + // Sampling interval that is used by start and unpause + public final int mInterval; + private final int mSampleCount; + + @GuardedBy("GeckoJavaSampler.class") + private boolean mBufferOverflowed = false; + + @GuardedBy("GeckoJavaSampler.class") + private @NonNull final List mThreadsToProfile; + + @GuardedBy("GeckoJavaSampler.class") + private final Sample[] mSamples; + + @GuardedBy("GeckoJavaSampler.class") + private int mSamplePos; + + public SamplingRunnable( + @NonNull final List aThreadsToProfile, + final int aInterval, + final int aSampleCount) { + mThreadsToProfile = aThreadsToProfile; + // Sanity check of sampling interval. + mInterval = Math.max(1, aInterval); + mSampleCount = aSampleCount; + mSamples = new Sample[mSampleCount]; + mSamplePos = 0; + } + + @Override + public void run() { + synchronized (GeckoJavaSampler.class) { + // To minimize allocation in the critical section, we use a traditional for loop instead of + // a for each (i.e. `elem : coll`) loop because that allocates an iterator. + // + // We won't capture threads that are started during profiling because we iterate through an + // unchanging list of threads (bug 1759550). + for (int i = 0; i < mThreadsToProfile.size(); i++) { + final Thread thread = mThreadsToProfile.get(i); + + // getStackTrace will return an empty trace if the thread is not alive: we call continue + // to avoid wasting space in the buffer for an empty sample. + final StackTraceElement[] stackTrace = thread.getStackTrace(); + if (stackTrace.length == 0) { + continue; + } + + mSamples[mSamplePos] = new Sample(thread.getId(), stackTrace); + mSamplePos += 1; + if (mSamplePos == mSampleCount) { + // Sample array is full now, go back to start of + // the array and override old samples + mSamplePos = 0; + mBufferOverflowed = true; + } + } + } + } + + private Sample getSample(final int aSampleId) { + synchronized (GeckoJavaSampler.class) { + if (aSampleId >= mSampleCount) { + // Return early because there is no more sample left. + return null; + } + + int samplePos = aSampleId; + if (mBufferOverflowed) { + // This is a circular buffer and the buffer is overflowed. Start + // of the buffer is mSamplePos now. Calculate the real index. + samplePos = (samplePos + mSamplePos) % mSampleCount; + } + + // Since the array elements are initialized to null, it will return + // null whenever we access to an element that's not been written yet. + // We want it to return null in that case, so it's okay. + return mSamples[samplePos]; + } + } + } + + /** + * Returns the sample with the given sample ID. + * + *

    Thread safety code smell: this method call is synchronized but this class returns a + * reference to an effectively immutable object so that the reference is accessible after + * synchronization ends. It's unclear if this is thread safe. However, this is safe with the + * current callers (because they are all synchronized and don't leak the Sample) so we don't + * investigate it further. + */ + private static synchronized Sample getSample(final int aSampleId) { + return sSamplingRunnable.getSample(aSampleId); + } + + @WrapForJNI + public static Marker pollNextMarker() { + return sMarkerStorage.pollNextMarker(); + } + + @WrapForJNI + public static synchronized int getRegisteredThreadCount() { + return sSamplingRunnable.mThreadsToProfile.size(); + } + + @WrapForJNI + public static synchronized ThreadInfo getRegisteredThreadInfo(final int aIndex) { + final Thread thread = sSamplingRunnable.mThreadsToProfile.get(aIndex); + + // See REPLACEMENT_MAIN_THREAD_NAME for why we do this. + String adjustedThreadName = + thread.getId() == sSamplingRunnable.mMainThreadId + ? REPLACEMENT_MAIN_THREAD_NAME + : thread.getName(); + + // To distinguish JVM threads from native threads, we append a JVM-specific suffix. + adjustedThreadName += " (JVM)"; + return new ThreadInfo(getAdjustedThreadId(thread.getId()), adjustedThreadName); + } + + @WrapForJNI + public static synchronized long getThreadId(final int aSampleId) { + final Sample sample = getSample(aSampleId); + return getAdjustedThreadId(sample != null ? sample.mThreadId : 0); + } + + private static synchronized long getAdjustedThreadId(final long threadId) { + // See REPLACEMENT_MAIN_THREAD_ID for why we do this. + return threadId == sSamplingRunnable.mMainThreadId ? REPLACEMENT_MAIN_THREAD_ID : threadId; + } + + @WrapForJNI + public static synchronized double getSampleTime(final int aSampleId) { + final Sample sample = getSample(aSampleId); + if (sample != null) { + if (sample.mJavaTime != 0) { + return (sample.mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return sample.mTime; + } + return 0; + } + + @WrapForJNI + public static synchronized String getFrameName(final int aSampleId, final int aFrameId) { + final Sample sample = getSample(aSampleId); + if (sample != null && aFrameId < sample.mFrames.length) { + final Frame frame = sample.mFrames[aFrameId]; + if (frame == null) { + return null; + } + return frame.className + "." + frame.methodName + "()"; + } + return null; + } + + /** + * A start/stop-aware container for storing profiler markers. + * + *

    This class is thread safe: see {@link #mMarkers} and other member variables for the + * threading policy. Start/stop are guaranteed to execute in the order they are called but other + * methods do not have such ordering guarantees. + */ + private static class MarkerStorage { + /** + * The underlying storage for the markers. This field maintains thread safety without using + * synchronized everywhere by: + *

  • - using volatile to allow non-blocking reads + *
  • - leveraging a thread safe collection when accessing the underlying data + *
  • - looping until success for compound read-write operations + */ + private volatile Queue mMarkers; + + /** + * The thread ids of the threads we're profiling. This field maintains thread safety by writing + * a read-only value to this volatile field before concurrency begins and only reading it during + * concurrent sections. + */ + private volatile Set mProfiledThreadIds = Collections.emptySet(); + + MarkerStorage() {} + + public synchronized void start(final int aMarkerCount, final List aProfiledThreads) { + if (this.mMarkers != null) { + return; + } + this.mMarkers = new LinkedBlockingQueue<>(aMarkerCount); + + final Set profiledThreadIds = new HashSet<>(aProfiledThreads.size()); + for (final Thread thread : aProfiledThreads) { + profiledThreadIds.add(thread.getId()); + } + + // We use a temporary collection, rather than mutating the collection within the member + // variable, to ensure the collection is fully written before the state is made available to + // all threads via the volatile write into the member variable. This collection must be + // read-only for it to remain thread safe. + mProfiledThreadIds = Collections.unmodifiableSet(profiledThreadIds); + } + + public synchronized void stop() { + if (this.mMarkers == null) { + return; + } + this.mMarkers = null; + mProfiledThreadIds = Collections.emptySet(); + } + + private void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + final Queue markersQueue = this.mMarkers; + if (markersQueue == null) { + // Profiler is not active. + return; + } + + final long threadId = Thread.currentThread().getId(); + if (!mProfiledThreadIds.contains(threadId)) { + return; + } + + final Marker newMarker = new Marker(threadId, aMarkerName, aStartTime, aEndTime, aText); + boolean successful = markersQueue.offer(newMarker); + while (!successful) { + // Marker storage is full, remove the head and add again. + markersQueue.poll(); + successful = markersQueue.offer(newMarker); + } + } + + private Marker pollNextMarker() { + final Queue markersQueue = this.mMarkers; + if (markersQueue == null) { + // Profiler is not active. + return null; + } + // Retrieve and return the head of this queue. + // Returns null if the queue is empty. + return markersQueue.poll(); + } + } + + @WrapForJNI + public static void start( + @NonNull final Object[] aFilters, final int aInterval, final int aEntryCount) { + synchronized (GeckoJavaSampler.class) { + if (sSamplingRunnable != null) { + return; + } + + final ScheduledFuture future = sSamplingFuture.get(); + if (future != null && !future.isDone()) { + return; + } + + Log.i(LOGTAG, "Profiler starting. Calling thread: " + Thread.currentThread().getName()); + + // Setting a limit of 120000 (2 mins with 1ms interval) for samples and markers for now + // to make sure we are not allocating too much. + final int limitedEntryCount = Math.min(aEntryCount, 120000); + + final List threadsToProfile = getThreadsToProfile(aFilters); + if (threadsToProfile.size() < 1) { + throw new IllegalStateException("Expected >= 1 thread to profile (main thread)."); + } + Log.i(LOGTAG, "Number of threads to profile: " + threadsToProfile.size()); + + sSamplingRunnable = new SamplingRunnable(threadsToProfile, aInterval, limitedEntryCount); + sMarkerStorage.start(limitedEntryCount, threadsToProfile); + sSamplingScheduler = Executors.newSingleThreadScheduledExecutor(); + sSamplingFuture.set( + sSamplingScheduler.scheduleAtFixedRate( + sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS)); + } + } + + private static @NonNull List getThreadsToProfile(final Object[] aFilters) { + // Clean up filters. + final List cleanedFilters = new ArrayList<>(); + for (final Object rawFilter : aFilters) { + // aFilters is a String[] but jni can only accept Object[] so we're forced to cast. + // + // We could pass the lowercased filters from native code but it may not handle lowercasing the + // same way Java does so we lower case here so it's consistent later when we lower case the + // thread name and compare against it. + final String filter = ((String) rawFilter).trim().toLowerCase(Locale.US); + + // If the filter is empty, it's not meaningful: skip. + if (filter.isEmpty()) { + continue; + } + + cleanedFilters.add(filter); + } + + final ThreadGroup rootThreadGroup = getRootThreadGroup(); + final Thread[] activeThreads = getActiveThreads(rootThreadGroup); + final Thread mainThread = Looper.getMainLooper().getThread(); + + // We model these catch-all filters after the C++ code (which we should eventually deduplicate): + // https://searchfox.org/mozilla-central/rev/b0779bcc485dc1c04334dfb9ea024cbfff7b961a/tools/profiler/core/platform.cpp#778-801 + if (cleanedFilters.contains("*") || doAnyFiltersMatchPid(cleanedFilters, Process.myPid())) { + final List activeThreadList = new ArrayList<>(); + Collections.addAll(activeThreadList, activeThreads); + if (!activeThreadList.contains(mainThread)) { + activeThreadList.add(mainThread); // see below for why this is necessary. + } + return activeThreadList; + } + + // We always want to profile the main thread. We're not certain getActiveThreads returns + // all active threads since we've observed that getActiveThreads doesn't include the main thread + // during xpcshell tests even though it's alive (bug 1760716). We intentionally don't rely on + // that method to add the main thread here. + final List threadsToProfile = new ArrayList<>(); + threadsToProfile.add(mainThread); + + for (final Thread thread : activeThreads) { + if (shouldProfileThread(thread, cleanedFilters, mainThread)) { + threadsToProfile.add(thread); + } + } + return threadsToProfile; + } + + private static boolean shouldProfileThread( + final Thread aThread, final List aFilters, final Thread aMainThread) { + final String threadName = aThread.getName().trim().toLowerCase(Locale.US); + if (threadName.isEmpty()) { + return false; // We can't match against a thread with no name: skip. + } + + if (aThread.equals(aMainThread)) { + return false; // We've already added the main thread outside of this method. + } + + for (final String filter : aFilters) { + // In order to generically support thread pools with thread names like "arch_disk_io_0" (the + // kotlin IO dispatcher), we check if the filter is inside the thread name (e.g. a filter of + // "io" will match all of the threads in that pool) rather than an equality check. + if (threadName.contains(filter)) { + return true; + } + } + + return false; + } + + private static boolean doAnyFiltersMatchPid( + @NonNull final List aFilters, final long aPid) { + final String prefix = "pid:"; + for (final String filter : aFilters) { + if (!filter.startsWith(prefix)) { + continue; + } + + try { + final long filterPid = Long.parseLong(filter.substring(prefix.length())); + if (filterPid == aPid) { + return true; + } + } catch (final NumberFormatException e) { + /* do nothing. */ + } + } + + return false; + } + + private static @NonNull Thread[] getActiveThreads(final @NonNull ThreadGroup rootThreadGroup) { + // We need the root thread group to get all of the active threads because of how + // ThreadGroup.enumerate works. + // + // ThreadGroup.enumerate is inherently racey so we loop until we capture all of the active + // threads. We can only detect if we didn't capture all of the threads if the number of threads + // found (the value returned by enumerate) is smaller than the array we're capturing them in. + // Therefore, we make the array slightly larger than the known number of threads. + Thread[] allThreads; + int threadsFound; + do { + allThreads = new Thread[rootThreadGroup.activeCount() + 15]; + threadsFound = rootThreadGroup.enumerate(allThreads, /* recurse */ true); + } while (threadsFound >= allThreads.length); + + // There will be more indices in the array than threads and these will be set to null. We remove + // the null values to minimize bugs. + return Arrays.copyOfRange(allThreads, 0, threadsFound); + } + + private static @NonNull ThreadGroup getRootThreadGroup() { + // Assert non-null: getThreadGroup only returns null for dead threads but the current thread + // can't be dead. + ThreadGroup parentGroup = Objects.requireNonNull(Thread.currentThread().getThreadGroup()); + + ThreadGroup group = null; + while (parentGroup != null) { + group = parentGroup; + parentGroup = group.getParent(); + } + return group; + } + + @WrapForJNI + public static void pauseSampling() { + synchronized (GeckoJavaSampler.class) { + final ScheduledFuture future = sSamplingFuture.getAndSet(null); + future.cancel(false /* mayInterruptIfRunning */); + } + } + + @WrapForJNI + public static void unpauseSampling() { + synchronized (GeckoJavaSampler.class) { + if (sSamplingFuture.get() != null) { + return; + } + sSamplingFuture.set( + sSamplingScheduler.scheduleAtFixedRate( + sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS)); + } + } + + @WrapForJNI + public static void stop() { + synchronized (GeckoJavaSampler.class) { + if (sSamplingRunnable == null) { + return; + } + + Log.i( + LOGTAG, + "Profiler stopping. Sample array position: " + + sSamplingRunnable.mSamplePos + + ". Overflowed? " + + sSamplingRunnable.mBufferOverflowed); + + try { + sSamplingScheduler.shutdown(); + // 1s is enough to wait shutdown. + sSamplingScheduler.awaitTermination(1000, TimeUnit.MILLISECONDS); + } catch (final InterruptedException e) { + Log.e(LOGTAG, "Sampling scheduler isn't terminated. Last sampling data might be broken."); + sSamplingScheduler.shutdownNow(); + } + sSamplingScheduler = null; + sSamplingRunnable = null; + sSamplingFuture.set(null); + sMarkerStorage.stop(); + } + } + + @WrapForJNI(dispatchTo = "gecko", stubName = "StartProfiler") + private static native void startProfilerNative(String[] aFilters, String[] aFeaturesArr); + + @WrapForJNI(dispatchTo = "gecko", stubName = "StopProfiler") + private static native void stopProfilerNative(GeckoResult aResult); + + public static void startProfiler(final String[] aFilters, final String[] aFeaturesArr) { + startProfilerNative(aFilters, aFeaturesArr); + } + + public static GeckoResult stopProfiler() { + final GeckoResult result = new GeckoResult(); + stopProfilerNative(result); + return result; + } + + /** Returns the device brand and model as a string. */ + @WrapForJNI + public static String getDeviceInformation() { + final StringBuilder sb = new StringBuilder(Build.BRAND); + sb.append(" "); + sb.append(Build.MODEL); + return sb.toString(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java new file mode 100644 index 0000000000..02ed848f6b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java @@ -0,0 +1,413 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.DhcpInfo; +import android.net.wifi.WifiManager; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.NetworkUtils; +import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType; +import org.mozilla.gecko.util.NetworkUtils.ConnectionType; +import org.mozilla.gecko.util.NetworkUtils.NetworkStatus; + +/** + * Provides connection type, subtype and general network status (up/down). + * + *

    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 { + private static final String LOGTAG = "GeckoNetworkManager"; + + // If network configuration and/or status changed, we send details of what changed. + // If we received a "check out new network state!" intent from the OS but nothing in it looks + // different, we ignore it. See Bug 1330836 for some relevant details. + private static final String LINK_DATA_CHANGED = "changed"; + + private static GeckoNetworkManager instance; + + // We hackishly (yet harmlessly, in this case) keep a Context reference passed in via the start + // method. + // See context handling notes in handleManagerEvent, and Bug 1277333. + private Context mContext; + + public static void destroy() { + if (instance != null) { + instance.onDestroy(); + instance = null; + } + } + + public enum ManagerState { + OffNoListeners, + OffWithListeners, + OnNoListeners, + OnWithListeners + } + + public enum ManagerEvent { + start, + stop, + enableNotifications, + disableNotifications, + receivedUpdate + } + + private ManagerState mCurrentState = ManagerState.OffNoListeners; + private ConnectionType mCurrentConnectionType = ConnectionType.NONE; + private ConnectionType mPreviousConnectionType = ConnectionType.NONE; + private ConnectionSubType mCurrentConnectionSubtype = ConnectionSubType.UNKNOWN; + private ConnectionSubType mPreviousConnectionSubtype = ConnectionSubType.UNKNOWN; + private NetworkStatus mCurrentNetworkStatus = NetworkStatus.UNKNOWN; + private NetworkStatus mPreviousNetworkStatus = NetworkStatus.UNKNOWN; + + private GeckoNetworkManager() {} + + private void onDestroy() { + handleManagerEvent(ManagerEvent.stop); + } + + public static GeckoNetworkManager getInstance() { + if (instance == null) { + instance = new GeckoNetworkManager(); + } + + return instance; + } + + public double[] getCurrentInformation() { + final Context applicationContext = GeckoAppShell.getApplicationContext(); + final ConnectionType connectionType = mCurrentConnectionType; + return new double[] { + connectionType.value, + connectionType == ConnectionType.WIFI ? 1.0 : 0.0, + connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0 + }; + } + + @Override + public void onReceive(final Context aContext, final Intent aIntent) { + handleManagerEvent(ManagerEvent.receivedUpdate); + } + + public void start(final Context context) { + mContext = context; + handleManagerEvent(ManagerEvent.start); + } + + public void stop() { + handleManagerEvent(ManagerEvent.stop); + } + + public void enableNotifications() { + handleManagerEvent(ManagerEvent.enableNotifications); + } + + public void disableNotifications() { + handleManagerEvent(ManagerEvent.disableNotifications); + } + + /** + * For a given event, figure out the next state, run any transition by-product actions, and switch + * current state to the next state. If event is invalid for the current state, this is a no-op. + * + * @param event Incoming event + * @return Boolean indicating if transition was performed. + */ + private synchronized boolean handleManagerEvent(final ManagerEvent event) { + final ManagerState nextState = getNextState(mCurrentState, event); + + Log.d(LOGTAG, "Incoming event " + event + " for state " + mCurrentState + " -> " + nextState); + if (nextState == null) { + Log.w(LOGTAG, "Invalid event " + event + " for state " + mCurrentState); + return false; + } + + // We're being deliberately careful about handling context here; it's possible that in some + // rare cases and possibly related to timing of when this is called (seems to be early in the + // startup phase), + // GeckoAppShell.getApplicationContext() will be null, and .start() wasn't called yet, + // so we don't have a local Context reference either. If both of these are true, we have to drop + // the event. + // NB: this is hacky (and these checks attempt to isolate the hackiness), and root cause + // seems to be how this class fits into the larger ecosystem and general flow of events. + // See Bug 1277333. + final Context contextForAction; + if (mContext != null) { + contextForAction = mContext; + } else { + contextForAction = GeckoAppShell.getApplicationContext(); + } + + if (contextForAction == null) { + Log.w( + LOGTAG, + "Context is not available while processing event " + + event + + " for state " + + mCurrentState); + return false; + } + + performActionsForStateEvent(contextForAction, mCurrentState, event); + mCurrentState = nextState; + + return true; + } + + /** + * Defines a transition matrix for our state machine. For a given state/event pair, returns + * nextState. + * + * @param currentState Current state against which we have an incoming event + * @param event Incoming event for which we'd like to figure out the next state + * @return State into which we should transition as result of given event + */ + @Nullable + public static ManagerState getNextState( + final @NonNull ManagerState currentState, final @NonNull ManagerEvent event) { + switch (currentState) { + case OffNoListeners: + switch (event) { + case start: + return ManagerState.OnNoListeners; + case enableNotifications: + return ManagerState.OffWithListeners; + default: + return null; + } + case OnNoListeners: + switch (event) { + case stop: + return ManagerState.OffNoListeners; + case enableNotifications: + return ManagerState.OnWithListeners; + case receivedUpdate: + return ManagerState.OnNoListeners; + default: + return null; + } + case OnWithListeners: + switch (event) { + case stop: + return ManagerState.OffWithListeners; + case disableNotifications: + return ManagerState.OnNoListeners; + case receivedUpdate: + return ManagerState.OnWithListeners; + default: + return null; + } + case OffWithListeners: + switch (event) { + case start: + return ManagerState.OnWithListeners; + case disableNotifications: + return ManagerState.OffNoListeners; + default: + return null; + } + default: + throw new IllegalStateException("Unknown current state: " + currentState.name()); + } + } + + /** + * For a given state/event combination, run any actions which are by-products of leaving the state + * because of a given event. Since this is a deterministic state machine, we can easily do that + * without any additional information. + * + * @param currentState State which we are leaving + * @param event Event which is causing us to leave the state + */ + private void performActionsForStateEvent( + final Context context, final ManagerState currentState, final ManagerEvent event) { + // NB: network state might be queried via getCurrentInformation at any time; pre-rewrite + // behaviour was + // that network state was updated whenever enableNotifications was called. To avoid deviating + // from previous behaviour and causing weird side-effects, we call + // updateNetworkStateAndConnectionType + // whenever notifications are enabled. + switch (currentState) { + case OffNoListeners: + if (event == ManagerEvent.start) { + updateNetworkStateAndConnectionType(context); + registerBroadcastReceiver(context, this); + } + if (event == ManagerEvent.enableNotifications) { + updateNetworkStateAndConnectionType(context); + } + break; + case OnNoListeners: + if (event == ManagerEvent.receivedUpdate) { + updateNetworkStateAndConnectionType(context); + sendNetworkStateToListeners(context); + } + if (event == ManagerEvent.enableNotifications) { + updateNetworkStateAndConnectionType(context); + registerBroadcastReceiver(context, this); + } + if (event == ManagerEvent.stop) { + unregisterBroadcastReceiver(context, this); + } + break; + case OnWithListeners: + if (event == ManagerEvent.receivedUpdate) { + updateNetworkStateAndConnectionType(context); + sendNetworkStateToListeners(context); + } + if (event == ManagerEvent.stop) { + unregisterBroadcastReceiver(context, this); + } + /* no-op event: ManagerEvent.disableNotifications */ + break; + case OffWithListeners: + if (event == ManagerEvent.start) { + registerBroadcastReceiver(context, this); + } + /* no-op event: ManagerEvent.disableNotifications */ + break; + default: + throw new IllegalStateException("Unknown current state: " + currentState.name()); + } + } + + /** Update current network state and connection types. */ + private void updateNetworkStateAndConnectionType(final Context context) { + final ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + // Type/status getters below all have a defined behaviour for when connectivityManager == null + if (connectivityManager == null) { + Log.e(LOGTAG, "ConnectivityManager does not exist."); + } + mCurrentConnectionType = NetworkUtils.getConnectionType(connectivityManager); + mCurrentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager); + mCurrentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager); + Log.d( + LOGTAG, + "New network state: " + + mCurrentNetworkStatus + + ", " + + mCurrentConnectionType + + ", " + + mCurrentConnectionSubtype); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void onConnectionChanged( + int type, String subType, boolean isWifi, int dhcpGateway); + + @WrapForJNI(dispatchTo = "gecko") + private static native void onStatusChanged(String status); + + /** Send current network state and connection type to whomever is listening. */ + private void sendNetworkStateToListeners(final Context context) { + final boolean connectionTypeOrSubtypeChanged = + mCurrentConnectionType != mPreviousConnectionType + || mCurrentConnectionSubtype != mPreviousConnectionSubtype; + if (connectionTypeOrSubtypeChanged) { + mPreviousConnectionType = mCurrentConnectionType; + mPreviousConnectionSubtype = mCurrentConnectionSubtype; + + final boolean isWifi = mCurrentConnectionType == ConnectionType.WIFI; + final int gateway = !isWifi ? 0 : wifiDhcpGatewayAddress(context); + + if (GeckoThread.isRunning()) { + onConnectionChanged( + mCurrentConnectionType.value, mCurrentConnectionSubtype.value, isWifi, gateway); + } else { + GeckoThread.queueNativeCall( + GeckoNetworkManager.class, + "onConnectionChanged", + mCurrentConnectionType.value, + String.class, + mCurrentConnectionSubtype.value, + isWifi, + gateway); + } + } + + // If neither network status nor network configuration changed, do nothing. + if (mCurrentNetworkStatus == mPreviousNetworkStatus && !connectionTypeOrSubtypeChanged) { + return; + } + + // If network status remains the same, send "changed". Otherwise, send new network status. + // See Bug 1330836 for relevant details. + final String status; + if (mCurrentNetworkStatus == mPreviousNetworkStatus) { + status = LINK_DATA_CHANGED; + } else { + mPreviousNetworkStatus = mCurrentNetworkStatus; + status = mCurrentNetworkStatus.value; + } + + if (GeckoThread.isRunning()) { + onStatusChanged(status); + } else { + GeckoThread.queueNativeCall( + GeckoNetworkManager.class, "onStatusChanged", String.class, status); + } + } + + /** Stop listening for network state updates. */ + private static void unregisterBroadcastReceiver( + final Context context, final BroadcastReceiver receiver) { + context.unregisterReceiver(receiver); + } + + /** Start listening for network state updates. */ + private static void registerBroadcastReceiver( + final Context context, final BroadcastReceiver receiver) { + final IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + context.registerReceiver(receiver, filter); + } + + private static int wifiDhcpGatewayAddress(final Context context) { + if (context == null) { + return 0; + } + + try { + final WifiManager mgr = + (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (mgr == null) { + return 0; + } + + @SuppressLint("MissingPermission") + final DhcpInfo d = mgr.getDhcpInfo(); + if (d == null) { + return 0; + } + + return d.gateway; + + } catch (final Exception ex) { + // getDhcpInfo() is not documented to require any permissions, but on some devices + // requires android.permission.ACCESS_WIFI_STATE. Just catch the generic exception + // here and returning 0. Not logging because this could be noisy. + return 0; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java new file mode 100644 index 0000000000..dc36c6b631 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java @@ -0,0 +1,76 @@ +/* -*- Mode: Java; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.Log; +import android.view.Display; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +public class GeckoScreenChangeListener implements DisplayManager.DisplayListener { + private static final String LOGTAG = "ScreenChangeListener"; + private static final boolean DEBUG = false; + + public GeckoScreenChangeListener() {} + + @Override + public void onDisplayAdded(final int displayId) {} + + @Override + public void onDisplayRemoved(final int displayId) {} + + @Override + public void onDisplayChanged(final int displayId) { + if (DEBUG) { + Log.d(LOGTAG, "onDisplayChanged"); + } + + // Even if onDisplayChanged is called, Configuration may not updated yet. + // So we use Display's data instead. + if (displayId != Display.DEFAULT_DISPLAY) { + if (DEBUG) { + Log.d(LOGTAG, "Primary display is only supported"); + } + return; + } + + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + + if (GeckoScreenOrientation.getInstance().update(displayManager.getDisplay(displayId))) { + // refreshScreenInfo is already called. + return; + } + + ScreenManagerHelper.refreshScreenInfo(); + } + + private static DisplayManager getDisplayManager() { + return (DisplayManager) + GeckoAppShell.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + } + + public void enable() { + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + displayManager.registerDisplayListener(this, null); + } + + public void disable() { + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + displayManager.unregisterDisplayListener(this); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java new file mode 100644 index 0000000000..bdb7b4b331 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java @@ -0,0 +1,273 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; +import static android.content.res.Configuration.ORIENTATION_PORTRAIT; + +import android.content.Context; +import android.graphics.Rect; +import android.util.Log; +import android.view.Display; +import android.view.Surface; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.util.ThreadUtils; + +/* + * Updates, locks and unlocks the screen orientation. + * + * Note: Replaces the OnOrientationChangeListener to avoid redundant rotation + * event handling. + */ +public class GeckoScreenOrientation { + private static final String LOGTAG = "GeckoScreenOrientation"; + + // Make sure that any change in hal/HalScreenConfiguration.h happens here too. + public enum ScreenOrientation { + NONE(0), + PORTRAIT_PRIMARY(1 << 0), + PORTRAIT_SECONDARY(1 << 1), + PORTRAIT(PORTRAIT_PRIMARY.value | PORTRAIT_SECONDARY.value), + LANDSCAPE_PRIMARY(1 << 2), + LANDSCAPE_SECONDARY(1 << 3), + LANDSCAPE(LANDSCAPE_PRIMARY.value | LANDSCAPE_SECONDARY.value), + ANY( + PORTRAIT_PRIMARY.value + | PORTRAIT_SECONDARY.value + | LANDSCAPE_PRIMARY.value + | LANDSCAPE_SECONDARY.value), + DEFAULT(1 << 4); + + public final short value; + + private ScreenOrientation(final int value) { + this.value = (short) value; + } + + private static final ScreenOrientation[] sValues = ScreenOrientation.values(); + + public static ScreenOrientation get(final int value) { + for (final ScreenOrientation orient : sValues) { + if (orient.value == value) { + return orient; + } + } + return NONE; + } + } + + // Singleton instance. + private static GeckoScreenOrientation sInstance; + // Default rotation, used when device rotation is unknown. + private static final int DEFAULT_ROTATION = Surface.ROTATION_0; + // Last updated screen orientation with Gecko value space. + private ScreenOrientation mScreenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + + public interface OrientationChangeListener { + void onScreenOrientationChanged(ScreenOrientation newOrientation); + } + + private final List mListeners; + + public static GeckoScreenOrientation getInstance() { + if (sInstance == null) { + sInstance = new GeckoScreenOrientation(); + } + return sInstance; + } + + private GeckoScreenOrientation() { + mListeners = new ArrayList<>(); + update(); + } + + /** Add a listener that will be notified when the screen orientation has changed. */ + public void addListener(final OrientationChangeListener aListener) { + ThreadUtils.assertOnUiThread(); + mListeners.add(aListener); + } + + /** Remove a OrientationChangeListener again. */ + public void removeListener(final OrientationChangeListener aListener) { + ThreadUtils.assertOnUiThread(); + mListeners.remove(aListener); + } + + /* + * Update screen orientation. + * Retrieve orientation and rotation via GeckoAppShell. + * + * @return Whether the screen orientation has changed. + */ + public boolean update() { + // Check whether we have the application context for fenix/a-c unit test. + final Context appContext = GeckoAppShell.getApplicationContext(); + if (appContext == null) { + return false; + } + final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride(); + final int orientation = + rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT; + return update(getScreenOrientation(orientation, getRotation())); + } + + /* + * Update screen orientation. + * Retrieve orientation and rotation via Display. + * + * @param aDisplay The Display that has screen orientation information + * + * @return Whether the screen orientation has changed. + */ + public boolean update(final Display aDisplay) { + return update(getScreenOrientation(aDisplay)); + } + + /* + * Update screen orientation given the android orientation. + * Retrieve rotation via GeckoAppShell. + * + * @param aAndroidOrientation + * Android screen orientation from Configuration.orientation. + * + * @return Whether the screen orientation has changed. + */ + public boolean update(final int aAndroidOrientation) { + return update(getScreenOrientation(aAndroidOrientation, getRotation())); + } + + /* + * Update screen orientation given the screen orientation. + * + * @param aScreenOrientation + * Gecko screen orientation based on android orientation and rotation. + * + * @return Whether the screen orientation has changed. + */ + public synchronized boolean update(final ScreenOrientation aScreenOrientation) { + // Gecko expects a definite screen orientation, so we default to the + // primary orientations. + final ScreenOrientation screenOrientation; + if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_PRIMARY.value) != 0) { + screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + } else if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_SECONDARY.value) != 0) { + screenOrientation = ScreenOrientation.PORTRAIT_SECONDARY; + } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_PRIMARY.value) != 0) { + screenOrientation = ScreenOrientation.LANDSCAPE_PRIMARY; + } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_SECONDARY.value) != 0) { + screenOrientation = ScreenOrientation.LANDSCAPE_SECONDARY; + } else { + screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + } + if (mScreenOrientation == screenOrientation) { + return false; + } + mScreenOrientation = screenOrientation; + Log.d(LOGTAG, "updating to new orientation " + mScreenOrientation); + notifyListeners(mScreenOrientation); + ScreenManagerHelper.refreshScreenInfo(); + return true; + } + + private void notifyListeners(final ScreenOrientation newOrientation) { + final Runnable notifier = + new Runnable() { + @Override + public void run() { + for (final OrientationChangeListener listener : mListeners) { + listener.onScreenOrientationChanged(newOrientation); + } + } + }; + + if (ThreadUtils.isOnUiThread()) { + notifier.run(); + } else { + ThreadUtils.runOnUiThread(notifier); + } + } + + /* + * @return The Gecko screen orientation derived from Android orientation and + * rotation. + */ + public ScreenOrientation getScreenOrientation() { + return mScreenOrientation; + } + + /* + * Combine the Android orientation and rotation to the Gecko orientation. + * + * @param aAndroidOrientation + * Android orientation from Configuration.orientation. + * @param aRotation + * Device rotation from Display.getRotation(). + * + * @return Gecko screen orientation. + */ + private ScreenOrientation getScreenOrientation( + final int aAndroidOrientation, final int aRotation) { + final boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90; + if (aAndroidOrientation == ORIENTATION_PORTRAIT) { + if (isPrimary) { + // Non-rotated portrait device or landscape device rotated + // to primary portrait mode counter-clockwise. + return ScreenOrientation.PORTRAIT_PRIMARY; + } + return ScreenOrientation.PORTRAIT_SECONDARY; + } + if (aAndroidOrientation == ORIENTATION_LANDSCAPE) { + if (isPrimary) { + // Non-rotated landscape device or portrait device rotated + // to primary landscape mode counter-clockwise. + return ScreenOrientation.LANDSCAPE_PRIMARY; + } + return ScreenOrientation.LANDSCAPE_SECONDARY; + } + return ScreenOrientation.NONE; + } + + /* + * Get the Gecko orientation from Display. + * + * @param aDisplay The display that has orientation information. + * + * @return Gecko screen orientation. + */ + private ScreenOrientation getScreenOrientation(final Display aDisplay) { + final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride(); + final int orientation = + rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT; + return getScreenOrientation(orientation, aDisplay.getRotation()); + } + + /* + * @return Device rotation converted to an angle. + */ + public short getAngle() { + switch (getRotation()) { + case Surface.ROTATION_0: + return 0; + case Surface.ROTATION_90: + return 90; + case Surface.ROTATION_180: + return 180; + case Surface.ROTATION_270: + return 270; + default: + Log.w(LOGTAG, "getAngle: unexpected rotation value"); + return 0; + } + } + + /* + * @return Device rotation. + */ + private int getRotation() { + return GeckoAppShell.getRotation(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java new file mode 100644 index 0000000000..6a71eff1fe --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java @@ -0,0 +1,185 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Configuration; +import android.database.ContentObserver; +import android.hardware.input.InputManager; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.Log; +import android.view.InputDevice; +import androidx.annotation.RequiresApi; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.InputDeviceUtils; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoSystemStateListener implements InputManager.InputDeviceListener { + private static final String LOGTAG = "SystemStateListener"; + + private static final GeckoSystemStateListener listenerInstance = new GeckoSystemStateListener(); + + private boolean mInitialized; + private ContentObserver mContentObserver; + private static Context sApplicationContext; + private InputManager mInputManager; + private boolean mIsNightMode; + + public static GeckoSystemStateListener getInstance() { + return listenerInstance; + } + + private GeckoSystemStateListener() {} + + public synchronized void initialize(final Context context) { + if (mInitialized) { + Log.w(LOGTAG, "Already initialized!"); + return; + } + mInputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + mInputManager.registerInputDeviceListener(listenerInstance, ThreadUtils.getUiHandler()); + + sApplicationContext = context; + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + final Uri animationSetting = Settings.System.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE); + mContentObserver = + new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(final boolean selfChange) { + onDeviceChanged(); + } + }; + contentResolver.registerContentObserver(animationSetting, false, mContentObserver); + + final Uri invertSetting = + Settings.Secure.getUriFor(Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED); + contentResolver.registerContentObserver(invertSetting, false, mContentObserver); + + mIsNightMode = + (sApplicationContext.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) + == Configuration.UI_MODE_NIGHT_YES; + + mInitialized = true; + } + + public synchronized void shutdown() { + if (!mInitialized) { + Log.w(LOGTAG, "Already shut down!"); + return; + } + + if (mInputManager != null) { + Log.e(LOGTAG, "mInputManager should be valid!"); + return; + } + + mInputManager.unregisterInputDeviceListener(listenerInstance); + + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + contentResolver.unregisterContentObserver(mContentObserver); + + mInitialized = false; + mInputManager = null; + mContentObserver = null; + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + @WrapForJNI(calledFrom = "gecko") + /** + * For prefers-reduced-motion media queries feature. + * + *

    Uses `Settings.Global` which was introduced in API version 17. + */ + private static boolean prefersReducedMotion() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return false; + } + + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1) + == 0.0f; + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @WrapForJNI(calledFrom = "gecko") + /** + * For inverted-colors queries feature. + * + *

    Uses `Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED` which was introduced in API + * version 21. + */ + private static boolean isInvertedColors() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return false; + } + + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Secure.getInt( + contentResolver, Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED, 0) + == 1; + } + + /** For prefers-color-scheme media queries feature. */ + public boolean isNightMode() { + return mIsNightMode; + } + + public void updateNightMode(final int newUIMode) { + final boolean isNightMode = + (newUIMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + if (isNightMode == mIsNightMode) { + return; + } + mIsNightMode = isNightMode; + onDeviceChanged(); + } + + @WrapForJNI(stubName = "OnDeviceChanged", calledFrom = "any", dispatchTo = "gecko") + private static native void nativeOnDeviceChanged(); + + public static void onDeviceChanged() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeOnDeviceChanged(); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, GeckoSystemStateListener.class, "nativeOnDeviceChanged"); + } + } + + private void notifyDeviceChanged(final int deviceId) { + final InputDevice device = InputDevice.getDevice(deviceId); + if (device == null || !InputDeviceUtils.isPointerTypeDevice(device)) { + return; + } + onDeviceChanged(); + } + + @Override + public void onInputDeviceAdded(final int deviceId) { + notifyDeviceChanged(deviceId); + } + + @Override + public void onInputDeviceRemoved(final int deviceId) { + // Call onDeviceChanged directly without checking device source types + // since we can no longer get a valid `InputDevice` in the case of + // device removal. + onDeviceChanged(); + } + + @Override + public void onInputDeviceChanged(final int deviceId) { + notifyDeviceChanged(deviceId); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java new file mode 100644 index 0000000000..8860c1cd42 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java @@ -0,0 +1,985 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.StringTokenizer; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.process.GeckoProcessManager; +import org.mozilla.gecko.process.GeckoProcessType; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; + +public class GeckoThread extends Thread { + private static final String LOGTAG = "GeckoThread"; + + public enum State implements NativeQueue.State { + // After being loaded by class loader. + @WrapForJNI + INITIAL(0), + // After launching Gecko thread + @WrapForJNI + LAUNCHED(1), + // After loading the mozglue library. + @WrapForJNI + MOZGLUE_READY(2), + // After loading the libxul library. + @WrapForJNI + LIBS_READY(3), + // After initializing nsAppShell and JNI calls. + @WrapForJNI + JNI_READY(4), + // After initializing profile and prefs. + @WrapForJNI + PROFILE_READY(5), + // After initializing frontend JS + @WrapForJNI + RUNNING(6), + // After granting request to shutdown + @WrapForJNI + EXITING(3), + // After granting request to restart + @WrapForJNI + RESTARTING(3), + // After failed lib extraction due to corrupted APK + CORRUPT_APK(2), + // After exiting GeckoThread (corresponding to "Gecko:Exited" event) + @WrapForJNI + EXITED(0); + + /* The rank is an arbitrary value reflecting the amount of components or features + * that are available for use. During startup and up to the RUNNING state, the + * rank value increases because more components are initialized and available for + * use. During shutdown and up to the EXITED state, the rank value decreases as + * components are shut down and become unavailable. EXITING has the same rank as + * LIBS_READY because both states have a similar amount of components available. + */ + private final int mRank; + + private State(final int rank) { + mRank = rank; + } + + @Override + public boolean is(final NativeQueue.State other) { + return this == other; + } + + @Override + public boolean isAtLeast(final NativeQueue.State other) { + if (other instanceof State) { + return mRank >= ((State) other).mRank; + } + return false; + } + + @Override + public String toString() { + return name(); + } + } + + // -1 denotes an invalid or missing File Descriptor + private static final int INVALID_FD = -1; + + private static final NativeQueue sNativeQueue = new NativeQueue(State.INITIAL, State.RUNNING); + + /* package */ static NativeQueue getNativeQueue() { + return sNativeQueue; + } + + public static final State MIN_STATE = State.INITIAL; + public static final State MAX_STATE = State.EXITED; + + private static final Runnable UI_THREAD_CALLBACK = + new Runnable() { + @Override + public void run() { + ThreadUtils.assertOnUiThread(); + final long nextDelay = runUiThreadCallback(); + if (nextDelay >= 0) { + ThreadUtils.getUiHandler().postDelayed(this, nextDelay); + } + } + }; + + private static final GeckoThread INSTANCE = new GeckoThread(); + + @WrapForJNI private static final ClassLoader clsLoader = GeckoThread.class.getClassLoader(); + @WrapForJNI private static MessageQueue msgQueue; + @WrapForJNI private static int uiThreadId; + + private static TelemetryUtils.Timer sInitTimer; + private static LinkedList sStateListeners = new LinkedList<>(); + + // Main process parameters + public static final int FLAG_DEBUGGING = 1 << 0; // Debugging mode. + public static final int FLAG_PRELOAD_CHILD = 1 << 1; // Preload child during main thread start. + public static final int FLAG_ENABLE_NATIVE_CRASHREPORTER = + 1 << 2; // Enable native crash reporting. + + /* package */ static final String EXTRA_ARGS = "args"; + + private boolean mInitialized; + private InitInfo mInitInfo; + + public static final class ParcelFileDescriptors { + public final @Nullable ParcelFileDescriptor prefs; + public final @Nullable ParcelFileDescriptor prefMap; + public final @NonNull ParcelFileDescriptor ipc; + public final @Nullable ParcelFileDescriptor crashReporter; + public final @Nullable ParcelFileDescriptor crashAnnotation; + + private ParcelFileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + crashAnnotation = builder.crashAnnotation; + } + + public FileDescriptors detach() { + return FileDescriptors.builder() + .prefs(detach(prefs)) + .prefMap(detach(prefMap)) + .ipc(detach(ipc)) + .crashReporter(detach(crashReporter)) + .crashAnnotation(detach(crashAnnotation)) + .build(); + } + + private static int detach(final ParcelFileDescriptor pfd) { + if (pfd == null) { + return INVALID_FD; + } + return pfd.detachFd(); + } + + public void close() { + close(prefs, prefMap, ipc, crashReporter, crashAnnotation); + } + + private static void close(final ParcelFileDescriptor... pfds) { + for (final ParcelFileDescriptor pfd : pfds) { + if (pfd != null) { + try { + pfd.close(); + } catch (final IOException ex) { + // Nothing we can do about this really. + Log.w(LOGTAG, "Failed to close File Descriptors.", ex); + } + } + } + } + + public static ParcelFileDescriptors from(final FileDescriptors fds) { + return ParcelFileDescriptors.builder() + .prefs(from(fds.prefs)) + .prefMap(from(fds.prefMap)) + .ipc(from(fds.ipc)) + .crashReporter(from(fds.crashReporter)) + .crashAnnotation(from(fds.crashAnnotation)) + .build(); + } + + private static ParcelFileDescriptor from(final int fd) { + if (fd == INVALID_FD) { + return null; + } + try { + return ParcelFileDescriptor.fromFd(fd); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + ParcelFileDescriptor prefs; + ParcelFileDescriptor prefMap; + ParcelFileDescriptor ipc; + ParcelFileDescriptor crashReporter; + ParcelFileDescriptor crashAnnotation; + + private Builder() {} + + public ParcelFileDescriptors build() { + return new ParcelFileDescriptors(this); + } + + public Builder prefs(final ParcelFileDescriptor prefs) { + this.prefs = prefs; + return this; + } + + public Builder prefMap(final ParcelFileDescriptor prefMap) { + this.prefMap = prefMap; + return this; + } + + public Builder ipc(final ParcelFileDescriptor ipc) { + this.ipc = ipc; + return this; + } + + public Builder crashReporter(final ParcelFileDescriptor crashReporter) { + this.crashReporter = crashReporter; + return this; + } + + public Builder crashAnnotation(final ParcelFileDescriptor crashAnnotation) { + this.crashAnnotation = crashAnnotation; + return this; + } + } + } + + public static final class FileDescriptors { + final int prefs; + final int prefMap; + final int ipc; + final int crashReporter; + final int crashAnnotation; + + private FileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + crashAnnotation = builder.crashAnnotation; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + int prefs = INVALID_FD; + int prefMap = INVALID_FD; + int ipc = INVALID_FD; + int crashReporter = INVALID_FD; + int crashAnnotation = INVALID_FD; + + private Builder() {} + + public FileDescriptors build() { + return new FileDescriptors(this); + } + + public Builder prefs(final int prefs) { + this.prefs = prefs; + return this; + } + + public Builder prefMap(final int prefMap) { + this.prefMap = prefMap; + return this; + } + + public Builder ipc(final int ipc) { + this.ipc = ipc; + return this; + } + + public Builder crashReporter(final int crashReporter) { + this.crashReporter = crashReporter; + return this; + } + + public Builder crashAnnotation(final int crashAnnotation) { + this.crashAnnotation = crashAnnotation; + return this; + } + } + } + + public static class InitInfo { + public final String[] args; + public final Bundle extras; + public final int flags; + public final Map prefs; + public final String userSerialNumber; + + public final boolean xpcshell; + public final String outFilePath; + + public final FileDescriptors fds; + + private InitInfo(final Builder builder) { + final List result = new ArrayList<>(builder.mArgs.length); + + boolean xpcshell = false; + for (final String argument : builder.mArgs) { + if ("-xpcshell".equals(argument)) { + xpcshell = true; + } else { + result.add(argument); + } + } + this.xpcshell = xpcshell; + + args = result.toArray(new String[0]); + + extras = builder.mExtras != null ? new Bundle(builder.mExtras) : new Bundle(3); + flags = builder.mFlags; + prefs = builder.mPrefs; + userSerialNumber = builder.mUserSerialNumber; + + outFilePath = xpcshell ? builder.mOutFilePath : null; + + if (builder.mFds != null) { + fds = builder.mFds; + } else { + fds = FileDescriptors.builder().build(); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String[] mArgs; + private Bundle mExtras; + private int mFlags; + private Map mPrefs; + private String mUserSerialNumber; + + private String mOutFilePath; + + private FileDescriptors mFds; + + // Prevent direct instantiation + private Builder() {} + + public InitInfo build() { + return new InitInfo(this); + } + + public Builder args(final String[] args) { + mArgs = args; + return this; + } + + public Builder extras(final Bundle extras) { + mExtras = extras; + return this; + } + + public Builder flags(final int flags) { + mFlags = flags; + return this; + } + + public Builder prefs(final Map prefs) { + mPrefs = prefs; + return this; + } + + public Builder userSerialNumber(final String userSerialNumber) { + mUserSerialNumber = userSerialNumber; + return this; + } + + public Builder outFilePath(final String outFilePath) { + mOutFilePath = outFilePath; + return this; + } + + public Builder fds(final FileDescriptors fds) { + mFds = fds; + return this; + } + } + } + + private static class StateGeckoResult extends GeckoResult { + final State state; + + public StateGeckoResult(final State state) { + this.state = state; + } + } + + GeckoThread() { + // Request more (virtual) stack space to avoid overflows in the CSS frame + // constructor. 8 MB matches desktop. + super(null, null, "Gecko", 8 * 1024 * 1024); + } + + @WrapForJNI + private static boolean isChildProcess() { + final InitInfo info = INSTANCE.mInitInfo; + return info != null && info.fds.ipc != INVALID_FD; + } + + public static boolean init(final InitInfo info) { + return INSTANCE.initInternal(info); + } + + private synchronized boolean initInternal(final InitInfo info) { + ThreadUtils.assertOnUiThread(); + uiThreadId = Process.myTid(); + + if (mInitialized) { + return false; + } + + sInitTimer = new TelemetryUtils.UptimeTimer("GV_STARTUP_RUNTIME_MS"); + + mInitInfo = info; + mInitialized = true; + notifyAll(); + return true; + } + + public static boolean launch() { + ThreadUtils.assertOnUiThread(); + + if (checkAndSetState(State.INITIAL, State.LAUNCHED)) { + INSTANCE.start(); + return true; + } + return false; + } + + public static boolean isLaunched() { + return !isState(State.INITIAL); + } + + @RobocopTarget + public static boolean isRunning() { + return isState(State.RUNNING); + } + + private static void loadGeckoLibs(final Context context) { + GeckoLoader.loadSQLiteLibs(context); + GeckoLoader.loadNSSLibs(context); + GeckoLoader.loadGeckoLibs(context); + setState(State.LIBS_READY); + } + + private static void initGeckoEnvironment() { + final Context context = GeckoAppShell.getApplicationContext(); + final Locale locale = Locale.getDefault(); + final Resources res = context.getResources(); + if (locale.toString().equalsIgnoreCase("zh_hk")) { + final Locale mappedLocale = Locale.TRADITIONAL_CHINESE; + Locale.setDefault(mappedLocale); + final Configuration config = res.getConfiguration(); + config.locale = mappedLocale; + res.updateConfiguration(config, null); + } + + if (!isChildProcess()) { + GeckoSystemStateListener.getInstance().initialize(context); + } + + loadGeckoLibs(context); + } + + private String[] getMainProcessArgs() { + final Context context = GeckoAppShell.getApplicationContext(); + final ArrayList args = new ArrayList<>(); + + // argv[0] is the program name, which for us is the package name. + args.add(context.getPackageName()); + + if (!mInitInfo.xpcshell) { + args.add("-greomni"); + args.add(context.getPackageResourcePath()); + } + + if (mInitInfo.args != null) { + args.addAll(Arrays.asList(mInitInfo.args)); + } + + // Legacy "args" parameter + final String extraArgs = mInitInfo.extras.getString(EXTRA_ARGS, null); + if (extraArgs != null) { + final StringTokenizer st = new StringTokenizer(extraArgs); + while (st.hasMoreTokens()) { + args.add(st.nextToken()); + } + } + + // "argX" parameters + for (int i = 0; mInitInfo.extras.containsKey("arg" + i); i++) { + final String arg = mInitInfo.extras.getString("arg" + i); + args.add(arg); + } + + return args.toArray(new String[0]); + } + + public static @Nullable Bundle getActiveExtras() { + synchronized (INSTANCE) { + if (!INSTANCE.mInitialized) { + return null; + } + return new Bundle(INSTANCE.mInitInfo.extras); + } + } + + public static int getActiveFlags() { + synchronized (INSTANCE) { + if (!INSTANCE.mInitialized) { + return 0; + } + + return INSTANCE.mInitInfo.flags; + } + } + + private static ArrayList getEnvFromExtras(final Bundle extras) { + if (extras == null) { + return new ArrayList<>(); + } + + final ArrayList result = new ArrayList<>(); + if (extras != null) { + String env = extras.getString("env0"); + for (int c = 1; env != null; c++) { + if (BuildConfig.DEBUG_BUILD) { + Log.d(LOGTAG, "env var: " + env); + } + result.add(env); + env = extras.getString("env" + c); + } + } + + return result; + } + + @Override + public void run() { + Log.i(LOGTAG, "preparing to run Gecko"); + + Looper.prepare(); + GeckoThread.msgQueue = Looper.myQueue(); + ThreadUtils.sGeckoThread = this; + ThreadUtils.sGeckoHandler = new Handler(); + + // Preparation for pumpMessageLoop() + final MessageQueue.IdleHandler idleHandler = + new MessageQueue.IdleHandler() { + @Override + public boolean queueIdle() { + final Handler geckoHandler = ThreadUtils.sGeckoHandler; + final Message idleMsg = Message.obtain(geckoHandler); + // Use |Message.obj == GeckoHandler| to identify our "queue is empty" message + idleMsg.obj = geckoHandler; + geckoHandler.sendMessageAtFrontOfQueue(idleMsg); + // Keep this IdleHandler + return true; + } + }; + Looper.myQueue().addIdleHandler(idleHandler); + + // Wait until initialization before preparing environment. + synchronized (this) { + while (!mInitialized) { + try { + wait(); + } catch (final InterruptedException e) { + } + } + } + + final Context context = GeckoAppShell.getApplicationContext(); + final List env = getEnvFromExtras(mInitInfo.extras); + + // In Gecko, the native crash reporter is enabled by default in opt builds, and + // disabled by default in debug builds. + if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) == 0 && !BuildConfig.DEBUG_BUILD) { + env.add(0, "MOZ_CRASHREPORTER_DISABLE=1"); + } else if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) != 0 + && BuildConfig.DEBUG_BUILD) { + env.add(0, "MOZ_CRASHREPORTER=1"); + } + + if (mInitInfo.userSerialNumber != null) { + env.add(0, "MOZ_ANDROID_USER_SERIAL_NUMBER=" + mInitInfo.userSerialNumber); + } + + // Start the profiler before even loading mozglue, so we can capture more + // things that are happening on the JVM side. + maybeStartGeckoProfiler(env); + + GeckoLoader.loadMozGlue(context); + setState(State.MOZGLUE_READY); + + final boolean isChildProcess = isChildProcess(); + + GeckoLoader.setupGeckoEnvironment( + context, + isChildProcess, + context.getFilesDir().getPath(), + env, + mInitInfo.prefs, + mInitInfo.xpcshell); + + initGeckoEnvironment(); + + if ((mInitInfo.flags & FLAG_PRELOAD_CHILD) != 0) { + // Preload the content ("tab") child process. + GeckoProcessManager.getInstance().preload(GeckoProcessType.CONTENT); + } + + if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) { + try { + Thread.sleep(5 * 1000 /* 5 seconds */); + } catch (final InterruptedException e) { + } + } + + Log.w(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - runGecko"); + + final String[] args = isChildProcess ? mInitInfo.args : getMainProcessArgs(); + + if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) { + Log.i(LOGTAG, "RunGecko - args = " + TextUtils.join(" ", args)); + } + + // And go. + GeckoLoader.nativeRun( + args, + mInitInfo.fds.prefs, + mInitInfo.fds.prefMap, + mInitInfo.fds.ipc, + mInitInfo.fds.crashReporter, + mInitInfo.fds.crashAnnotation, + isChildProcess ? false : mInitInfo.xpcshell, + isChildProcess ? null : mInitInfo.outFilePath); + + // And... we're done. + final boolean restarting = isState(State.RESTARTING); + setState(State.EXITED); + + final GeckoBundle data = new GeckoBundle(1); + data.putBoolean("restart", restarting); + EventDispatcher.getInstance().dispatch("Gecko:Exited", data); + + // Remove pumpMessageLoop() idle handler + Looper.myQueue().removeIdleHandler(idleHandler); + + if (isChildProcess) { + // The child process is completely controlled by Gecko so we don't really need to keep + // it alive after Gecko exits. + System.exit(0); + } + } + + // This may start the gecko profiler early by looking at the environment variables. + // Refer to the platform side for more information about the environment variables: + // https://searchfox.org/mozilla-central/rev/2f9eacd9d3d995c937b4251a5557d95d494c9be1/tools/profiler/core/platform.cpp#2969-3072 + private static void maybeStartGeckoProfiler(final @NonNull List env) { + final String startupEnv = "MOZ_PROFILER_STARTUP="; + final String intervalEnv = "MOZ_PROFILER_STARTUP_INTERVAL="; + final String capacityEnv = "MOZ_PROFILER_STARTUP_ENTRIES="; + final String filtersEnv = "MOZ_PROFILER_STARTUP_FILTERS="; + boolean isStartupProfiling = false; + // Putting default values for now, but they can be overwritten. + // Keep these values in sync with profiler defaults. + int interval = 1; + // 8M entries. Keep this in sync with `PROFILER_DEFAULT_STARTUP_ENTRIES`. + int capacity = 8 * 1024 * 1024; + // We have a default 8M of entries but user can actually put less entries + // with environment variables. But even though user can put anything, we + // have a hard cap on the minimum value count, because if it's lower than + // this value, profiler could not capture anything meaningful. + // This value is kept in `scMinimumBufferEntries` variable in the cpp side: + // https://searchfox.org/mozilla-central/rev/fa7f47027917a186fb2052dee104cd06c21dd76f/tools/profiler/core/platform.cpp#749 + // This number is not clear in the cpp code at first, so lets calculate: + // scMinimumBufferEntries = scMinimumBufferSize / scBytesPerEntry + // expands into + // scMinimumNumberOfChunks * 2 * scExpectedMaximumStackSize / scBytesPerEntry + // and this is: 4 * 2 * 64 * 1024 / 8 = 65536 (~512 kb) + final int minCapacity = 65536; + + // Set the default value of no filters - an empty array - which is safer than using null. + // If we find a user provided value, this will be overwritten. + String[] filters = new String[0]; + + // Looping the environment variable list to check known variable names. + for (final String envItem : env) { + if (envItem == null) { + continue; + } + + if (envItem.startsWith(startupEnv)) { + // Check the environment variable value to see if it's positive. + final String value = envItem.substring(startupEnv.length()); + if (value.isEmpty() || value.equals("0") || value.equals("n") || value.equals("N")) { + // ''/'0'/'n'/'N' values mean do not start the startup profiler. + // There's no need to inspect other environment variables, + // so let's break out of the loop + break; + } + + isStartupProfiling = true; + } else if (envItem.startsWith(intervalEnv)) { + // Parse the interval environment variable if present + final String value = envItem.substring(intervalEnv.length()); + + try { + final int intValue = Integer.parseInt(value); + interval = Math.max(intValue, interval); + } catch (final NumberFormatException err) { + // Failed to parse. Do nothing and just use the default value. + } + } else if (envItem.startsWith(capacityEnv)) { + // Parse the capacity environment variable if present + final String value = envItem.substring(capacityEnv.length()); + + try { + final int intValue = Integer.parseInt(value); + // See `scMinimumBufferEntries` variable for this value on the platform side. + capacity = Math.max(intValue, minCapacity); + } catch (final NumberFormatException err) { + // Failed to parse. Do nothing and just use the default value. + } + } else if (envItem.startsWith(filtersEnv)) { + filters = envItem.substring(filtersEnv.length()).split(","); + } + } + + if (isStartupProfiling) { + GeckoJavaSampler.start(filters, interval, capacity); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean pumpMessageLoop(final Message msg) { + final Handler geckoHandler = ThreadUtils.sGeckoHandler; + + if (msg.obj == geckoHandler && msg.getTarget() == geckoHandler) { + // Our "queue is empty" message; see runGecko() + return false; + } + + if (msg.getTarget() == null) { + Looper.myLooper().quit(); + } else { + msg.getTarget().dispatchMessage(msg); + } + + return true; + } + + /** + * Check that the current Gecko thread state matches the given state. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isState(final State state) { + return sNativeQueue.getState().is(state); + } + + /** + * Check that the current Gecko thread state is at the given state or further along, according to + * the order defined in the State enum. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isStateAtLeast(final State state) { + return sNativeQueue.getState().isAtLeast(state); + } + + /** + * Check that the current Gecko thread state is at the given state or prior, according to the + * order defined in the State enum. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isStateAtMost(final State state) { + return state.isAtLeast(sNativeQueue.getState()); + } + + /** + * Check that the current Gecko thread state falls into an inclusive range of states, according to + * the order defined in the State enum. + * + * @param minState Lower range of allowable states + * @param maxState Upper range of allowable states + * @return True if the current Gecko thread state matches + */ + public static boolean isStateBetween(final State minState, final State maxState) { + return isStateAtLeast(minState) && isStateAtMost(maxState); + } + + @WrapForJNI(calledFrom = "gecko") + private static void setState(final State newState) { + checkAndSetState(null, newState); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean checkAndSetState(final State expectedState, final State newState) { + final boolean result = sNativeQueue.checkAndSetState(expectedState, newState); + if (result) { + Log.d(LOGTAG, "State changed to " + newState); + + if (sInitTimer != null && isRunning()) { + sInitTimer.stop(); + sInitTimer = null; + } + + notifyStateListeners(); + } + return result; + } + + @WrapForJNI(stubName = "SpeculativeConnect") + private static native void speculativeConnectNative(String uri); + + public static void speculativeConnect(final String uri) { + // This is almost always called before Gecko loads, so we don't + // bother checking here if Gecko is actually loaded or not. + // Speculative connection depends on proxy settings, + // so the earliest it can happen is after profile is ready. + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "speculativeConnectNative", uri); + } + + @UiThread + public static GeckoResult 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 newListeners = new LinkedList<>(); + for (final StateGeckoResult result : sStateListeners) { + if (!isStateAtLeast(result.state)) { + newListeners.add(result); + continue; + } + + result.complete(null); + } + + sStateListeners = newListeners; + } + } + + @WrapForJNI(stubName = "OnPause", dispatchTo = "gecko") + private static native void nativeOnPause(); + + public static void onPause() { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeOnPause(); + } else { + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnPause"); + } + } + + @WrapForJNI(stubName = "OnResume", dispatchTo = "gecko") + private static native void nativeOnResume(); + + public static void onResume() { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeOnResume(); + } else { + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnResume"); + } + } + + @WrapForJNI(stubName = "CreateServices", dispatchTo = "gecko") + private static native void nativeCreateServices(String category, String data); + + public static void createServices(final String category, final String data) { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeCreateServices(category, data); + } else { + queueNativeCallUntil( + State.PROFILE_READY, + GeckoThread.class, + "nativeCreateServices", + String.class, + category, + String.class, + data); + } + } + + @WrapForJNI(calledFrom = "ui") + /* package */ static native long runUiThreadCallback(); + + @WrapForJNI(dispatchTo = "gecko") + public static native void forceQuit(); + + @WrapForJNI(dispatchTo = "gecko") + public static native void crash(); + + @WrapForJNI + private static void requestUiThreadCallback(final long delay) { + ThreadUtils.getUiHandler().postDelayed(UI_THREAD_CALLBACK, delay); + } + + /** Queue a call to the given static method until Gecko is in the RUNNING state. */ + public static void queueNativeCall( + final Class cls, final String methodName, final Object... args) { + sNativeQueue.queueUntilReady(cls, methodName, args); + } + + /** Queue a call to the given instance method until Gecko is in the RUNNING state. */ + public static void queueNativeCall( + final Object obj, final String methodName, final Object... args) { + sNativeQueue.queueUntilReady(obj, methodName, args); + } + + /** Queue a call to the given instance method until Gecko is in the RUNNING state. */ + public static void queueNativeCallUntil( + final State state, final Object obj, final String methodName, final Object... args) { + sNativeQueue.queueUntil(state, obj, methodName, args); + } + + /** Queue a call to the given static method until Gecko is in the RUNNING state. */ + public static void queueNativeCallUntil( + final State state, final Class cls, final String methodName, final Object... args) { + sNativeQueue.queueUntil(state, cls, methodName, args); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java new file mode 100644 index 0000000000..5689944717 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java @@ -0,0 +1,106 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.os.Build; +import android.provider.Settings.Secure; +import android.view.View; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import java.util.Collection; + +public final class InputMethods { + public static final String METHOD_ANDROID_LATINIME = "com.android.inputmethod.latin/.LatinIME"; + // ATOK has a lot of package names since they release custom versions. + public static final String METHOD_ATOK_PREFIX = "com.justsystems.atokmobile"; + public static final String METHOD_ATOK_OEM_PREFIX = "com.atok.mobile."; + public static final String METHOD_GOOGLE_JAPANESE_INPUT = + "com.google.android.inputmethod.japanese/.MozcService"; + public static final String METHOD_ATOK_OEM_SOFTBANK = + "com.mobiroo.n.justsystems.atok/.AtokInputMethodService"; + public static final String METHOD_GOOGLE_LATINIME = + "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"; + public static final String METHOD_HTC_TOUCH_INPUT = "com.htc.android.htcime/.HTCIMEService"; + public static final String METHOD_IWNN = + "jp.co.omronsoft.iwnnime.ml/.standardcommon.IWnnLanguageSwitcher"; + public static final String METHOD_OPENWNN_PLUS = "com.owplus.ime.openwnnplus/.OpenWnnJAJP"; + public static final String METHOD_SAMSUNG = "com.sec.android.inputmethod/.SamsungKeypad"; + public static final String METHOD_SIMEJI = "com.adamrocker.android.input.simeji/.OpenWnnSimeji"; + public static final String METHOD_SONY = + "com.sonyericsson.textinput.uxp/.glue.InputMethodServiceGlue"; + public static final String METHOD_SWIFTKEY = + "com.touchtype.swiftkey/com.touchtype.KeyboardService"; + public static final String METHOD_SWYPE = "com.swype.android.inputmethod/.SwypeInputMethod"; + public static final String METHOD_SWYPE_BETA = "com.nuance.swype.input/.IME"; + public static final String METHOD_TOUCHPAL_KEYBOARD = + "com.cootek.smartinputv5/com.cootek.smartinput5.TouchPalIME"; + + private InputMethods() {} + + public static String getCurrentInputMethod(final Context context) { + final String inputMethod = + Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD); + return (inputMethod != null ? inputMethod : ""); + } + + public static InputMethodInfo getInputMethodInfo( + final Context context, final String inputMethod) { + final InputMethodManager imm = getInputMethodManager(context); + final Collection infos = imm.getEnabledInputMethodList(); + for (final InputMethodInfo info : infos) { + if (info.getId().equals(inputMethod)) { + return info; + } + } + return null; + } + + public static InputMethodManager getInputMethodManager(final Context context) { + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public static void restartInput(final Context context, final View view) { + final InputMethodManager imm = getInputMethodManager(context); + if (imm != null) { + imm.restartInput(view); + } + } + + public static boolean needsSoftResetWorkaround(final String inputMethod) { + // Stock latin IME on Android 4.2 and above + return Build.VERSION.SDK_INT >= 17 + && (METHOD_ANDROID_LATINIME.equals(inputMethod) + || METHOD_GOOGLE_LATINIME.equals(inputMethod)); + } + + /** + * Check input method if we require a workaround to remove composition in {@link + * android.view.inputmethod.InputMethodManager.updateSelection}. + * + * @param inputMethod The input method name by {@link #getCurrentInputMethod}. + * @return true if {@link android.view.inputmethod.InputMethodManager.updateSelection} doesn't + * remove the composition, use {@link + * android.view.inputmethod.InputMehtodManager.restartInput} to remove it in this case. + */ + public static boolean needsRestartInput(final String inputMethod) { + return inputMethod.startsWith(METHOD_ATOK_PREFIX) + || inputMethod.startsWith(METHOD_ATOK_OEM_PREFIX) + || METHOD_ATOK_OEM_SOFTBANK.equals(inputMethod); + } + + public static boolean shouldCommitCharAsKey(final String inputMethod) { + return METHOD_HTC_TOUCH_INPUT.equals(inputMethod); + } + + public static boolean needsRestartOnReplaceRemove(final Context context) { + final String inputMethod = getCurrentInputMethod(context); + return METHOD_SONY.equals(inputMethod); + } + + // TODO: Replace usages by definition in EditorInfoCompat once available (bug 1385726). + public static final int IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java new file mode 100644 index 0000000000..2003abcc6f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java @@ -0,0 +1,137 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +/** + * A {@link android.view.SurfaceView} which allows a {@link android.widget.Magnifier} widget to + * magnify a custom {@link android.view.Surface} rather than the SurfaceView's default Surface. + */ +public class MagnifiableSurfaceView extends SurfaceView { + private static final String LOGTAG = "MagnifiableSurfaceView"; + + private SurfaceHolderWrapper mHolder; + + public MagnifiableSurfaceView(final Context context) { + super(context); + } + + @Override + public SurfaceHolder getHolder() { + if (mHolder != null) { + // Only return our custom holder if we are being called from the Magnifier class. + // Throwable.getStackTrace() is faster than Thread.getStackTrace(), but still has a cost, + // hence why we only check the caller if we have set an override Surface. + final StackTraceElement[] stackTrace = new Throwable().getStackTrace(); + if (stackTrace.length >= 2 + && stackTrace[1].getClassName().equals("android.widget.Magnifier")) { + return mHolder; + } + } + return super.getHolder(); + } + + /** + * Sets the Surface that should be magnified by a Magnifier widget. + * + *

    This should be set immediately before calling {@link android.widget.Magnifier#show()} or + * {@link android.widget.Magnifier#update()}, and unset immediately afterwards. + * + * @param surface The Surface to be magnified. If null, the SurfaceView's default Surface will be + * used. + */ + public void setMagnifierSurface(final Surface surface) { + if (surface != null) { + mHolder = new SurfaceHolderWrapper(getHolder(), surface); + } else { + mHolder = null; + } + } + + /** + * A {@link android.view.SurfaceHolder} implementation that simply forwards all methods to a + * provided SurfaceHolder instance, except for getSurface() which returns a custom Surface. + */ + private class SurfaceHolderWrapper implements SurfaceHolder { + private final SurfaceHolder mHolder; + private final Surface mSurface; + + public SurfaceHolderWrapper(final SurfaceHolder holder, final Surface surface) { + mHolder = holder; + mSurface = surface; + } + + @Override + public void addCallback(final Callback callback) { + mHolder.addCallback(callback); + } + + @Override + public void removeCallback(final Callback callback) { + mHolder.removeCallback(callback); + } + + @Override + public boolean isCreating() { + return mHolder.isCreating(); + } + + @Override + public void setType(final int type) { + mHolder.setType(type); + } + + @Override + public void setFixedSize(final int width, final int height) { + mHolder.setFixedSize(width, height); + } + + @Override + public void setSizeFromLayout() { + mHolder.setSizeFromLayout(); + } + + @Override + public void setFormat(final int format) { + mHolder.setFormat(format); + } + + @Override + public void setKeepScreenOn(final boolean screenOn) { + mHolder.setKeepScreenOn(screenOn); + } + + @Override + public Canvas lockCanvas() { + return mHolder.lockCanvas(); + } + + @Override + public Canvas lockCanvas(final Rect dirty) { + return mHolder.lockCanvas(dirty); + } + + @Override + public void unlockCanvasAndPost(final Canvas canvas) { + mHolder.unlockCanvasAndPost(canvas); + } + + @Override + public Rect getSurfaceFrame() { + return mHolder.getSurfaceFrame(); + } + + @Override + public Surface getSurface() { + return mSurface; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java new file mode 100644 index 0000000000..ff26d99dea --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java @@ -0,0 +1,186 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Defines a map that holds a collection of values against each key. + * + * @param Key type + * @param Value type + */ +public class MultiMap { + private HashMap> mMap; + private final List 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> 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 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 addAll(final @NonNull K key, final @NonNull List values) { + if (values == null || values.isEmpty()) { + return null; + } + + ensure(key); + + final List 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 remove(final @Nullable K key) { + return mMap.remove(key); + } + + /** + * Remove a (key, value) mapping from this map + * + * @param key the key to remove + * @param value the value to remove + * @return true if the (key, value) mapping was present, false otherwise + */ + @Nullable + public boolean remove(final @Nullable K key, final @Nullable T value) { + if (!mMap.containsKey(key)) { + return false; + } + + final List values = mMap.get(key); + final boolean wasPresent = values.remove(value); + + if (values.isEmpty()) { + mMap.remove(key); + } + + return wasPresent; + } + + /** Remove all mappings from this map. */ + public void clear() { + mMap.clear(); + } + + /** + * @return a set with all the keys for this map. + */ + @NonNull + public Set keySet() { + return mMap.keySet(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java new file mode 100644 index 0000000000..7932e6c839 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java @@ -0,0 +1,225 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; + +public class NativeQueue { + private static final String LOGTAG = "GeckoNativeQueue"; + + public interface State { + boolean is(final State other); + + boolean isAtLeast(final State other); + } + + private volatile State mState; + private final State mReadyState; + + public NativeQueue(final State initial, final State ready) { + mState = initial; + mReadyState = ready; + } + + public boolean isReady() { + return getState().isAtLeast(mReadyState); + } + + public State getState() { + return mState; + } + + public boolean setState(final State newState) { + return checkAndSetState(null, newState); + } + + public synchronized boolean checkAndSetState(final State expectedState, final State newState) { + if (expectedState != null && !mState.is(expectedState)) { + return false; + } + flushQueuedLocked(newState); + mState = newState; + return true; + } + + private static class QueuedCall { + public Method method; + public Object target; + public Object[] args; + public State state; + + public QueuedCall( + final Method method, final Object target, final Object[] args, final State state) { + this.method = method; + this.target = target; + this.args = args; + this.state = state; + } + } + + private static final int QUEUED_CALLS_COUNT = 16; + /* package */ final ArrayList 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> argTypes = new ArrayList<>(args.length); + final ArrayList argValues = new ArrayList<>(args.length); + + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof Class) { + argTypes.add((Class) args[i]); + argValues.add(args[++i]); + continue; + } + Class argType = args[i].getClass(); + if (argType == Boolean.class) argType = Boolean.TYPE; + else if (argType == Byte.class) argType = Byte.TYPE; + else if (argType == Character.class) argType = Character.TYPE; + else if (argType == Double.class) argType = Double.TYPE; + else if (argType == Float.class) argType = Float.TYPE; + else if (argType == Integer.class) argType = Integer.TYPE; + else if (argType == Long.class) argType = Long.TYPE; + else if (argType == Short.class) argType = Short.TYPE; + argTypes.add(argType); + argValues.add(args[i]); + } + final Method method; + try { + method = cls.getDeclaredMethod(methodName, argTypes.toArray(new Class[argTypes.size()])); + } catch (final NoSuchMethodException e) { + throw new IllegalArgumentException("Cannot find method", e); + } + + if (!Modifier.isNative(method.getModifiers())) { + // As a precaution, we disallow queuing non-native methods. Queuing non-native + // methods is dangerous because the method could end up being called on either + // the original thread or the Gecko thread depending on timing. Native methods + // usually handle this by posting an event to the Gecko thread automatically, + // but there is no automatic mechanism for non-native methods. + throw new UnsupportedOperationException("Not allowed to queue non-native methods"); + } + + if (getState().isAtLeast(state)) { + invokeMethod(method, obj, argValues.toArray()); + return; + } + + mQueue.add(new QueuedCall(method, obj, argValues.toArray(), state)); + } + + /** + * Queue a call to the given instance method if the given current state does not satisfy the + * isReady condition. + * + * @param obj Object that declares the instance method. + * @param methodName Name of the instance method. + * @param args Args to call the instance method with; to specify a parameter type, pass in a Class + * instance first, followed by the value. + */ + public synchronized void queueUntilReady( + final Object obj, final String methodName, final Object... args) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, mReadyState); + } + + /** + * Queue a call to the given static method if the given current state does not satisfy the isReady + * condition. + * + * @param cls Class that declares the static method. + * @param methodName Name of the instance method. + * @param args Args to call the instance method with; to specify a parameter type, pass in a Class + * instance first, followed by the value. + */ + public synchronized void queueUntilReady( + final Class cls, final String methodName, final Object... args) { + queueNativeCallLocked(cls, methodName, null, args, mReadyState); + } + + /** + * Queue a call to the given instance method if the given current state does not satisfy the given + * state. + * + * @param state The state in which the native call could be executed. + * @param obj Object that declares the instance method. + * @param methodName Name of the instance method. + * @param args Args to call the instance method with; to specify a parameter type, pass in a Class + * instance first, followed by the value. + */ + public synchronized void queueUntil( + final State state, final Object obj, final String methodName, final Object... args) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, state); + } + + /** + * Queue a call to the given static method if the given current state does not satisfy the given + * state. + * + * @param state The state in which the native call could be executed. + * @param cls Class that declares the static method. + * @param methodName Name of the instance method. + * @param args Args to call the instance method with; to specify a parameter type, pass in a Class + * instance first, followed by the value. + */ + public synchronized void queueUntil( + final State state, final Class cls, final String methodName, final Object... args) { + queueNativeCallLocked(cls, methodName, null, args, state); + } + + // Run all queued methods + private void flushQueuedLocked(final State state) { + int lastSkipped = -1; + for (int i = 0; i < mQueue.size(); i++) { + final QueuedCall call = mQueue.get(i); + if (call == null) { + // We already handled the call. + continue; + } + if (!state.isAtLeast(call.state)) { + // The call is not ready yet; skip it. + lastSkipped = i; + continue; + } + // Mark as handled. + mQueue.set(i, null); + + invokeMethod(call.method, call.target, call.args); + } + if (lastSkipped < 0) { + // We're done here; release the memory + mQueue.clear(); + } else if (lastSkipped < mQueue.size() - 1) { + // We skipped some; free up null entries at the end, + // but keep all the previous entries for later. + mQueue.subList(lastSkipped + 1, mQueue.size()).clear(); + } + } + + public synchronized void reset(final State initial) { + mQueue.clear(); + mState = initial; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java new file mode 100644 index 0000000000..edd6c7418a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java @@ -0,0 +1,24 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import org.mozilla.gecko.annotation.WrapForJNI; + +class ScreenManagerHelper { + + /** Trigger a refresh of the cached screen information held by Gecko. */ + public static void refreshScreenInfo() { + // Screen data is initialised automatically on startup, so no need to queue the call if + // Gecko isn't running yet. + if (GeckoThread.isRunning()) { + nativeRefreshScreenInfo(); + } + } + + @WrapForJNI(stubName = "RefreshScreenInfo", dispatchTo = "gecko") + private static native void nativeRefreshScreenInfo(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java new file mode 100644 index 0000000000..7c6f572edc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java @@ -0,0 +1,230 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil -*- */ +/* vim: set ts=20 sts=4 et sw=4: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.os.Build; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import android.util.Log; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +public class SpeechSynthesisService { + private static final String LOGTAG = "GeckoSpeechSynthesis"; + // Object type is used to make it easier to remove android.speech dependencies using Proguard. + private static Object sTTS; + + @WrapForJNI(calledFrom = "gecko") + public static void initSynth() { + initSynthInternal(); + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void initSynthInternal() { + if (sTTS != null) { + return; + } + + final Context ctx = GeckoAppShell.getApplicationContext(); + + sTTS = + new TextToSpeech( + ctx, + new TextToSpeech.OnInitListener() { + @Override + public void onInit(final int status) { + if (status != TextToSpeech.SUCCESS) { + Log.w(LOGTAG, "Failed to initialize TextToSpeech"); + return; + } + + setUtteranceListener(); + registerVoicesByLocale(); + } + }); + } + + private static TextToSpeech getTTS() { + return (TextToSpeech) sTTS; + } + + private static void registerVoicesByLocale() { + ThreadUtils.postToBackgroundThread( + new Runnable() { + @Override + public void run() { + final TextToSpeech tss = getTTS(); + if (tss == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + final Locale defaultLocale = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 + ? tss.getDefaultLanguage() + : tss.getLanguage(); + for (final Locale locale : getAvailableLanguages()) { + final Set features = tss.getFeatures(locale); + final boolean isLocal = + features != null + && features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS); + final String localeStr = locale.toString(); + registerVoice( + "moz-tts:android:" + localeStr, + locale.getDisplayName(), + localeStr.replace("_", "-"), + !isLocal, + defaultLocale == locale); + } + doneRegisteringVoices(); + } + }); + } + + private static Set getAvailableLanguages() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // While this method was introduced in 21, it seems that it + // has not been implemented in the speech service side until 23. + final Set availableLanguages = getTTS().getAvailableLanguages(); + if (availableLanguages != null) { + return availableLanguages; + } + } + final Set locales = new HashSet(); + for (final Locale locale : Locale.getAvailableLocales()) { + if (locale.getVariant().isEmpty() && getTTS().isLanguageAvailable(locale) > 0) { + locales.add(locale); + } + } + + return locales; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void registerVoice( + String uri, String name, String locale, boolean isNetwork, boolean isDefault); + + @WrapForJNI(dispatchTo = "gecko") + private static native void doneRegisteringVoices(); + + @WrapForJNI(calledFrom = "gecko") + public static String speak( + final String uri, + final String text, + final float rate, + final float pitch, + final float volume) { + final AtomicBoolean result = new AtomicBoolean(false); + final String utteranceId = UUID.randomUUID().toString(); + speakInternal(uri, text, rate, pitch, volume, utteranceId, result); + return result.get() ? utteranceId : null; + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void speakInternal( + final String uri, + final String text, + final float rate, + final float pitch, + final float volume, + final String utteranceId, + final AtomicBoolean result) { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + final HashMap params = new HashMap(); + params.put(TextToSpeech.Engine.KEY_PARAM_VOLUME, Float.toString(volume)); + params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); + final TextToSpeech tss = (TextToSpeech) sTTS; + tss.setLanguage(new Locale(uri.substring("moz-tts:android:".length()))); + tss.setSpeechRate(rate); + tss.setPitch(pitch); + final int speakRes = tss.speak(text, TextToSpeech.QUEUE_FLUSH, params); + result.set(speakRes == TextToSpeech.SUCCESS); + } + + private static void setUtteranceListener() { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + getTTS() + .setOnUtteranceProgressListener( + new UtteranceProgressListener() { + @Override + public void onDone(final String utteranceId) { + dispatchEnd(utteranceId); + } + + @Override + public void onError(final String utteranceId) { + dispatchError(utteranceId); + } + + @Override + public void onStart(final String utteranceId) { + dispatchStart(utteranceId); + } + + @Override + public void onStop(final String utteranceId, final boolean interrupted) { + if (interrupted) { + dispatchEnd(utteranceId); + } else { + // utterance isn't started yet. + dispatchError(utteranceId); + } + } + + public void onRangeStart( + final String utteranceId, final int start, final int end, final int frame) { + dispatchBoundary(utteranceId, start, end); + } + }); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchStart(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchEnd(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchError(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchBoundary(String utteranceId, int start, int end); + + @WrapForJNI(calledFrom = "gecko") + public static void stop() { + stopInternal(); + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void stopInternal() { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + getTTS().stop(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // Android M has onStop method. If Android L or above, dispatch + // event + dispatchEnd(null); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java new file mode 100644 index 0000000000..d5258d7bd0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java @@ -0,0 +1,198 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PixelFormat; +import android.graphics.SurfaceTexture; +import android.os.Build; +import android.util.Log; +import android.view.Surface; +import android.view.SurfaceControl; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; + +/** Provides transparent access to either a SurfaceView or TextureView */ +public class SurfaceViewWrapper { + private static final String LOGTAG = "SurfaceViewWrapper"; + + private ListenerWrapper mListenerWrapper; + private View mView; + + // Only one of these will be non-null at any point in time + SurfaceView mSurfaceView; + TextureView mTextureView; + + public SurfaceViewWrapper(final Context context) { + // By default, use SurfaceView + mListenerWrapper = new ListenerWrapper(); + initSurfaceView(context); + } + + private void initSurfaceView(final Context context) { + mSurfaceView = new MagnifiableSurfaceView(context); + mSurfaceView.setBackgroundColor(Color.TRANSPARENT); + mSurfaceView.getHolder().setFormat(PixelFormat.TRANSPARENT); + mView = mSurfaceView; + } + + public void useSurfaceView(final Context context) { + if (mTextureView != null) { + mListenerWrapper.onSurfaceTextureDestroyed(mTextureView.getSurfaceTexture()); + mTextureView = null; + } + mListenerWrapper.reset(); + initSurfaceView(context); + } + + public void useTextureView(final Context context) { + if (mSurfaceView != null) { + mListenerWrapper.surfaceDestroyed(mSurfaceView.getHolder()); + mSurfaceView = null; + } + mListenerWrapper.reset(); + mTextureView = new TextureView(context); + mTextureView.setSurfaceTextureListener(mListenerWrapper); + mView = mTextureView; + } + + public void setBackgroundColor(final int color) { + if (mSurfaceView != null) { + mSurfaceView.setBackgroundColor(color); + } else { + Log.e(LOGTAG, "TextureView doesn't support background color."); + } + } + + public void setListener(final Listener listener) { + mListenerWrapper.mListener = listener; + mSurfaceView.getHolder().addCallback(mListenerWrapper); + } + + public int getWidth() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurfaceFrame().right; + } + return mListenerWrapper.mWidth; + } + + public int getHeight() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurfaceFrame().bottom; + } + return mListenerWrapper.mHeight; + } + + /** + * Returns the SurfaceControl associated with the SurfaceView, or null on unsupported SDK versions + * or when using the TextureView backend. + */ + public SurfaceControl getSurfaceControl() { + if (mSurfaceView != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return mSurfaceView.getSurfaceControl(); + } + + return null; + } + + public Surface getSurface() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurface(); + } + + return mListenerWrapper.mSurface; + } + + public View getView() { + return mView; + } + + /** + * Translates SurfaceTextureListener and SurfaceHolder.Callback into a common interface + * SurfaceViewWrapper.Listener + */ + private class ListenerWrapper + implements TextureView.SurfaceTextureListener, SurfaceHolder.Callback { + private Listener mListener; + + // TextureView doesn't provide getters for these so we keep track of them here + private Surface mSurface; + private int mWidth; + private int mHeight; + + public void reset() { + mWidth = 0; + mHeight = 0; + mSurface = null; + } + + // TextureView + @Override + public void onSurfaceTextureAvailable( + final SurfaceTexture surface, final int width, final int height) { + mSurface = new Surface(surface); + mWidth = width; + mHeight = height; + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, width, height); + } + } + + @Override + public void onSurfaceTextureSizeChanged( + final SurfaceTexture surface, final int width, final int height) { + mWidth = width; + mHeight = height; + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight); + } + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surface) { + if (mListener != null) { + mListener.onSurfaceDestroyed(); + } + mSurface = null; + return false; + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surface) { + mSurface = new Surface(surface); + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight); + } + } + + // SurfaceView + @Override + public void surfaceCreated(final SurfaceHolder holder) {} + + @Override + public void surfaceChanged( + final SurfaceHolder holder, final int format, final int width, final int height) { + if (mListener != null) { + mListener.onSurfaceChanged(holder.getSurface(), getSurfaceControl(), width, height); + } + } + + @Override + public void surfaceDestroyed(final SurfaceHolder holder) { + if (mListener != null) { + mListener.onSurfaceDestroyed(); + } + } + } + + public interface Listener { + void onSurfaceChanged(Surface surface, SurfaceControl surfaceControl, int width, int height); + + void onSurfaceDestroyed(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java new file mode 100644 index 0000000000..3c9c1f90a0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java @@ -0,0 +1,102 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko; + +import android.os.SystemClock; +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * All telemetry times are relative to one of two clocks: + * + *

    * 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 String LOGTAG = "TelemetryUtils"; + + @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko") + private static native void nativeAddHistogram(String name, int value); + + public static long uptime() { + return SystemClock.uptimeMillis(); + } + + public static long realtime() { + return SystemClock.elapsedRealtime(); + } + + // Define new histograms in: + // toolkit/components/telemetry/Histograms.json + public static void addToHistogram(final String name, final int value) { + if (GeckoThread.isRunning()) { + nativeAddHistogram(name, value); + } else { + GeckoThread.queueNativeCall( + TelemetryUtils.class, "nativeAddHistogram", String.class, name, value); + } + } + + public abstract static class Timer { + private final long mStartTime; + private final String mName; + + private volatile boolean mHasFinished; + private volatile long mElapsed = -1; + + protected abstract long now(); + + public Timer(final String name) { + mName = name; + mStartTime = now(); + } + + public void cancel() { + mHasFinished = true; + } + + public long getElapsed() { + return mElapsed; + } + + public void stop() { + // Only the first stop counts. + if (mHasFinished) { + return; + } + + mHasFinished = true; + + final long elapsed = now() - mStartTime; + if (elapsed < 0) { + Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?"); + return; + } + + mElapsed = elapsed; + if (elapsed > Integer.MAX_VALUE) { + Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram."); + return; + } + + addToHistogram(mName, (int) (elapsed)); + } + } + + public static class UptimeTimer extends Timer { + public UptimeTimer(final String name) { + super(name); + } + + @Override + protected long now() { + return TelemetryUtils.uptime(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java new file mode 100644 index 0000000000..805e0a3f79 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java @@ -0,0 +1,25 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to tag classes that are conditionally built behind build flags. Any + * generated JNI bindings will incorporate the specified build flags. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface BuildFlag { + /** + * Preprocessor macro for conditionally building the generated bindings. "MOZ_FOO" wraps generated + * bindings in "#ifdef MOZ_FOO / #endif" "!MOZ_FOO" wraps generated bindings in "#ifndef MOZ_FOO / + * #endif" + */ + String value() default ""; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java new file mode 100644 index 0000000000..d6140a1ffb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface JNITarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java new file mode 100644 index 0000000000..e873ebeb96 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/* + * Used to indicate to ProGuard that this definition is accessed + * via reflection and should not be stripped from the source. + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface ReflectionTarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java new file mode 100644 index 0000000000..e15875dc8b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface RobocopTarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java new file mode 100644 index 0000000000..f58dea1487 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface WebRTCJNITarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java new file mode 100644 index 0000000000..6a3fcfcb1c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to tag methods that are to have wrapper methods generated. Such methods + * will be protected from destruction by ProGuard, and allow us to avoid writing by hand large + * amounts of boring boilerplate. + */ +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR}) +@Retention(RetentionPolicy.RUNTIME) +public @interface WrapForJNI { + /** Skip this member when generating wrappers for a whole class. */ + boolean skip() default false; + + /** + * Optional parameter specifying the name of the generated method stub. If omitted, the + * capitalized name of the Java method will be used. + */ + String stubName() default ""; + + /** + * Action to take if member access returns an exception. - "abort" will cause a crash if there is + * a pending exception. - "ignore" will not handle any pending exceptions; it is then the caller's + * responsibility to handle exceptions. - "nsresult" will clear any pending exceptions and return + * an error code; not supported for native methods. + */ + String exceptionMode() default "abort"; + + /** + * The thread that the method will be called from. One of "any", "gecko", or "ui". Not supported + * for fields. + */ + String calledFrom() default "any"; + + /** + * The thread that the method call will be dispatched to. - "current" indicates no dispatching; + * only supported value for fields, constructors, non-native methods, and non-void native methods. + * - "gecko" indicates dispatching to the Gecko XPCOM (nsThread) event queue. - "gecko_priority" + * indicates dispatching to the Gecko widget (nsAppShell) event queue; in most cases, events in + * the widget event queue (aka native event queue) are favored over events in the XPCOM event + * queue. - "proxy" indicates dispatching to a proxy function as a function object; see + * widget/jni/Natives.h. + */ + String dispatchTo() default "current"; + + /** Generate a getter instead of a literal. Only supported for static final fields. */ + boolean noLiteral() default false; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java new file mode 100644 index 0000000000..c87bf466d0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java @@ -0,0 +1,72 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.Handler; +import android.os.Looper; +import android.view.Choreographer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** This class receives HW vsync events through a {@link Choreographer}. */ +@WrapForJNI +/* package */ final class AndroidVsync extends JNIObject implements Choreographer.FrameCallback { + @WrapForJNI + @Override // JNIObject + protected native void disposeNative(); + + private static final String LOGTAG = "AndroidVsync"; + + /* package */ Choreographer mChoreographer; + private volatile boolean mObservingVsync; + + public AndroidVsync() { + final Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post( + new Runnable() { + @Override + public void run() { + mChoreographer = Choreographer.getInstance(); + if (mObservingVsync) { + mChoreographer.postFrameCallback(AndroidVsync.this); + } + } + }); + } + + @WrapForJNI(stubName = "NotifyVsync") + private native void nativeNotifyVsync(final long frameTimeNanos); + + // Choreographer callback implementation. + public void doFrame(final long frameTimeNanos) { + if (mObservingVsync) { + mChoreographer.postFrameCallback(this); + nativeNotifyVsync(frameTimeNanos); + } + } + + /** + * Start/stop observing Vsync event. + * + * @param enable true to start observing; false to stop. + * @return true if observing and false if not. + */ + @WrapForJNI + public synchronized boolean observeVsync(final boolean enable) { + if (mObservingVsync != enable) { + mObservingVsync = enable; + + if (mChoreographer != null) { + if (enable) { + mChoreographer.postFrameCallback(this); + } else { + mChoreographer.removeFrameCallback(this); + } + } + } + return mObservingVsync; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java new file mode 100644 index 0000000000..1378a284b7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java @@ -0,0 +1,26 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.RemoteException; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class CompositorSurfaceManager { + private static final String LOGTAG = "CompSurfManager"; + + private ICompositorSurfaceManager mManager; + + public CompositorSurfaceManager(final ICompositorSurfaceManager aManager) { + mManager = aManager; + } + + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) + throws RemoteException { + mManager.onSurfaceChanged(widgetId, surface); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java new file mode 100644 index 0000000000..7cf891aa59 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java @@ -0,0 +1,152 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import static org.mozilla.geckoview.BuildConfig.DEBUG_BUILD; + +import android.os.Parcel; +import android.os.Parcelable; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class GeckoSurface implements Parcelable { + private static final String LOGTAG = "GeckoSurface"; + + private Surface mSurface; + private long mHandle; + private boolean mIsSingleBuffer; + private volatile boolean mIsAvailable; + private boolean mOwned = true; + private volatile boolean mIsReleased = false; + + private int mMyPid; + // Locally allocated surface/texture. Do not pass it over IPC. + private GeckoSurface mSyncSurface; + + @WrapForJNI(exceptionMode = "nsresult") + public GeckoSurface(final GeckoSurfaceTexture gst) { + mSurface = new Surface(gst); + mHandle = gst.getHandle(); + mIsSingleBuffer = gst.isSingleBuffer(); + mIsAvailable = true; + mMyPid = android.os.Process.myPid(); + } + + public GeckoSurface(final Parcel p) { + mSurface = Surface.CREATOR.createFromParcel(p); + mHandle = p.readLong(); + mIsSingleBuffer = p.readByte() == 1 ? true : false; + mIsAvailable = (p.readByte() == 1 ? true : false); + mMyPid = p.readInt(); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + public GeckoSurface createFromParcel(final Parcel p) { + return new GeckoSurface(p); + } + + public GeckoSurface[] newArray(final int size) { + return new GeckoSurface[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + mSurface.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. + mSurface.release(); + } + mOwned = false; + + out.writeLong(mHandle); + out.writeByte((byte) (mIsSingleBuffer ? 1 : 0)); + out.writeByte((byte) (mIsAvailable ? 1 : 0)); + out.writeInt(mMyPid); + } + + public void release() { + if (mIsReleased) { + return; + } + mIsReleased = true; + + if (mSyncSurface != null) { + mSyncSurface.release(); + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(mSyncSurface.getHandle()); + if (gst != null) { + gst.decrementUse(); + } + mSyncSurface = null; + } + + if (mOwned) { + mSurface.release(); + } + } + + @WrapForJNI + public long getHandle() { + return mHandle; + } + + @WrapForJNI + public Surface getSurface() { + return mSurface; + } + + @WrapForJNI + public boolean getAvailable() { + return mIsAvailable; + } + + @WrapForJNI + public boolean isReleased() { + return mIsReleased; + } + + @WrapForJNI + public void setAvailable(final boolean available) { + mIsAvailable = available; + } + + /* package */ boolean inProcess() { + return android.os.Process.myPid() == mMyPid; + } + + /* package */ SyncConfig initSyncSurface(final int width, final int height) { + if (DEBUG_BUILD) { + if (inProcess()) { + throw new AssertionError("no need for sync when allocated in process"); + } + } + if (GeckoSurfaceTexture.lookup(mHandle) != null) { + throw new AssertionError("texture#" + mHandle + " already in use."); + } + final GeckoSurfaceTexture texture = + GeckoSurfaceTexture.acquire(GeckoSurfaceTexture.isSingleBufferSupported(), mHandle); + if (texture != null) { + texture.setDefaultBufferSize(width, height); + texture.track(mHandle); + mSyncSurface = new GeckoSurface(texture); + return new SyncConfig(mHandle, mSyncSurface, width, height); + } + + return null; + } +} 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..2d045edb44 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java @@ -0,0 +1,330 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.SurfaceTexture; +import android.os.Build; +import android.util.Log; +import android.util.LongSparseArray; +import androidx.annotation.RequiresApi; +import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/* package */ final class GeckoSurfaceTexture extends SurfaceTexture { + private static final String LOGTAG = "GeckoSurfaceTexture"; + private static final int MAX_SURFACE_TEXTURES = 200; + private static final LongSparseArray sSurfaceTextures = + new LongSparseArray(); + + private static LongSparseArray> sUnusedTextures = + new LongSparseArray>(); + + private long mHandle; + private boolean mIsSingleBuffer; + + private long mAttachedContext; + private int mTexName; + + private GeckoSurfaceTexture.Callbacks mListener; + private AtomicInteger mUseCount; + private boolean mFinalized; + + private long mUpstream; + private NativeGLBlitHelper mBlitter; + + private GeckoSurfaceTexture(final long handle) { + super(0); + init(handle, false); + } + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private GeckoSurfaceTexture(final long handle, final boolean singleBufferMode) { + super(0, singleBufferMode); + init(handle, singleBufferMode); + } + + @Override + protected void finalize() throws Throwable { + // We only want finalize() to be called once + if (mFinalized) { + return; + } + + mFinalized = true; + super.finalize(); + } + + private void init(final long handle, final boolean singleBufferMode) { + mHandle = handle; + mIsSingleBuffer = singleBufferMode; + mUseCount = new AtomicInteger(1); + + // Start off detached + detachFromGLContext(); + } + + @WrapForJNI + public long getHandle() { + return mHandle; + } + + @WrapForJNI + public int getTexName() { + return mTexName; + } + + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void attachToGLContext(final long context, final int texName) { + if (context == mAttachedContext && texName == mTexName) { + return; + } + + attachToGLContext(texName); + + mAttachedContext = context; + mTexName = texName; + } + + @Override + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void detachFromGLContext() { + super.detachFromGLContext(); + + mAttachedContext = mTexName = 0; + } + + @WrapForJNI + public synchronized boolean isAttachedToGLContext(final long context) { + return mAttachedContext == context; + } + + @WrapForJNI + public boolean isSingleBuffer() { + return mIsSingleBuffer; + } + + @Override + @WrapForJNI + public synchronized void updateTexImage() { + try { + if (mUpstream != 0) { + SurfaceAllocator.sync(mUpstream); + } + super.updateTexImage(); + if (mListener != null) { + mListener.onUpdateTexImage(); + } + } catch (final Exception e) { + Log.w(LOGTAG, "updateTexImage() failed", e); + } + } + + @Override + public synchronized void release() { + mUpstream = 0; + if (mBlitter != null) { + mBlitter.close(); + } + try { + super.release(); + synchronized (sSurfaceTextures) { + sSurfaceTextures.remove(mHandle); + } + } catch (final Exception e) { + Log.w(LOGTAG, "release() failed", e); + } + } + + @Override + @WrapForJNI + public synchronized void releaseTexImage() { + if (!mIsSingleBuffer) { + return; + } + + try { + super.releaseTexImage(); + if (mListener != null) { + mListener.onReleaseTexImage(); + } + } catch (final Exception e) { + Log.w(LOGTAG, "releaseTexImage() failed", e); + } + } + + public synchronized void setListener(final GeckoSurfaceTexture.Callbacks listener) { + mListener = listener; + } + + @WrapForJNI + public static boolean isSingleBufferSupported() { + return Build.VERSION.SDK_INT >= 19; + } + + @WrapForJNI + public synchronized void incrementUse() { + mUseCount.incrementAndGet(); + } + + @WrapForJNI + public synchronized void decrementUse() { + final int useCount = mUseCount.decrementAndGet(); + + if (useCount == 0) { + setListener(null); + + if (mAttachedContext == 0) { + release(); + synchronized (sUnusedTextures) { + sSurfaceTextures.remove(mHandle); + } + return; + } + + synchronized (sUnusedTextures) { + LinkedList list = sUnusedTextures.get(mAttachedContext); + if (list == null) { + list = new LinkedList(); + sUnusedTextures.put(mAttachedContext, list); + } + list.addFirst(this); + } + } + } + + @WrapForJNI + public static void destroyUnused(final long context) { + final LinkedList list; + synchronized (sUnusedTextures) { + list = sUnusedTextures.get(context); + sUnusedTextures.delete(context); + } + + if (list == null) { + return; + } + + for (final GeckoSurfaceTexture tex : list) { + try { + if (tex.isSingleBuffer()) { + tex.releaseTexImage(); + } + + tex.detachFromGLContext(); + tex.release(); + + // We need to manually call finalize here, otherwise we can run out + // of file descriptors if the GC doesn't kick in soon enough. Bug 1416015. + try { + tex.finalize(); + } catch (final Throwable t) { + Log.e(LOGTAG, "Failed to finalize SurfaceTexture", t); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to destroy SurfaceTexture", e); + } + } + } + + public static GeckoSurfaceTexture acquire(final boolean singleBufferMode, final long handle) { + if (singleBufferMode && !isSingleBufferSupported()) { + throw new IllegalArgumentException("single buffer mode not supported on API version < 19"); + } + + // Attempting to create a SurfaceTexture from an isolated process on Android versions prior to + // 8.0 results in an indefinite hang. See bug 1706656. + if (GeckoAppShell.isIsolatedProcess() && Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return null; + } + + synchronized (sSurfaceTextures) { + // We want to limit the maximum number of SurfaceTextures at any one time. + // This is because they use a large number of fds, and once the process' limit + // is reached bad things happen. See bug 1421586. + if (sSurfaceTextures.size() >= MAX_SURFACE_TEXTURES) { + return null; + } + + if (sSurfaceTextures.indexOfKey(handle) >= 0) { + throw new IllegalArgumentException("Already have a GeckoSurfaceTexture with that handle"); + } + + final GeckoSurfaceTexture gst; + if (isSingleBufferSupported()) { + gst = new GeckoSurfaceTexture(handle, singleBufferMode); + } else { + gst = new GeckoSurfaceTexture(handle); + } + + sSurfaceTextures.put(handle, gst); + + return gst; + } + } + + @WrapForJNI + public static GeckoSurfaceTexture lookup(final long handle) { + synchronized (sSurfaceTextures) { + return sSurfaceTextures.get(handle); + } + } + + /* package */ synchronized void track(final long upstream) { + mUpstream = upstream; + } + + /* package */ synchronized void configureSnapshot( + final GeckoSurface target, final int width, final int height) { + mBlitter = NativeGLBlitHelper.create(mHandle, target, width, height); + } + + /* package */ synchronized void takeSnapshot() { + mBlitter.blit(); + } + + public interface Callbacks { + void onUpdateTexImage(); + + void onReleaseTexImage(); + } + + @WrapForJNI + public static final class NativeGLBlitHelper extends JNIObject { + public static NativeGLBlitHelper create( + final long textureHandle, + final GeckoSurface targetSurface, + final int width, + final int height) { + final NativeGLBlitHelper helper = nativeCreate(textureHandle, targetSurface, width, height); + helper.mTargetSurface = targetSurface; // Take ownership of surface. + return helper; + } + + public static native NativeGLBlitHelper nativeCreate( + final long textureHandle, + final GeckoSurface targetSurface, + final int width, + final int height); + + public native void blit(); + + public void close() { + disposeNative(); + if (mTargetSurface != null) { + mTargetSurface.release(); + mTargetSurface = null; + } + } + + @Override + protected native void disposeNative(); + + private GeckoSurface mTargetSurface; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java new file mode 100644 index 0000000000..b8ceb74f0b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java @@ -0,0 +1,71 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.SystemClock; +import android.util.Log; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.annotation.RobocopTarget; + +public final class PanningPerfAPI { + private static final String LOGTAG = "GeckoPanningPerfAPI"; + + // make this large enough to avoid having to resize the frame time + // list, as that may be expensive and impact the thing we're trying + // to measure. + private static final int EXPECTED_FRAME_COUNT = 2048; + + private static boolean mRecordingFrames; + private static List mFrameTimes; + private static long mFrameStartTime; + + private static void initialiseRecordingArrays() { + if (mFrameTimes == null) { + mFrameTimes = new ArrayList(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 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 stopCheckerboardRecording() { + throw new UnsupportedOperationException(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java new file mode 100644 index 0000000000..3244519da1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java @@ -0,0 +1,77 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import java.util.concurrent.atomic.AtomicInteger; + +public final class RemoteSurfaceAllocator extends ISurfaceAllocator.Stub { + private static final String LOGTAG = "RemoteSurfaceAllocator"; + + private static RemoteSurfaceAllocator mInstance; + + private final int mAllocatorId; + /// Monotonically increasing counter used to generate unique handles + /// for each SurfaceTexture by combining with mAllocatorId. + private static AtomicInteger sNextHandle = new AtomicInteger(1); + + /** + * Retrieves the singleton allocator instance for this process. + * + * @param allocatorId A unique ID identifying the process this instance belongs to, which must be + * 0 for the parent process instance. + */ + public static synchronized RemoteSurfaceAllocator getInstance(final int allocatorId) { + if (mInstance == null) { + mInstance = new RemoteSurfaceAllocator(allocatorId); + } + return mInstance; + } + + private RemoteSurfaceAllocator(final int allocatorId) { + mAllocatorId = allocatorId; + } + + @Override + public GeckoSurface acquireSurface( + final int width, final int height, final boolean singleBufferMode) { + final long handle = ((long) mAllocatorId << 32) | sNextHandle.getAndIncrement(); + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.acquire(singleBufferMode, handle); + + if (gst == null) { + return null; + } + + if (width > 0 && height > 0) { + gst.setDefaultBufferSize(width, height); + } + + return new GeckoSurface(gst); + } + + @Override + public void releaseSurface(final long handle) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle); + if (gst != null) { + gst.decrementUse(); + } + } + + @Override + public void configureSync(final SyncConfig config) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(config.sourceTextureHandle); + if (gst != null) { + gst.configureSnapshot(config.targetSurface, config.width, config.height); + } + } + + @Override + public void sync(final long handle) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle); + if (gst != null) { + gst.takeSnapshot(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java new file mode 100644 index 0000000000..89fba6c2f9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java @@ -0,0 +1,143 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.util.LongSparseArray; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.process.GeckoProcessManager; +import org.mozilla.gecko.process.GeckoServiceChildProcess; + +/* package */ final class SurfaceAllocator { + private static final String LOGTAG = "SurfaceAllocator"; + + private static ISurfaceAllocator sAllocator; + + // Keep a reference to all allocated Surfaces, so that we can release them if we lose the + // connection to the allocator service. + private static final LongSparseArray sSurfaces = + new LongSparseArray(); + + private static synchronized void ensureConnection() { + if (sAllocator != null) { + return; + } + + try { + if (GeckoAppShell.isParentProcess()) { + sAllocator = GeckoProcessManager.getInstance().getSurfaceAllocator(); + } else { + sAllocator = GeckoServiceChildProcess.getSurfaceAllocator(); + } + + if (sAllocator == null) { + Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator"); + return; + } + sAllocator + .asBinder() + .linkToDeath( + new IBinder.DeathRecipient() { + @Override + public void binderDied() { + Log.w(LOGTAG, "RemoteSurfaceAllocator died"); + synchronized (SurfaceAllocator.class) { + // Our connection to the remote allocator has died, so all our surfaces are + // invalid. Release them all now. When their owners attempt to render in to + // them they can detect they have been released and allocate new ones instead. + for (int i = 0; i < sSurfaces.size(); i++) { + sSurfaces.valueAt(i).release(); + } + sSurfaces.clear(); + sAllocator = null; + } + } + }, + 0); + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator", e); + sAllocator = null; + } + } + + @WrapForJNI + public static synchronized GeckoSurface acquireSurface( + final int width, final int height, final boolean singleBufferMode) { + try { + ensureConnection(); + + if (sAllocator == null) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface: not connected"); + return null; + } + + if (singleBufferMode && !GeckoSurfaceTexture.isSingleBufferSupported()) { + return null; + } + + final GeckoSurface surface = sAllocator.acquireSurface(width, height, singleBufferMode); + if (surface == null) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface: RemoteSurfaceAllocator returned null"); + return null; + } + sSurfaces.put(surface.getHandle(), surface); + + if (!surface.inProcess()) { + final SyncConfig config = surface.initSyncSurface(width, height); + if (config != null) { + sAllocator.configureSync(config); + } + } + return surface; + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface", e); + return null; + } + } + + @WrapForJNI + public static synchronized void disposeSurface(final GeckoSurface surface) { + // If the surface has already been released (probably due to losing connection to the remote + // allocator) then there is nothing to do here. + if (surface.isReleased()) { + return; + } + + sSurfaces.remove(surface.getHandle()); + + // Release our Surface + surface.release(); + + if (sAllocator == null) { + return; + } + + // Release the SurfaceTexture on the other side. If we have lost connection then do nothing, as + // there is nothing on the other side to release. + try { + if (sAllocator != null) { + sAllocator.releaseSurface(surface.getHandle()); + } + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to release surface texture", e); + } + } + + public static synchronized void sync(final long upstream) { + // Sync from the SurfaceTexture on the other side. If we have lost connection then do nothing, + // as there is nothing on the other side to sync from. + try { + if (sAllocator != null) { + sAllocator.sync(upstream); + } + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to sync texture", e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java new file mode 100644 index 0000000000..e02ab98952 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java @@ -0,0 +1,105 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.Build; +import android.view.Surface; +import android.view.SurfaceControl; +import androidx.annotation.RequiresApi; +import java.util.Iterator; +import java.util.Map; +import java.util.WeakHashMap; +import org.mozilla.gecko.annotation.WrapForJNI; + +// A helper class that creates Surfaces from SurfaceControl objects, for the widget to render in to. +// Unlike the Surfaces provided to the widget directly from the application, these are suitable for +// use in the GPU process as well as the main process. +// +// The reason we must not render directly in to the Surface provided by the application from the GPU +// process is because of a bug on Android versions 12 and later: when the GPU process dies the +// Surface is not detached from the dead process' EGL surface, and any subsequent attempts to +// attach another EGL surface to the Surface will fail. +// +// The application is therefore required to provide the SurfaceControl object to a GeckoDisplay +// whenever rendering in to a SurfaceView. The widget will then obtain a Surface from that +// SurfaceControl using getChildSurface(). Internally, this creates another SurfaceControl as a +// child of the provided SurfaceControl, then creates the Surface from that child. If the GPU +// process dies we are able to simply destroy and recreate the child SurfaceControl objects, thereby +// avoiding the bug. +public class SurfaceControlManager { + private static final String LOGTAG = "SurfaceControlManager"; + + private static final SurfaceControlManager sInstance = new SurfaceControlManager(); + + private WeakHashMap mChildSurfaceControls = new WeakHashMap<>(); + + @WrapForJNI + public static SurfaceControlManager getInstance() { + return sInstance; + } + + // Returns a Surface of the requested size that will be composited in to the specified + // SurfaceControl. + @RequiresApi(api = Build.VERSION_CODES.Q) + @WrapForJNI(exceptionMode = "abort") + public synchronized Surface getChildSurface( + final SurfaceControl parent, final int width, final int height) { + SurfaceControl child = mChildSurfaceControls.get(parent); + if (child == null) { + // We must periodically check if any of the SurfaceControls we are managing have been + // destroyed, as we are unable to directly listen to their SurfaceViews' surfaceDestroyed + // callbacks, and they may not be attached to any compositor when they are destroyed meaning + // we cannot perform cleanup in response to the compositor being paused. + // Doing so here, when we encounter a new SurfaceControl instance, is a reasonable guess as to + // when a previous instance may have been released. + final Iterator> it = + mChildSurfaceControls.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry entry = it.next(); + if (!entry.getKey().isValid()) { + it.remove(); + } + } + + child = new SurfaceControl.Builder().setParent(parent).setName("GeckoSurface").build(); + mChildSurfaceControls.put(parent, child); + } + + new SurfaceControl.Transaction() + .setVisibility(child, true) + .setBufferSize(child, width, height) + .apply(); + + return new Surface(child); + } + + // Removes an existing parent SurfaceControl and its corresponding child from the manager. This + // can be used when we require the next call to getChildSurface() for the specified parent to + // create a new child rather than return the existing one. + @RequiresApi(api = Build.VERSION_CODES.Q) + @WrapForJNI(exceptionMode = "abort") + public synchronized void removeSurface(final SurfaceControl parent) { + final SurfaceControl child = mChildSurfaceControls.remove(parent); + if (child != null) { + child.release(); + } + } + + // Must be called whenever the GPU process has died. This destroys all the child SurfaceControls + // that have been created, meaning subsequent calls to getChildSurface() will create new ones. + @RequiresApi(api = Build.VERSION_CODES.Q) + @WrapForJNI(exceptionMode = "abort") + public synchronized void onGpuProcessLoss() { + for (final SurfaceControl child : mChildSurfaceControls.values()) { + // We could reparent the child SurfaceControl to null here to immediately remove it from the + // tree. However, this will result in a black screen while we wait for the new compositor to + // be created. It's preferable for the user to see the old content instead, so simply call + // release(). + child.release(); + } + mChildSurfaceControls.clear(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java new file mode 100644 index 0000000000..0ba79d1f42 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.graphics.SurfaceTexture; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/* package */ final class SurfaceTextureListener extends JNIObject + implements SurfaceTexture.OnFrameAvailableListener { + @WrapForJNI(calledFrom = "gecko") + private SurfaceTextureListener() {} + + @WrapForJNI(dispatchTo = "gecko") + @Override // JNIObject + protected native void disposeNative(); + + @Override + protected void finalize() { + disposeNative(); + } + + @WrapForJNI(stubName = "OnFrameAvailable") + private native void nativeOnFrameAvailable(); + + @Override // SurfaceTexture.OnFrameAvailableListener + public void onFrameAvailable(final SurfaceTexture surfaceTexture) { + try { + nativeOnFrameAvailable(); + } catch (final NullPointerException e) { + // Ignore exceptions caused by a disposed object, i.e. + // getting a callback after this listener is no longer in use. + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java new file mode 100644 index 0000000000..d8e2099ddc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.gfx; + +import android.os.Parcel; +import android.os.Parcelable; + +/* package */ final class SyncConfig implements Parcelable { + final long sourceTextureHandle; + final GeckoSurface targetSurface; + final int width; + final int height; + + /* package */ SyncConfig( + final long sourceTextureHandle, + final GeckoSurface targetSurface, + final int width, + final int height) { + this.sourceTextureHandle = sourceTextureHandle; + this.targetSurface = targetSurface; + this.width = width; + this.height = height; + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SyncConfig createFromParcel(final Parcel parcel) { + return new SyncConfig(parcel); + } + + @Override + public SyncConfig[] newArray(final int i) { + return new SyncConfig[i]; + } + }; + + private SyncConfig(final Parcel parcel) { + sourceTextureHandle = parcel.readLong(); + targetSurface = GeckoSurface.CREATOR.createFromParcel(parcel); + width = parcel.readInt(); + height = parcel.readInt(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + parcel.writeLong(sourceTextureHandle); + targetSurface.writeToParcel(parcel, flags); + parcel.writeInt(width); + parcel.writeInt(height); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java new file mode 100644 index 0000000000..d1d0728fac --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Handler; +import android.view.Surface; +import java.nio.ByteBuffer; + +// A wrapper interface that mimics the new {@link android.media.MediaCodec} +// asynchronous mode API in Lollipop. +public interface AsyncCodec { + public interface Callbacks { + void onInputBufferAvailable(AsyncCodec codec, int index); + + void onOutputBufferAvailable(AsyncCodec codec, int index, BufferInfo info); + + void onError(AsyncCodec codec, int error); + + void onOutputFormatChanged(AsyncCodec codec, MediaFormat format); + } + + public abstract void setCallbacks(Callbacks callbacks, Handler handler); + + public abstract void configure( + MediaFormat format, Surface surface, MediaCrypto crypto, int flags); + + public abstract boolean isAdaptivePlaybackSupported(String mimeType); + + public abstract boolean isTunneledPlaybackSupported(final String mimeType); + + public abstract void start(); + + public abstract void stop(); + + public abstract void flush(); + + // Must be called after flush(). + public abstract void resumeReceivingInputs(); + + public abstract void release(); + + public abstract ByteBuffer getInputBuffer(int index); + + public abstract MediaFormat getInputFormat(); + + public abstract ByteBuffer getOutputBuffer(int index); + + public abstract void queueInputBuffer( + int index, int offset, int size, long presentationTimeUs, int flags); + + public abstract void setBitrate(int bps); + + public abstract void queueSecureInputBuffer( + int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); + + public abstract void releaseOutputBuffer(int index, boolean render); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java new file mode 100644 index 0000000000..3295919b91 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.os.Build; +import java.io.IOException; + +public final class AsyncCodecFactory { + public static AsyncCodec create(final String name) throws IOException { + // A bug that getInputBuffer() could fail after flush() then start() wasn't fixed until MR1. + // See: + // https://android.googlesource.com/platform/frameworks/av/+/d9e0603a1be07dbb347c55050c7d4629ea7492e8 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + ? new LollipopAsyncCodec(name) + : new JellyBeanAsyncCodec(name); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java new file mode 100644 index 0000000000..d9556d545d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import java.util.concurrent.ConcurrentLinkedQueue; + +public interface BaseHlsPlayer { + + public enum TrackType { + UNDEFINED, + AUDIO, + VIDEO, + TEXT, + } + + public enum ResourceError { + BASE(-100), + UNKNOWN(-101), + PLAYER(-102), + UNSUPPORTED(-103); + + private int mNumVal; + + private ResourceError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + public enum DemuxerError { + BASE(-200), + UNKNOWN(-201), + PLAYER(-202), + UNSUPPORTED(-203); + + private int mNumVal; + + private DemuxerError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + public interface DemuxerCallbacks { + void onInitialized(boolean hasAudio, boolean hasVideo); + + void onError(int errorCode); + } + + public interface ResourceCallbacks { + void onLoad(String mediaUrl); + + void onDataArrived(); + + void onError(int errorCode); + } + + // Used to identify player instance. + public int getId(); + + // ======================================================================= + // API for GeckoHLSResourceWrapper + // ======================================================================= + public void init(String url, ResourceCallbacks callback); + + public boolean isLiveStream(); + + // ======================================================================= + // API for GeckoHLSDemuxerWrapper + // ======================================================================= + public void addDemuxerWrapperCallbackListener(DemuxerCallbacks callback); + + public ConcurrentLinkedQueue 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..dc9d9e3862 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java @@ -0,0 +1,712 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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 mAvailableInputBuffers = new LinkedList<>(); + private Queue mDequeuedSamples = new LinkedList<>(); + private Queue mInputSamples = new LinkedList<>(); + private boolean mStopped; + + private synchronized Sample onAllocate(final int size) { + final Sample sample = mSamplePool.obtainInput(size); + sample.session = mSession; + mDequeuedSamples.add(sample); + return sample; + } + + private synchronized void onSample(final Sample sample) { + if (sample == null) { + // Ignore empty input. + mSamplePool.recycleInput(mDequeuedSamples.remove()); + Log.w(LOGTAG, "WARN: empty input sample"); + return; + } + + if (sample.isEOS()) { + queueSample(sample); + return; + } + + if (sample.session >= mSession) { + final Sample dequeued = mDequeuedSamples.remove(); + dequeued.setBufferInfo(sample.info); + dequeued.setCryptoInfo(sample.cryptoInfo); + queueSample(dequeued); + } + + sample.dispose(); + } + + private void queueSample(final Sample sample) { + if (!mInputSamples.offer(new Input(sample))) { + reportError(Error.FATAL, new Exception("FAIL: input sample queue is full")); + return; + } + + try { + feedSampleToBuffer(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + private synchronized void onBuffer(final int index) { + if (mStopped || !isValidBuffer(index)) { + return; + } + + if (!mHasInputCapacitySet) { + final int capacity = mCodec.getInputBuffer(index).capacity(); + if (capacity > 0) { + mSamplePool.setInputBufferSize(capacity); + mHasInputCapacitySet = true; + } + } + + if (mAvailableInputBuffers.offer(index)) { + feedSampleToBuffer(); + } else { + reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full")); + } + } + + private boolean isValidBuffer(final int index) { + try { + return mCodec.getInputBuffer(index) != null; + } catch (final IllegalStateException e) { + if (DEBUG) { + Log.d(LOGTAG, "invalid input buffer#" + index, e); + } + return false; + } + } + + private void feedSampleToBuffer() { + while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) { + final int index = mAvailableInputBuffers.poll(); + if (!isValidBuffer(index)) { + continue; + } + int len = 0; + final Sample sample = mInputSamples.poll().sample; + final long pts = sample.info.presentationTimeUs; + final int flags = sample.info.flags; + final MediaCodec.CryptoInfo cryptoInfo = sample.cryptoInfo; + if (!sample.isEOS() && sample.bufferId != Sample.NO_BUFFER) { + len = sample.info.size; + final ByteBuffer buf = mCodec.getInputBuffer(index); + try { + mSamplePool + .getInputBuffer(sample.bufferId) + .writeToByteBuffer(buf, sample.info.offset, len); + } catch (final IOException e) { + e.printStackTrace(); + len = 0; + } + mSamplePool.recycleInput(sample); + } + + try { + if (cryptoInfo != null && len > 0) { + mCodec.queueSecureInputBuffer(index, 0, cryptoInfo, pts, flags); + } else { + mCodec.queueInputBuffer(index, 0, len, pts, flags); + } + mCallbacks.onInputQueued(pts); + } catch (final RemoteException e) { + e.printStackTrace(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + return; + } + } + reportPendingInputs(); + } + + private void reportPendingInputs() { + try { + for (final Input i : mInputSamples) { + if (!i.reported) { + i.reported = true; + mCallbacks.onInputPending(i.sample.info.presentationTimeUs); + } + } + } catch (final RemoteException e) { + e.printStackTrace(); + } + } + + private synchronized void reset() { + for (final Input i : mInputSamples) { + if (!i.sample.isEOS()) { + mSamplePool.recycleInput(i.sample); + } + } + mInputSamples.clear(); + + for (final Sample s : mDequeuedSamples) { + mSamplePool.recycleInput(s); + } + mDequeuedSamples.clear(); + + mAvailableInputBuffers.clear(); + } + + private synchronized void start() { + if (!mStopped) { + return; + } + mStopped = false; + } + + private synchronized void stop() { + if (mStopped) { + return; + } + mStopped = true; + reset(); + } + } + + private static final class Output { + public final Sample sample; + public final int index; + + public Output(final Sample sample, final int index) { + this.sample = sample; + this.index = index; + } + } + + private class OutputProcessor { + private final boolean mRenderToSurface; + private boolean mHasOutputCapacitySet; + private Queue mSentOutputs = new LinkedList<>(); + private boolean mStopped; + + private OutputProcessor(final boolean renderToSurface) { + mRenderToSurface = renderToSurface; + } + + private synchronized void onBuffer(final int index, final MediaCodec.BufferInfo info) { + if (mStopped || !isValidBuffer(index)) { + return; + } + + try { + final Sample output = obtainOutputSample(index, info); + mSentOutputs.add(new Output(output, index)); + output.session = mSession; + mCallbacks.onOutput(output); + } catch (final Exception e) { + e.printStackTrace(); + mCodec.releaseOutputBuffer(index, false); + } + + final boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + if (DEBUG && eos) { + Log.d(LOGTAG, "output EOS"); + } + } + + private boolean isValidBuffer(final int index) { + try { + return (mCodec.getOutputBuffer(index) != null) || mRenderToSurface; + } catch (final IllegalStateException e) { + if (DEBUG) { + Log.e(LOGTAG, "invalid buffer#" + index, e); + } + return false; + } + } + + private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) { + final Sample sample = mSamplePool.obtainOutput(info); + + if (mRenderToSurface) { + return sample; + } + + final ByteBuffer output = mCodec.getOutputBuffer(index); + if (!mHasOutputCapacitySet) { + final int capacity = output.capacity(); + if (capacity > 0) { + mSamplePool.setOutputBufferSize(capacity); + mHasOutputCapacitySet = true; + } + } + + if (info.size > 0) { + try { + mSamplePool + .getOutputBuffer(sample.bufferId) + .readFromByteBuffer(output, info.offset, info.size); + } catch (final IOException e) { + Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage()); + } + } + + return sample; + } + + private synchronized void onRelease(final Sample sample, final boolean render) { + final Output output = mSentOutputs.poll(); + if (output != null) { + mCodec.releaseOutputBuffer(output.index, render); + mSamplePool.recycleOutput(output.sample); + } else if (DEBUG) { + Log.d(LOGTAG, sample + " already released"); + } + + sample.dispose(); + } + + private synchronized void onFormatChanged(final MediaFormat format) { + if (mStopped) { + return; + } + try { + mCallbacks.onOutputFormatChanged(new FormatParam(format)); + } catch (final RemoteException re) { + // Dead recipient. + re.printStackTrace(); + } + } + + private synchronized void reset() { + for (final Output o : mSentOutputs) { + mCodec.releaseOutputBuffer(o.index, false); + mSamplePool.recycleOutput(o.sample); + } + mSentOutputs.clear(); + } + + private synchronized void start() { + if (!mStopped) { + return; + } + mStopped = false; + } + + private synchronized void stop() { + if (mStopped) { + return; + } + mStopped = true; + reset(); + } + } + + private volatile ICodecCallbacks mCallbacks; + private GeckoSurface mSurface; + private AsyncCodec mCodec; + private InputProcessor mInputProcessor; + private OutputProcessor mOutputProcessor; + private long mSession; + private SamplePool mSamplePool; + // Values will be updated after configure called. + private volatile boolean mIsAdaptivePlaybackSupported = false; + private volatile boolean mIsHardwareAccelerated = false; + private boolean mIsTunneledPlaybackSupported = false; + + public synchronized void setCallbacks(final ICodecCallbacks callbacks) throws RemoteException { + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Callbacks is dead"); + try { + release(); + } catch (final RemoteException e) { + // Nowhere to report the error. + } + } + + @Override + public synchronized boolean configure( + final FormatParam format, final GeckoSurface surface, final int flags, final String drmStubId) + throws RemoteException { + if (mCallbacks == null) { + Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()"); + return false; + } + + if (mCodec != null) { + if (DEBUG) { + Log.d(LOGTAG, "release existing codec: " + mCodec); + } + mCodec.release(); + } + + if (DEBUG) { + Log.d(LOGTAG, "configure " + this); + } + + final MediaFormat fmt = format.asFormat(); + final String mime = fmt.getString(MediaFormat.KEY_MIME); + if (mime == null || mime.isEmpty()) { + Log.e(LOGTAG, "invalid MIME type: " + mime); + return false; + } + + final List found = + findMatchingCodecNames(fmt, flags == MediaCodec.CONFIGURE_FLAG_ENCODE); + for (final String name : found) { + final AsyncCodec codec = + configureCodec( + name, fmt, surface != null ? surface.getSurface() : null, flags, drmStubId); + if (codec == null) { + Log.w(LOGTAG, "unable to configure " + name + ". Try next."); + continue; + } + mIsHardwareAccelerated = !name.startsWith(SW_CODEC_PREFIX); + mCodec = codec; + // Bug 1789846: Check if the Codec provides stride or height values to use. + if (flags == MediaCodec.CONFIGURE_FLAG_ENCODE && fmt.containsKey(MediaFormat.KEY_WIDTH)) { + final MediaFormat inputFormat = mCodec.getInputFormat(); + if (inputFormat != null) { + if (inputFormat.containsKey(MediaFormat.KEY_STRIDE)) { + fmt.setInteger(MediaFormat.KEY_STRIDE, inputFormat.getInteger(MediaFormat.KEY_STRIDE)); + } + if (inputFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + fmt.setInteger( + MediaFormat.KEY_SLICE_HEIGHT, inputFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT)); + } + } + } + mInputProcessor = new InputProcessor(); + final boolean renderToSurface = surface != null; + mOutputProcessor = new OutputProcessor(renderToSurface); + mSamplePool = new SamplePool(name, renderToSurface); + if (renderToSurface) { + mIsTunneledPlaybackSupported = mCodec.isTunneledPlaybackSupported(mime); + mSurface = surface; // Take ownership of surface. + } + if (DEBUG) { + Log.d(LOGTAG, codec.toString() + " created. Render to surface?" + renderToSurface); + } + return true; + } + + return false; + } + + private List 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 found = new ArrayList<>(); + for (int i = 0; i < numCodecs; i++) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder() == !isEncoder) { + continue; + } + + final String[] types = info.getSupportedTypes(); + for (final String t : types) { + if (!t.equalsIgnoreCase(mimeType)) { + continue; + } + final String name = info.getName(); + // API 21+ provide a method to query whether supplied size is supported. For + // older version, just avoid software video encoders. + if (isEncoder && width > 0 && height > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final VideoCapabilities c = + info.getCapabilitiesForType(mimeType).getVideoCapabilities(); + if (c != null && !c.isSizeSupported(width, height)) { + if (DEBUG) { + Log.d(LOGTAG, name + ": " + width + "x" + height + " not supported"); + } + continue; + } + } else if (name.startsWith(SW_CODEC_PREFIX)) { + continue; + } + } + + found.add(name); + if (DEBUG) { + Log.d( + LOGTAG, + "found " + (isEncoder ? "encoder:" : "decoder:") + name + " for mime:" + mimeType); + } + } + } + return found; + } + + private AsyncCodec configureCodec( + final String name, + final MediaFormat format, + final Surface surface, + final int flags, + final String drmStubId) { + try { + final AsyncCodec codec = AsyncCodecFactory.create(name); + codec.setCallbacks(new Callbacks(), null); + + final MediaCrypto crypto = RemoteMediaDrmBridgeStub.getMediaCrypto(drmStubId); + if (DEBUG) { + Log.d( + LOGTAG, + "configure mediacodec with crypto(" + (crypto != null) + ") / Id :" + drmStubId); + } + + if (surface != null) { + setupAdaptivePlayback(codec, format); + } + + codec.configure(format, surface, crypto, flags); + return codec; + } catch (final Exception e) { + Log.e(LOGTAG, "codec creation error", e); + return null; + } + } + + private void setupAdaptivePlayback(final AsyncCodec codec, final MediaFormat format) { + // Video decoder should config with adaptive playback capability. + mIsAdaptivePlaybackSupported = + codec.isAdaptivePlaybackSupported(format.getString(MediaFormat.KEY_MIME)); + if (mIsAdaptivePlaybackSupported) { + if (DEBUG) { + Log.d(LOGTAG, "codec supports adaptive playback = " + mIsAdaptivePlaybackSupported); + } + // TODO: may need to find a way to not use hard code to decide the max w/h. + format.setInteger(MediaFormat.KEY_MAX_WIDTH, 1920); + format.setInteger(MediaFormat.KEY_MAX_HEIGHT, 1080); + } + } + + @Override + public synchronized boolean isAdaptivePlaybackSupported() { + return mIsAdaptivePlaybackSupported; + } + + @Override + public synchronized boolean isHardwareAccelerated() { + return mIsHardwareAccelerated; + } + + @Override + public synchronized boolean isTunneledPlaybackSupported() { + return mIsTunneledPlaybackSupported; + } + + @Override + public synchronized void start() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "start " + this); + } + mInputProcessor.start(); + mOutputProcessor.start(); + try { + mCodec.start(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + private void reportError(final Error error, final Exception e) { + if (e != null) { + e.printStackTrace(); + } + try { + mCallbacks.onError(error == Error.FATAL); + } catch (final NullPointerException ne) { + // mCallbacks has been disposed by release(). + } catch (final RemoteException re) { + re.printStackTrace(); + } + } + + @Override + public synchronized void stop() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "stop " + this); + } + try { + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.stop(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void flush() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "flush " + this); + } + try { + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.flush(); + if (DEBUG) { + Log.d(LOGTAG, "flushed " + this); + } + mInputProcessor.start(); + mOutputProcessor.start(); + mCodec.resumeReceivingInputs(); + mSession++; + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized Sample dequeueInput(final int size) throws RemoteException { + try { + return mInputProcessor.onAllocate(size); + } catch (final Exception e) { + // Translate allocation error to remote exception. + throw new RemoteException(e.getMessage()); + } + } + + @Override + public synchronized SampleBuffer getInputBuffer(final int id) { + if (mSamplePool == null) { + return null; + } + return mSamplePool.getInputBuffer(id); + } + + @Override + public synchronized SampleBuffer getOutputBuffer(final int id) { + if (mSamplePool == null) { + return null; + } + return mSamplePool.getOutputBuffer(id); + } + + @Override + public synchronized void queueInput(final Sample sample) throws RemoteException { + try { + mInputProcessor.onSample(sample); + } catch (final Exception e) { + throw new RemoteException(e.getMessage()); + } + } + + @Override + public synchronized void setBitrate(final int bps) { + try { + mCodec.setBitrate(bps); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void releaseOutput(final Sample sample, final boolean render) { + try { + mOutputProcessor.onRelease(sample, render); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void release() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "release " + this); + } + try { + // In case Codec.stop() is not called yet. + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.release(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + mCodec = null; + mSamplePool.reset(); + mSamplePool = null; + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + if (mSurface != null) { + mSurface.release(); + mSurface = null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java new file mode 100644 index 0000000000..e31ea4b132 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java @@ -0,0 +1,508 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaFormat; +import android.os.Build; +import android.os.DeadObjectException; +import android.os.RemoteException; +import android.util.Log; +import android.util.SparseArray; +import androidx.annotation.RequiresApi; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.GeckoSurface; +import org.mozilla.gecko.mozglue.JNIObject; + +// Proxy class of ICodec binder. +public final class CodecProxy { + private static final String LOGTAG = "GeckoRemoteCodecProxy"; + private static final boolean DEBUG = false; + @WrapForJNI private static final long INVALID_SESSION = -1; + + private ICodec mRemote; + private long mSession; + private boolean mIsEncoder; + private FormatParam mFormat; + private GeckoSurface mOutputSurface; + private CallbacksForwarder mCallbacks; + private String mRemoteDrmStubId; + private Queue mSurfaceOutputs = new ConcurrentLinkedQueue<>(); + private boolean mFlushed = true; + + private SparseArray mInputBuffers = new SparseArray<>(); + private SparseArray mOutputBuffers = new SparseArray<>(); + + public interface Callbacks { + void onInputStatus(long timestamp, boolean processed); + + void onOutputFormatChanged(MediaFormat format); + + void onOutput(Sample output, SampleBuffer buffer); + + void onError(boolean fatal); + } + + @WrapForJNI + public static class NativeCallbacks extends JNIObject implements Callbacks { + public native void onInputStatus(long timestamp, boolean processed); + + public native void onOutputFormatChanged(MediaFormat format); + + public native void onOutput(Sample output, SampleBuffer buffer); + + public native void onError(boolean fatal); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } + + private class CallbacksForwarder extends ICodecCallbacks.Stub { + private final Callbacks mCallbacks; + private boolean mCodecProxyReleased; + + CallbacksForwarder(final Callbacks callbacks) { + mCallbacks = callbacks; + } + + @Override + public synchronized void onInputQueued(final long timestamp) throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onInputStatus(timestamp, true /* processed */); + } + } + + @Override + public synchronized void onInputPending(final long timestamp) throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onInputStatus(timestamp, false /* processed */); + } + } + + @Override + public synchronized void onOutputFormatChanged(final FormatParam format) + throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onOutputFormatChanged(format.asFormat()); + } + } + + @Override + public synchronized void onOutput(final Sample sample) throws RemoteException { + if (mCodecProxyReleased) { + sample.dispose(); + return; + } + + final SampleBuffer buffer = CodecProxy.this.getOutputBuffer(sample.bufferId); + if (mOutputSurface != null) { + // Don't render to surface just yet. Callback will make that happen when it's time. + mSurfaceOutputs.offer(sample); + } else if (buffer == null) { + // Buffer with given ID has been flushed. + sample.dispose(); + return; + } + mCallbacks.onOutput(sample, buffer); + } + + @Override + public void onError(final boolean fatal) throws RemoteException { + reportError(fatal); + } + + private synchronized void reportError(final boolean fatal) { + if (!mCodecProxyReleased) { + mCallbacks.onError(fatal); + } + } + + private synchronized void setCodecProxyReleased() { + mCodecProxyReleased = true; + } + } + + @WrapForJNI + public int GetInputFormatStride() { + if (mFormat.asFormat().containsKey(MediaFormat.KEY_STRIDE)) { + return mFormat.asFormat().getInteger(MediaFormat.KEY_STRIDE); + } + return 0; + } + + @WrapForJNI + public int GetInputFormatYPlaneHeight() { + if (mFormat.asFormat().containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + return mFormat.asFormat().getInteger(MediaFormat.KEY_SLICE_HEIGHT); + } + return 0; + } + + @WrapForJNI + public static CodecProxy create( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + return RemoteManager.getInstance() + .createCodec(isEncoder, format, surface, callbacks, drmStubId); + } + + public static CodecProxy createCodecProxy( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + return new CodecProxy(isEncoder, format, surface, callbacks, drmStubId); + } + + private CodecProxy( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + mIsEncoder = isEncoder; + mFormat = new FormatParam(format); + mOutputSurface = surface; + mRemoteDrmStubId = drmStubId; + mCallbacks = new CallbacksForwarder(callbacks); + } + + boolean init(final ICodec remote) { + try { + remote.setCallbacks(mCallbacks); + if (!remote.configure( + mFormat, + mOutputSurface, + mIsEncoder ? MediaCodec.CONFIGURE_FLAG_ENCODE : 0, + mRemoteDrmStubId)) { + return false; + } + remote.start(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + + mRemote = remote; + return true; + } + + boolean deinit() { + try { + mRemote.stop(); + mRemote.release(); + mRemote = null; + return true; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isAdaptivePlaybackSupported() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isAdaptivePlaybackSupported with an ended codec"); + return false; + } + try { + return mRemote.isAdaptivePlaybackSupported(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isHardwareAccelerated() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isHardwareAccelerated with an ended codec"); + return false; + } + try { + return mRemote.isHardwareAccelerated(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isTunneledPlaybackSupported() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isTunneledPlaybackSupported with an ended codec"); + return false; + } + try { + return mRemote.isTunneledPlaybackSupported(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized long input( + final ByteBuffer bytes, final BufferInfo info, final CryptoInfo cryptoInfo) { + if (mRemote == null) { + Log.e(LOGTAG, "cannot send input to an ended codec"); + return INVALID_SESSION; + } + + final boolean eos = info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM; + + if (eos) { + return sendInput(Sample.EOS); + } + + try { + final Sample s = mRemote.dequeueInput(info.size); + fillInputBuffer(s.bufferId, bytes, info.offset, info.size); + mSession = s.session; + return sendInput(s.set(info, cryptoInfo)); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "fail to dequeue input buffer", e); + } catch (final IOException e) { + Log.e(LOGTAG, "fail to copy input data.", e); + // Balance dequeue/queue. + sendInput(null); + } + return INVALID_SESSION; + } + + private void fillInputBuffer( + final int bufferId, final ByteBuffer bytes, final int offset, final int size) + throws RemoteException, IOException { + if (bytes == null || size == 0) { + Log.w(LOGTAG, "empty input"); + return; + } + + SampleBuffer buffer = mInputBuffers.get(bufferId); + if (buffer == null) { + buffer = mRemote.getInputBuffer(bufferId); + if (buffer != null) { + mInputBuffers.put(bufferId, buffer); + } + } + + if (buffer.capacity() < size) { + final IOException e = + new IOException("data larger than capacity: " + size + " > " + buffer.capacity()); + Log.e(LOGTAG, "cannot fill input.", e); + throw e; + } + + buffer.readFromByteBuffer(bytes, offset, size); + } + + private long sendInput(final Sample sample) { + try { + mRemote.queueInput(sample); + if (sample != null) { + sample.dispose(); + mFlushed = false; + } + } catch (final Exception e) { + Log.e(LOGTAG, "fail to queue input:" + sample, e); + return INVALID_SESSION; + } + return mSession; + } + + @WrapForJNI + public synchronized boolean flush() { + if (mFlushed) { + return true; + } + if (mRemote == null) { + Log.e(LOGTAG, "cannot flush an ended codec"); + return false; + } + try { + if (DEBUG) { + Log.d(LOGTAG, "flush " + this); + } + resetBuffers(); + mRemote.flush(); + mFlushed = true; + } catch (final DeadObjectException e) { + return false; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + + private void resetBuffers() { + for (int i = 0; i < mInputBuffers.size(); ++i) { + mInputBuffers.valueAt(i).dispose(); + } + mInputBuffers.clear(); + for (int i = 0; i < mOutputBuffers.size(); ++i) { + mOutputBuffers.valueAt(i).dispose(); + } + mOutputBuffers.clear(); + } + + @WrapForJNI + public boolean release() { + mCallbacks.setCodecProxyReleased(); + synchronized (this) { + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + return true; + } + if (DEBUG) { + Log.d(LOGTAG, "release " + this); + } + + if (!mSurfaceOutputs.isEmpty()) { + // Flushing output buffers to surface may cause some frames to be skipped and + // should not happen unless caller release codec before processing all buffers. + Log.w(LOGTAG, "release codec when " + mSurfaceOutputs.size() + " output buffers unhandled"); + try { + for (final Sample s : mSurfaceOutputs) { + mRemote.releaseOutput(s, true); + } + } catch (final RemoteException e) { + e.printStackTrace(); + } + mSurfaceOutputs.clear(); + } + + resetBuffers(); + + try { + RemoteManager.getInstance().releaseCodec(this); + } catch (final DeadObjectException e) { + return false; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + } + + @WrapForJNI + public synchronized boolean setBitrate(final int bps) { + if (!mIsEncoder) { + Log.w(LOGTAG, "this api is encoder-only"); + return false; + } + + if (android.os.Build.VERSION.SDK_INT < 19) { + Log.w(LOGTAG, "this api was added in API level 19"); + return false; + } + + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + return true; + } + + try { + mRemote.setBitrate(bps); + } catch (final RemoteException e) { + Log.e(LOGTAG, "remote fail to set rates:" + bps); + e.printStackTrace(); + } + return true; + } + + @WrapForJNI + public synchronized boolean releaseOutput(final Sample sample, final boolean render) { + if (mOutputSurface != null) { + if (!mSurfaceOutputs.remove(sample)) { + if (mRemote != null) Log.w(LOGTAG, "already released: " + sample); + return true; + } + + if (DEBUG && !render) { + Log.d(LOGTAG, "drop output:" + sample.info.presentationTimeUs); + } + } + + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + sample.dispose(); + return true; + } + + try { + mRemote.releaseOutput(sample, render); + } catch (final RemoteException e) { + Log.e(LOGTAG, "remote fail to release output:" + sample.info.presentationTimeUs); + e.printStackTrace(); + } + sample.dispose(); + + return true; + } + + /* package */ void reportError(final boolean fatal) { + mCallbacks.reportError(fatal); + } + + private synchronized SampleBuffer getOutputBuffer(final int id) { + if (mRemote == null) { + Log.e(LOGTAG, "cannot get buffer#" + id + " from an ended codec"); + return null; + } + + if (mOutputSurface != null || id == Sample.NO_BUFFER) { + return null; + } + + SampleBuffer buffer = mOutputBuffers.get(id); + if (buffer != null) { + return buffer; + } + + try { + buffer = mRemote.getOutputBuffer(id); + } catch (final Exception e) { + Log.e(LOGTAG, "cannot get buffer#" + id, e); + return null; + } + if (buffer != null) { + mOutputBuffers.put(id, buffer); + } + + return buffer; + } + + @WrapForJNI + public static boolean supportsCBCS() { + // Android N/API-24 supports CBCS but there seems to be a bug. + // See https://github.com/google/ExoPlayer/issues/4022 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1; + } + + @RequiresApi(api = Build.VERSION_CODES.N_MR1) + @WrapForJNI + public static boolean setCryptoPatternIfNeeded( + final CryptoInfo info, final int blocksToEncrypt, final int blocksToSkip) { + if (supportsCBCS() && (blocksToEncrypt > 0 || blocksToSkip > 0)) { + info.setPattern(new CryptoInfo.Pattern(blocksToEncrypt, blocksToSkip)); + return true; + } + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java new file mode 100644 index 0000000000..03340530ee --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java @@ -0,0 +1,178 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import java.nio.ByteBuffer; + +/** + * A wrapper to make {@link MediaFormat} parcelable. Supports following keys: + * + *

      + *
    • {@link MediaFormat#KEY_MIME} + *
    • {@link MediaFormat#KEY_WIDTH} + *
    • {@link MediaFormat#KEY_HEIGHT} + *
    • {@link MediaFormat#KEY_CHANNEL_COUNT} + *
    • {@link MediaFormat#KEY_SAMPLE_RATE} + *
    • {@link MediaFormat#KEY_BIT_RATE} + *
    • {@link MediaFormat#KEY_BITRATE_MODE} + *
    • {@link MediaFormat#KEY_COLOR_FORMAT} + *
    • {@link MediaFormat#KEY_FRAME_RATE} + *
    • {@link MediaFormat#KEY_I_FRAME_INTERVAL} + *
    • {@link MediaFormat#KEY_STRIDE} + *
    • {@link MediaFormat#KEY_SLICE_HEIGHT} + *
    • "csd-0" + *
    • "csd-1" + *
    + */ +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 CREATOR = + new Creator() { + @Override + public FormatParam createFromParcel(final Parcel in) { + return new FormatParam(in); + } + + @Override + public FormatParam[] newArray(final int size) { + return new FormatParam[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + public void readFromParcel(final Parcel in) { + final Bundle bundle = in.readBundle(); + fromBundle(bundle); + } + + private void fromBundle(final Bundle bundle) { + if (bundle.containsKey(MediaFormat.KEY_MIME)) { + mFormat.setString(MediaFormat.KEY_MIME, bundle.getString(MediaFormat.KEY_MIME)); + } + if (bundle.containsKey(MediaFormat.KEY_WIDTH)) { + mFormat.setInteger(MediaFormat.KEY_WIDTH, bundle.getInt(MediaFormat.KEY_WIDTH)); + } + if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) { + mFormat.setInteger(MediaFormat.KEY_HEIGHT, bundle.getInt(MediaFormat.KEY_HEIGHT)); + } + if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + mFormat.setInteger( + MediaFormat.KEY_CHANNEL_COUNT, bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, bundle.getInt(MediaFormat.KEY_SAMPLE_RATE)); + } + if (bundle.containsKey(KEY_CONFIG_0)) { + mFormat.setByteBuffer(KEY_CONFIG_0, ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0))); + } + if (bundle.containsKey(KEY_CONFIG_1)) { + mFormat.setByteBuffer(KEY_CONFIG_1, ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1)))); + } + if (bundle.containsKey(MediaFormat.KEY_BIT_RATE)) { + mFormat.setInteger(MediaFormat.KEY_BIT_RATE, bundle.getInt(MediaFormat.KEY_BIT_RATE)); + } + if (bundle.containsKey(MediaFormat.KEY_BITRATE_MODE)) { + mFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, bundle.getInt(MediaFormat.KEY_BITRATE_MODE)); + } + if (bundle.containsKey(MediaFormat.KEY_COLOR_FORMAT)) { + mFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, bundle.getInt(MediaFormat.KEY_COLOR_FORMAT)); + } + if (bundle.containsKey(MediaFormat.KEY_FRAME_RATE)) { + mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, bundle.getInt(MediaFormat.KEY_FRAME_RATE)); + } + if (bundle.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) { + mFormat.setInteger( + MediaFormat.KEY_I_FRAME_INTERVAL, bundle.getInt(MediaFormat.KEY_I_FRAME_INTERVAL)); + } + if (bundle.containsKey(MediaFormat.KEY_STRIDE)) { + mFormat.setInteger(MediaFormat.KEY_STRIDE, bundle.getInt(MediaFormat.KEY_STRIDE)); + } + if (bundle.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + mFormat.setInteger(MediaFormat.KEY_SLICE_HEIGHT, bundle.getInt(MediaFormat.KEY_SLICE_HEIGHT)); + } + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeBundle(toBundle()); + } + + private Bundle toBundle() { + final Bundle bundle = new Bundle(); + if (mFormat.containsKey(MediaFormat.KEY_MIME)) { + bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME)); + } + if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) { + bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH)); + } + if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) { + bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT)); + } + if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + bundle.putInt( + MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)); + } + if (mFormat.containsKey(KEY_CONFIG_0)) { + final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0); + bundle.putByteArray(KEY_CONFIG_0, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + if (mFormat.containsKey(KEY_CONFIG_1)) { + final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1); + bundle.putByteArray(KEY_CONFIG_1, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + if (mFormat.containsKey(MediaFormat.KEY_BIT_RATE)) { + bundle.putInt(MediaFormat.KEY_BIT_RATE, mFormat.getInteger(MediaFormat.KEY_BIT_RATE)); + } + if (mFormat.containsKey(MediaFormat.KEY_BITRATE_MODE)) { + bundle.putInt(MediaFormat.KEY_BITRATE_MODE, mFormat.getInteger(MediaFormat.KEY_BITRATE_MODE)); + } + if (mFormat.containsKey(MediaFormat.KEY_COLOR_FORMAT)) { + bundle.putInt(MediaFormat.KEY_COLOR_FORMAT, mFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT)); + } + if (mFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) { + bundle.putInt(MediaFormat.KEY_FRAME_RATE, mFormat.getInteger(MediaFormat.KEY_FRAME_RATE)); + } + if (mFormat.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) { + bundle.putInt( + MediaFormat.KEY_I_FRAME_INTERVAL, mFormat.getInteger(MediaFormat.KEY_I_FRAME_INTERVAL)); + } + if (mFormat.containsKey(MediaFormat.KEY_STRIDE)) { + bundle.putInt(MediaFormat.KEY_STRIDE, mFormat.getInteger(MediaFormat.KEY_STRIDE)); + } + if (mFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + bundle.putInt(MediaFormat.KEY_SLICE_HEIGHT, mFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT)); + } + return bundle; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java new file mode 100644 index 0000000000..6418375a57 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import org.mozilla.gecko.annotation.WrapForJNI; + +// A subset of the class AudioInfo in dom/media/MediaInfo.h +@WrapForJNI +public final class GeckoAudioInfo { + public final byte[] codecSpecificData; + public final int rate; + public final int channels; + public final int bitDepth; + public final int profile; + public final long duration; + public final String mimeType; + + public GeckoAudioInfo( + final int rate, + final int channels, + final int bitDepth, + final int profile, + final long duration, + final String mimeType, + final byte[] codecSpecificData) { + this.rate = rate; + this.channels = channels; + this.bitDepth = bitDepth; + this.profile = profile; + this.duration = duration; + this.mimeType = mimeType; + this.codecSpecificData = codecSpecificData; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java new file mode 100644 index 0000000000..cd732fe535 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +public final class GeckoHLSDemuxerWrapper { + private static final String LOGTAG = "GeckoHLSDemuxerWrapper"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + + // NOTE : These TRACK definitions should be synced with Gecko. + public enum TrackType { + UNDEFINED(0), + AUDIO(1), + VIDEO(2), + TEXT(3); + private int mType; + + private TrackType(final int type) { + mType = type; + } + + public int value() { + return mType; + } + } + + private BaseHlsPlayer mPlayer = null; + + public static class Callbacks extends JNIObject implements BaseHlsPlayer.DemuxerCallbacks { + @WrapForJNI(calledFrom = "gecko") + Callbacks() {} + + @Override + @WrapForJNI + public native void onInitialized(boolean hasAudio, boolean hasVideo); + + @Override + @WrapForJNI + public native void onError(int errorCode); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // Callbacks + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + private BaseHlsPlayer.TrackType getPlayerTrackType(final int trackType) { + if (trackType == TrackType.AUDIO.value()) { + return BaseHlsPlayer.TrackType.AUDIO; + } else if (trackType == TrackType.VIDEO.value()) { + return BaseHlsPlayer.TrackType.VIDEO; + } else if (trackType == TrackType.TEXT.value()) { + return BaseHlsPlayer.TrackType.TEXT; + } + return BaseHlsPlayer.TrackType.UNDEFINED; + } + + @WrapForJNI + public long getBuffered() { + assertTrue(mPlayer != null); + return mPlayer.getBufferedPosition(); + } + + @WrapForJNI(calledFrom = "gecko") + public static GeckoHLSDemuxerWrapper create( + final int id, final BaseHlsPlayer.DemuxerCallbacks callback) { + return new GeckoHLSDemuxerWrapper(id, callback); + } + + @WrapForJNI + public int getNumberOfTracks(final int trackType) { + assertTrue(mPlayer != null); + final int tracks = mPlayer.getNumberOfTracks(getPlayerTrackType(trackType)); + if (DEBUG) Log.d(LOGTAG, "[GetNumberOfTracks] type : " + trackType + ", num = " + tracks); + return tracks; + } + + @WrapForJNI + public GeckoAudioInfo getAudioInfo(final int index) { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "[getAudioInfo] formatIndex : " + index); + final GeckoAudioInfo aInfo = mPlayer.getAudioInfo(index); + return aInfo; + } + + @WrapForJNI + public GeckoVideoInfo getVideoInfo(final int index) { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index); + final GeckoVideoInfo vInfo = mPlayer.getVideoInfo(index); + return vInfo; + } + + @WrapForJNI + public boolean seek(final long seekTime) { + // seekTime : microseconds. + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "seek : " + seekTime + " (Us)"); + return mPlayer.seek(seekTime); + } + + GeckoHLSDemuxerWrapper(final int id, final BaseHlsPlayer.DemuxerCallbacks callback) { + if (DEBUG) Log.d(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ..."); + assertTrue(callback != null); + try { + mPlayer = GeckoPlayerFactory.getPlayer(id); + if (mPlayer != null) { + mPlayer.addDemuxerWrapperCallbackListener(callback); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ... error", e); + callback.onError(BaseHlsPlayer.DemuxerError.UNKNOWN.code()); + } + } + + @WrapForJNI + private GeckoHLSSample[] getSamples(final int mediaType, final int number) { + assertTrue(mPlayer != null); + ConcurrentLinkedQueue samples = null; + // getA/VSamples will always return a non-null instance. + samples = mPlayer.getSamples(getPlayerTrackType(mediaType), number); + assertTrue(samples.size() <= number); + return samples.toArray(new GeckoHLSSample[samples.size()]); + } + + @WrapForJNI + private long getNextKeyFrameTime() { + assertTrue(mPlayer != null); + return mPlayer.getNextKeyFrameTime(); + } + + @WrapForJNI + private boolean isLiveStream() { + assertTrue(mPlayer != null); + return mPlayer.isLiveStream(); + } + + @WrapForJNI // Called when native object is destroyed. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mPlayer != null) { + release(); + } + } + + private void release() { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "release BaseHlsPlayer..."); + GeckoPlayerFactory.removePlayer(mPlayer); + mPlayer.release(); + mPlayer = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java new file mode 100644 index 0000000000..c21789fdd0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +public class GeckoHLSResourceWrapper { + private static final String LOGTAG = "GeckoHLSResourceWrapper"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + private BaseHlsPlayer mPlayer = null; + private boolean mDestroy = false; + + public static class Callbacks extends JNIObject implements BaseHlsPlayer.ResourceCallbacks { + @WrapForJNI(calledFrom = "gecko") + Callbacks() {} + + @Override + @WrapForJNI + public native void onLoad(String mediaUrl); + + @Override + @WrapForJNI + public native void onDataArrived(); + + @Override + @WrapForJNI + public native void onError(int errorCode); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // Callbacks + + private GeckoHLSResourceWrapper( + final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper created with url = " + url); + assertTrue(callback != null); + + mPlayer = GeckoPlayerFactory.getPlayer(); + try { + mPlayer.init(url, callback); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to create GeckoHlsResourceWrapper !", e); + callback.onError(BaseHlsPlayer.ResourceError.UNKNOWN.code()); + } + } + + @WrapForJNI(calledFrom = "gecko") + public static GeckoHLSResourceWrapper create( + final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + return new GeckoHLSResourceWrapper(url, callback); + } + + @WrapForJNI(calledFrom = "gecko") + public int getPlayerId() { + // GeckoHLSResourceWrapper should always be created before others + assertTrue(!mDestroy); + assertTrue(mPlayer != null); + return mPlayer.getId(); + } + + @WrapForJNI(calledFrom = "gecko") + public void suspend() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper suspend"); + if (mPlayer != null) { + mPlayer.suspend(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void resume() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper resume"); + if (mPlayer != null) { + mPlayer.resume(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void play() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement played"); + if (mPlayer != null) { + mPlayer.play(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void pause() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement paused"); + if (mPlayer != null) { + mPlayer.pause(); + } + } + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + @WrapForJNI // Called when native object is mDestroy. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mDestroy) { + return; + } + mDestroy = true; + if (mPlayer != null) { + GeckoPlayerFactory.removePlayer(mPlayer); + mPlayer.release(); + mPlayer = null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java new file mode 100644 index 0000000000..d2ab76a13d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class GeckoHLSSample { + public static final GeckoHLSSample EOS; + + static { + final BufferInfo eosInfo = new BufferInfo(); + eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + EOS = new GeckoHLSSample(null, eosInfo, null, 0); + } + + // Indicate the index of format which is used by this sample. + @WrapForJNI public final int formatIndex; + + @WrapForJNI public long duration; + + @WrapForJNI public final BufferInfo info; + + @WrapForJNI public final CryptoInfo cryptoInfo; + + private ByteBuffer mBuffer = null; + + @WrapForJNI + public void writeToByteBuffer(final ByteBuffer dest) throws IOException { + if (mBuffer != null && dest != null && info.size > 0) { + dest.put(mBuffer); + } + } + + @WrapForJNI + public boolean isEOS() { + return (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + } + + @WrapForJNI + public boolean isKeyFrame() { + return (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + } + + public static GeckoHLSSample create( + final ByteBuffer src, + final BufferInfo info, + final CryptoInfo cryptoInfo, + final int formatIndex) { + return new GeckoHLSSample(src, info, cryptoInfo, formatIndex); + } + + private GeckoHLSSample( + final ByteBuffer buffer, + final BufferInfo info, + final CryptoInfo cryptoInfo, + final int formatIndex) { + this.formatIndex = formatIndex; + duration = Long.MAX_VALUE; + this.mBuffer = buffer; + this.info = info; + this.cryptoInfo = cryptoInfo; + } + + @Override + public String toString() { + if (isEOS()) { + return "EOS GeckoHLSSample"; + } + + final StringBuilder str = new StringBuilder(); + str.append("{ info=") + .append("{ offset=") + .append(info.offset) + .append(", size=") + .append(info.size) + .append(", pts=") + .append(info.presentationTimeUs) + .append(", duration=") + .append(duration) + .append(", flags=") + .append(Integer.toHexString(info.flags)) + .append(" }") + .append(" }"); + return str.toString(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java new file mode 100644 index 0000000000..a666e0e860 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Build; +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.List; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +public class GeckoHlsAudioRenderer extends GeckoHlsRendererBase { + public GeckoHlsAudioRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) { + super(C.TRACK_TYPE_AUDIO, eventDispatcher); + assertTrue(Build.VERSION.SDK_INT >= 16); + LOGTAG = getClass().getSimpleName(); + DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + } + + @Override + public final int supportsFormat(final Format format) { + /* + * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering + * formats with the same mime type, but + * the properties of the format exceed + * the renderer's capability. + * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose + * renderer for formats of the same + * top-level type, but is not capable of + * rendering the format or any other format + * with the same mime type because the + * sub-type is not supported. + * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering + * the format, either because it does not support + * the format's top-level type, or because it's + * a specialized renderer for a different mime type. + * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats, + * but may suffer a brief discontinuity (~50-100ms) + * when adaptation occurs. + */ + final String mimeType = format.sampleMimeType; + if (!MimeTypes.isAudio(mimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + List decoderInfos = null; + try { + final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT; + decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (decoderInfos == null || decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + final MediaCodecInfo info = decoderInfos.get(0); + /* + * Note : If the code can make it to this place, ExoPlayer assumes + * support for unknown sampleRate and channelCount when + * SDK version is less than 21, otherwise, further check is needed + * if there's no sampleRate/channelCount in format. + */ + final boolean decoderCapable = + (Build.VERSION.SDK_INT < 21) + || ((format.sampleRate == Format.NO_VALUE + || info.isAudioSampleRateSupportedV21(format.sampleRate)) + && (format.channelCount == Format.NO_VALUE + || info.isAudioChannelCountSupportedV21(format.channelCount))); + return RendererCapabilities.create( + decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES, + ADAPTIVE_NOT_SEAMLESS, + TUNNELING_NOT_SUPPORTED); + } + + @Override + protected final void createInputBuffer() { + // We're not able to estimate the size for audio from format. So we rely + // on the dynamic allocation mechanism provided in DecoderInputBuffer. + mInputBuffer = null; + } + + @Override + protected void resetRenderer() { + mInputBuffer = null; + mInitialized = false; + } + + @Override + protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) { + // Do nothing + } + + @Override + protected void handleFormatRead(final DecoderInputBuffer bufferForRead) + throws ExoPlaybackException { + onInputFormatChanged(mFormatHolder.format); + } + + @Override + protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) { + mInputStreamEnded = true; + mDemuxedInputSamples.offer(GeckoHLSSample.EOS); + } + + @Override + protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) { + final int size = bufferForRead.data.limit(); + final byte[] realData = new byte[size]; + bufferForRead.data.get(realData, 0, size); + final ByteBuffer buffer = ByteBuffer.wrap(realData); + mInputBuffer = bufferForRead.data; + mInputBuffer.clear(); + + final CryptoInfo cryptoInfo = + bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null; + final BufferInfo bufferInfo = new BufferInfo(); + // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags. + int flags = 0; + flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; + flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0; + bufferInfo.set(0, size, bufferForRead.timeUs, flags); + + assertTrue(mFormats.size() >= 0); + // We add a new format in the list once format changes, so the formatIndex + // should indicate to the last(latest) format. + final GeckoHLSSample sample = + GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1); + + mDemuxedInputSamples.offer(sample); + + if (BuildConfig.DEBUG_BUILD) { + Log.d( + LOGTAG, + "Demuxed sample PTS : " + + sample.info.presentationTimeUs + + ", duration :" + + sample.duration + + ", formatIndex(" + + sample.formatIndex + + "), queue size : " + + mDemuxedInputSamples.size()); + } + } + + @Override + protected boolean clearInputSamplesQueue() { + if (DEBUG) { + Log.d(LOGTAG, "clearInputSamplesQueue"); + } + mDemuxedInputSamples.clear(); + return true; + } + + @Override + protected void notifyPlayerInputFormatChanged(final Format newFormat) { + mPlayerEventDispatcher.onAudioInputFormatChanged(newFormat); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java new file mode 100644 index 0000000000..b847ee79fb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java @@ -0,0 +1,1113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.DefaultLoadControl; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +@ReflectionTarget +public class GeckoHlsPlayer implements BaseHlsPlayer, ExoPlayer.EventListener { + private static final String LOGTAG = "GeckoHlsPlayer"; + private static final DefaultBandwidthMeter BANDWIDTH_METER = + new DefaultBandwidthMeter.Builder(null).build(); + private static final int MAX_TIMELINE_ITEM_LINES = 3; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + + private static final AtomicInteger sPlayerId = new AtomicInteger(0); + /* + * Because we treat GeckoHlsPlayer as a source data provider. + * It will be created and initialized with a URL by HLSResource in + * Gecko media pipleine (in cpp). Once HLSDemuxer is created later, we + * need to bridge this HLSResource to the created demuxer. And they share + * the same GeckoHlsPlayer. + * mPlayerId is a token used for Gecko media pipeline to obtain corresponding player. + */ + private final int mPlayerId; + // Accessed only in GeckoHlsPlayerThread. + private boolean mExoplayerSuspended = false; + + private static final int DEFAULT_MIN_BUFFER_MS = 5 * 1000; + private static final int DEFAULT_MAX_BUFFER_MS = 10 * 1000; + + private enum MediaDecoderPlayState { + PLAY_STATE_PREPARING, + PLAY_STATE_PAUSED, + PLAY_STATE_PLAYING + } + + // Default value is PLAY_STATE_PREPARING and it will be set to PLAY_STATE_PLAYING + // once HTMLMediaElement calls PlayInternal(). + // Accessed only in GeckoHlsPlayerThread. + private MediaDecoderPlayState mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PREPARING; + + private Handler mMainHandler; + private HandlerThread mThread; + private ExoPlayer mPlayer; + private GeckoHlsRendererBase[] mRenderers; + private DefaultTrackSelector mTrackSelector; + private MediaSource mMediaSource; + private SourceEventListener mSourceEventListener; + private ComponentListener mComponentListener; + private ComponentEventDispatcher mComponentEventDispatcher; + + private volatile boolean mIsTimelineStatic = false; + private long mDurationUs; + + private GeckoHlsVideoRenderer mVRenderer = null; + private GeckoHlsAudioRenderer mARenderer = null; + + // Able to control if we only want V/A/V+A tracks from bitstream. + private class RendererController { + private final boolean mEnableV; + private final boolean mEnableA; + + RendererController(final boolean enableVideoRenderer, final boolean enableAudioRenderer) { + this.mEnableV = enableVideoRenderer; + this.mEnableA = enableAudioRenderer; + } + + boolean isVideoRendererEnabled() { + return mEnableV; + } + + boolean isAudioRendererEnabled() { + return mEnableA; + } + } + + private RendererController mRendererController = new RendererController(true, true); + + // Provide statistical information of tracks. + private class HlsMediaTracksInfo { + private int mNumVideoTracks = 0; + private int mNumAudioTracks = 0; + private boolean mVideoInfoUpdated = false; + private boolean mAudioInfoUpdated = false; + private boolean mVideoDataArrived = false; + private boolean mAudioDataArrived = false; + + HlsMediaTracksInfo() {} + + public void reset() { + mNumVideoTracks = 0; + mNumAudioTracks = 0; + mVideoInfoUpdated = false; + mAudioInfoUpdated = false; + mVideoDataArrived = false; + mAudioDataArrived = false; + } + + public void updateNumOfVideoTracks(final int numOfTracks) { + mNumVideoTracks = numOfTracks; + } + + public void updateNumOfAudioTracks(final int numOfTracks) { + mNumAudioTracks = numOfTracks; + } + + public boolean hasVideo() { + return mNumVideoTracks > 0; + } + + public boolean hasAudio() { + return mNumAudioTracks > 0; + } + + public int getNumOfVideoTracks() { + return mNumVideoTracks; + } + + public int getNumOfAudioTracks() { + return mNumAudioTracks; + } + + public void onVideoInfoUpdated() { + mVideoInfoUpdated = true; + } + + public void onAudioInfoUpdated() { + mAudioInfoUpdated = true; + } + + public void onDataArrived(final int trackType) { + if (trackType == C.TRACK_TYPE_VIDEO) { + mVideoDataArrived = true; + } else if (trackType == C.TRACK_TYPE_AUDIO) { + mAudioDataArrived = true; + } + } + + public boolean videoReady() { + return !hasVideo() || (mVideoInfoUpdated && mVideoDataArrived); + } + + public boolean audioReady() { + return !hasAudio() || (mAudioInfoUpdated && mAudioDataArrived); + } + } + + private HlsMediaTracksInfo mTracksInfo = new HlsMediaTracksInfo(); + + // Used only in GeckoHlsPlayerThread. + private boolean mIsPlayerInitDone = false; + private boolean mIsDemuxerInitDone = false; + private BaseHlsPlayer.DemuxerCallbacks mDemuxerCallbacks; + private BaseHlsPlayer.ResourceCallbacks mResourceCallbacks; + + private boolean mReleasing = false; // Used only in Gecko Main thread. + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + protected void checkInitDone() { + if (mIsDemuxerInitDone) { + return; + } + assertTrue(mDemuxerCallbacks != null); + + if (DEBUG) { + Log.d( + LOGTAG, + "[checkInitDone] VReady:" + + mTracksInfo.videoReady() + + ",AReady:" + + mTracksInfo.audioReady() + + ",hasV:" + + mTracksInfo.hasVideo() + + ",hasA:" + + mTracksInfo.hasAudio()); + } + if (mTracksInfo.videoReady() && mTracksInfo.audioReady()) { + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onInitialized(mTracksInfo.hasAudio(), mTracksInfo.hasVideo()); + } + mIsDemuxerInitDone = true; + } + } + + private final class SourceEventListener implements MediaSourceEventListener { + public void onLoadStarted( + final int windowIndex, + final MediaSource.MediaPeriodId mediaPeriodId, + final LoadEventInfo loadEventInfo, + final MediaLoadData mediaLoadData) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) { + // Don't report non-media URLs. + return; + } + if (mResourceCallbacks == null || loadEventInfo.uri == null || mReleasing) { + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "on-load: url=" + loadEventInfo.uri); + } + mResourceCallbacks.onLoad(loadEventInfo.uri.toString()); + } + } + } + + public final class ComponentEventDispatcher { + // Called from GeckoHls{Audio,Video}Renderer/ExoPlayer internal playback thread + // or GeckoHlsPlayerThread. + public void onDataArrived(final int trackType) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onDataArrived(trackType)); + } + } + + // Called from GeckoHls{Audio,Video}Renderer internal playback thread. + public void onVideoInputFormatChanged(final Format format) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onVideoInputFormatChanged(format)); + } + } + + // Called from GeckoHls{Audio,Video}Renderer internal playback thread. + public void onAudioInputFormatChanged(final Format format) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onAudioInputFormatChanged(format)); + } + } + } + + public final class ComponentListener { + + // General purpose implementation + // Called on GeckoHlsPlayerThread + public void onDataArrived(final int trackType) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB][onDataArrived] id " + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + + mTracksInfo.onDataArrived(trackType); + if (!mReleasing) { + mResourceCallbacks.onDataArrived(); + } + checkInitDone(); + } + } + + // Called on GeckoHlsPlayerThread + public void onVideoInputFormatChanged(final Format format) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB] onVideoInputFormatChanged [" + format + "]"); + Log.d( + LOGTAG, + "[CB] SampleMIMEType [" + + format.sampleMimeType + + "], ContainerMIMEType [" + + format.containerMimeType + + "], id : " + + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + mTracksInfo.onVideoInfoUpdated(); + checkInitDone(); + } + } + + // Called on GeckoHlsPlayerThread + public void onAudioInputFormatChanged(final Format format) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB] onAudioInputFormatChanged [" + format + "], mPlayerId :" + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + mTracksInfo.onAudioInfoUpdated(); + checkInitDone(); + } + } + } + + private HlsMediaSource.Factory buildDataSourceFactory( + final Context ctx, final DefaultBandwidthMeter bandwidthMeter) { + return new HlsMediaSource.Factory( + new DefaultDataSourceFactory( + ctx, bandwidthMeter, buildHttpDataSourceFactory(bandwidthMeter))); + } + + private HttpDataSource.Factory buildHttpDataSourceFactory( + final DefaultBandwidthMeter bandwidthMeter) { + return new DefaultHttpDataSourceFactory( + BuildConfig.USER_AGENT_GECKOVIEW_MOBILE, + bandwidthMeter /* listener */, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + true /* allowCrossProtocolRedirects */); + } + + private long getDuration() { + return awaitPlayerThread( + () -> { + long duration = 0L; + // Value returned by getDuration() is in milliseconds. + if (mPlayer != null && !isLiveStream()) { + duration = Math.max(0L, mPlayer.getDuration() * 1000L); + } + if (DEBUG) { + Log.d(LOGTAG, "getDuration : " + duration + "(Us)"); + } + return duration; + }); + } + + // To make sure that each player has a unique id, GeckoHlsPlayer should be + // created only from synchronized APIs in GeckoPlayerFactory. + public GeckoHlsPlayer() { + mPlayerId = sPlayerId.incrementAndGet(); + if (DEBUG) { + Log.d(LOGTAG, " construct player with id(" + mPlayerId + ")"); + } + } + + // Should be only called by GeckoPlayerFactory and GeckoHLSResourceWrapper. + // The mPlayerId is used to make sure that the same GeckoHlsPlayer is used by + // corresponding HLSResource and HLSDemuxer for each media playback. + // Called on Gecko's main thread + @Override + public int getId() { + return mPlayerId; + } + + // Called on Gecko's main thread + @Override + public synchronized void addDemuxerWrapperCallbackListener( + final BaseHlsPlayer.DemuxerCallbacks callback) { + if (DEBUG) { + Log.d(LOGTAG, " addDemuxerWrapperCallbackListener ..."); + } + mDemuxerCallbacks = callback; + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onLoadingChanged(final boolean isLoading) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "loading [" + isLoading + "]"); + } + if (!isLoading) { + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + suspendExoplayer(); + } + // To update buffered position. + mComponentEventDispatcher.onDataArrived(C.TRACK_TYPE_DEFAULT); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onPlayerStateChanged(final boolean playWhenReady, final int state) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "state [" + playWhenReady + ", " + getStateString(state) + "]"); + } + if (state == ExoPlayer.STATE_READY + && !mExoplayerSuspended + && mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + resumeExoplayer(); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public void onPositionDiscontinuity(final int reason) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "positionDiscontinuity: reason=" + reason); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d( + LOGTAG, + "playbackParameters " + + String.format( + "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch)); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onPlayerError(final ExoPlaybackException e) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.e(LOGTAG, "playerFailed", e); + } + mIsPlayerInitDone = false; + if (mReleasing) { + return; + } + if (mResourceCallbacks != null) { + mResourceCallbacks.onError(ResourceError.PLAYER.code()); + } + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onError(DemuxerError.PLAYER.code()); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onTracksChanged( + final TrackGroupArray ignored, final TrackSelectionArray trackSelections) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "onTracksChanged : TGA[" + ignored + "], TSA[" + trackSelections + "]"); + + final MappedTrackInfo mappedTrackInfo = mTrackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + Log.d(LOGTAG, "Tracks []"); + return; + } + Log.d(LOGTAG, "Tracks ["); + // Log tracks associated to renderers. + for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) { + final TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + final TrackSelection trackSelection = trackSelections.get(rendererIndex); + if (rendererTrackGroups.length > 0) { + Log.d(LOGTAG, " Renderer:" + rendererIndex + " ["); + for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { + final TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); + final String adaptiveSupport = + getAdaptiveSupportString( + trackGroup.length, + mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false)); + Log.d( + LOGTAG, + " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + final String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); + final String formatSupport = + getFormatSupportString( + mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); + Log.d( + LOGTAG, + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, " ]"); + } + } + // Log tracks not associated with a renderer. + final TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups(); + if (unassociatedTrackGroups.length > 0) { + Log.d(LOGTAG, " Renderer:None ["); + for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { + Log.d(LOGTAG, " Group:" + groupIndex + " ["); + final TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + final String status = getTrackStatusString(false); + final String formatSupport = + getFormatSupportString(RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); + Log.d( + LOGTAG, + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, "]"); + } + mTracksInfo.reset(); + int numVideoTracks = 0; + int numAudioTracks = 0; + for (int j = 0; j < ignored.length; j++) { + final TrackGroup tg = ignored.get(j); + for (int i = 0; i < tg.length; i++) { + final Format fmt = tg.getFormat(i); + if (fmt.sampleMimeType != null) { + if (mRendererController.isVideoRendererEnabled() + && fmt.sampleMimeType.startsWith(new String("video"))) { + numVideoTracks++; + } else if (mRendererController.isAudioRendererEnabled() + && fmt.sampleMimeType.startsWith(new String("audio"))) { + numAudioTracks++; + } + } + } + } + mTracksInfo.updateNumOfVideoTracks(numVideoTracks); + mTracksInfo.updateNumOfAudioTracks(numAudioTracks); + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onTimelineChanged(final Timeline timeline, final int reason) { + assertTrue(isPlayerThread()); + + // For now, we use the interface ExoPlayer.getDuration() for gecko, + // so here we create local variable 'window' & 'peroid' to obtain + // the dynamic duration. + // See. + // http://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Timeline.html + // for further information. + final Timeline.Window window = new Timeline.Window(); + mIsTimelineStatic = + !timeline.isEmpty() && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic; + + final int periodCount = timeline.getPeriodCount(); + final int windowCount = timeline.getWindowCount(); + if (DEBUG) { + Log.d(LOGTAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount); + } + final Timeline.Period period = new Timeline.Period(); + for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getPeriod(i, period); + if (mDurationUs < period.getDurationUs()) { + mDurationUs = period.getDurationUs(); + } + } + for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getWindow(i, window); + if (mDurationUs < window.getDurationUs()) { + mDurationUs = window.getDurationUs(); + } + } + // TODO : Need to check if the duration from play.getDuration is different + // with the one calculated from multi-timelines/windows. + if (DEBUG) { + Log.d( + LOGTAG, + "Media duration (from Timeline) = " + + mDurationUs + + "(us)" + + " player.getDuration() = " + + mPlayer.getDuration() + + "(ms)"); + } + } + + private static String getStateString(final int state) { + switch (state) { + case ExoPlayer.STATE_BUFFERING: + return "B"; + case ExoPlayer.STATE_ENDED: + return "E"; + case ExoPlayer.STATE_IDLE: + return "I"; + case ExoPlayer.STATE_READY: + return "R"; + default: + return "?"; + } + } + + private static String getFormatSupportString(final int formatSupport) { + switch (formatSupport) { + case RendererCapabilities.FORMAT_HANDLED: + return "YES"; + case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: + return "NO_EXCEEDS_CAPABILITIES"; + case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: + return "NO_UNSUPPORTED_TYPE"; + case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: + return "NO"; + default: + return "?"; + } + } + + private static String getAdaptiveSupportString(final int trackCount, final int adaptiveSupport) { + if (trackCount < 2) { + return "N/A"; + } + switch (adaptiveSupport) { + case RendererCapabilities.ADAPTIVE_SEAMLESS: + return "YES"; + case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS: + return "YES_NOT_SEAMLESS"; + case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED: + return "NO"; + default: + return "?"; + } + } + + private static String getTrackStatusString( + final TrackSelection selection, final TrackGroup group, final int trackIndex) { + return getTrackStatusString( + selection != null + && selection.getTrackGroup() == group + && selection.indexOf(trackIndex) != C.INDEX_UNSET); + } + + private static String getTrackStatusString(final boolean enabled) { + return enabled ? "[X]" : "[ ]"; + } + + // Called on GeckoHlsPlayerThread + private void createExoPlayer(final String url) { + assertTrue(isPlayerThread()); + + final Context ctx = GeckoAppShell.getApplicationContext(); + mComponentListener = new ComponentListener(); + mComponentEventDispatcher = new ComponentEventDispatcher(); + mDurationUs = 0; + + // Prepare trackSelector + final TrackSelection.Factory videoTrackSelectionFactory = + new AdaptiveTrackSelection.Factory(BANDWIDTH_METER); + mTrackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + + // Prepare customized renderer + mRenderers = new GeckoHlsRendererBase[2]; + mVRenderer = new GeckoHlsVideoRenderer(mComponentEventDispatcher); + mARenderer = new GeckoHlsAudioRenderer(mComponentEventDispatcher); + mRenderers[0] = mVRenderer; + mRenderers[1] = mARenderer; + + final DefaultLoadControl dlc = + new DefaultLoadControl.Builder() + .setAllocator(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)) + .setBufferDurationsMs( + DEFAULT_MIN_BUFFER_MS, + DEFAULT_MAX_BUFFER_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + .createDefaultLoadControl(); + // Create ExoPlayer instance with specific components. + mPlayer = + new ExoPlayer.Builder(ctx, mRenderers) + .setTrackSelector(mTrackSelector) + .setLoadControl(dlc) + .build(); + mPlayer.addListener(this); + + final Uri uri = Uri.parse(url); + mMediaSource = buildDataSourceFactory(ctx, BANDWIDTH_METER).createMediaSource(uri); + mSourceEventListener = new SourceEventListener(); + mMediaSource.addEventListener(mMainHandler, mSourceEventListener); + if (DEBUG) { + Log.d( + LOGTAG, + "Uri is " + uri + ", ContentType is " + Util.inferContentType(uri.getLastPathSegment())); + } + mPlayer.setPlayWhenReady(false); + mPlayer.prepare(mMediaSource); + mIsPlayerInitDone = true; + } + + // ======================================================================= + // API for GeckoHLSResourceWrapper + // ======================================================================= + // Called on Gecko Main Thread + @Override + public synchronized void init(final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + if (DEBUG) { + Log.d(LOGTAG, " init"); + } + assertTrue(callback != null); + assertTrue(!mIsPlayerInitDone); + + mThread = new HandlerThread("GeckoHlsPlayerThread"); + mThread.start(); + mMainHandler = new Handler(mThread.getLooper()); + + mMainHandler.post( + () -> { + mResourceCallbacks = callback; + createExoPlayer(url); + }); + } + + // Called on MDSM's TaskQueue + @Override + public boolean isLiveStream() { + return !mIsTimelineStatic; + } + + // ======================================================================= + // API for GeckoHLSDemuxerWrapper + // ======================================================================= + // Called on HLSDemuxer's TaskQueue + @Override + public synchronized ConcurrentLinkedQueue getSamples( + final TrackType trackType, final int number) { + if (trackType == TrackType.VIDEO) { + return mVRenderer != null + ? mVRenderer.getQueuedSamples(number) + : new ConcurrentLinkedQueue(); + } else if (trackType == TrackType.AUDIO) { + return mARenderer != null + ? mARenderer.getQueuedSamples(number) + : new ConcurrentLinkedQueue(); + } else { + return new ConcurrentLinkedQueue(); + } + } + + // Called on MFR's TaskQueue + @Override + public long getBufferedPosition() { + return awaitPlayerThread( + () -> { + // Value returned by getBufferedPosition() is in milliseconds. + final long bufferedPos = + mPlayer == null ? 0L : Math.max(0L, mPlayer.getBufferedPosition() * 1000L); + if (DEBUG) { + Log.d(LOGTAG, "getBufferedPosition : " + bufferedPos + "(Us)"); + } + return bufferedPos; + }); + } + + // Called on MFR's TaskQueue + @Override + public synchronized int getNumberOfTracks(final TrackType trackType) { + if (DEBUG) { + Log.d(LOGTAG, "getNumberOfTracks : type " + trackType); + } + if (trackType == TrackType.VIDEO) { + return mTracksInfo.getNumOfVideoTracks(); + } else if (trackType == TrackType.AUDIO) { + return mTracksInfo.getNumOfAudioTracks(); + } + return 0; + } + + // Called on MFR's TaskQueue + @Override + public GeckoVideoInfo getVideoInfo(final int index) { + final Format fmt; + synchronized (this) { + if (DEBUG) { + Log.d(LOGTAG, "getVideoInfo"); + } + if (mVRenderer == null) { + Log.e(LOGTAG, "no render to get video info from. Index : " + index); + return null; + } + if (!mTracksInfo.hasVideo()) { + return null; + } + fmt = mVRenderer.getFormat(index); + if (fmt == null) { + return null; + } + } + final GeckoVideoInfo vInfo = + new GeckoVideoInfo( + fmt.width, + fmt.height, + fmt.width, + fmt.height, + fmt.rotationDegrees, + fmt.stereoMode, + getDuration(), + fmt.sampleMimeType, + null, + null); + return vInfo; + } + + // Called on MFR's TaskQueue + @Override + public GeckoAudioInfo getAudioInfo(final int index) { + final Format fmt; + synchronized (this) { + if (DEBUG) { + Log.d(LOGTAG, "getAudioInfo"); + } + if (mARenderer == null) { + Log.e(LOGTAG, "no render to get audio info from. Index : " + index); + return null; + } + if (!mTracksInfo.hasAudio()) { + return null; + } + fmt = mARenderer.getFormat(index); + if (fmt == null) { + return null; + } + } + /* According to https://github.com/google/ExoPlayer/blob + * /d979469659861f7fe1d39d153b90bdff1ab479cc/library/core/src/main + * /java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java#L221-L224, + * if the input audio format is not raw, exoplayer would assure that + * the sample's pcm encoding bitdepth is 16. + * For HLS content, it should always be 16. + */ + assertTrue(!MimeTypes.AUDIO_RAW.equals(fmt.sampleMimeType)); + // For HLS content, csd-0 is enough. + final byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0); + final GeckoAudioInfo aInfo = + new GeckoAudioInfo( + fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd); + return aInfo; + } + + // Called on HLSDemuxer's TaskQueue + @Override + public boolean seek(final long positionUs) { + synchronized (this) { + if (mPlayer == null) { + Log.d(LOGTAG, "Seek operation won't be performed as no player exists!"); + return false; + } + } + return awaitPlayerThread( + () -> { + // Need to temporarily resume Exoplayer to download the chunks for getting the demuxed + // keyframe sample when HTMLMediaElement is paused. Suspend Exoplayer when collecting + // enough + // samples in onLoadingChanged. + if (mExoplayerSuspended) { + resumeExoplayer(); + } + // positionUs : microseconds. + // NOTE : 1) It's not possible to seek media by tracktype via ExoPlayer Interface. + // 2) positionUs is samples PTS from MFR, we need to re-adjust it + // for ExoPlayer by subtracting sample start time. + // 3) Time unit for ExoPlayer.seek() is milliseconds. + try { + // TODO : Gather Timeline Period / Window information to develop + // complete timeline, and seekTime should be inside the duration. + Long startTime = Long.MAX_VALUE; + for (final GeckoHlsRendererBase r : mRenderers) { + if (r == mVRenderer + && mRendererController.isVideoRendererEnabled() + && mTracksInfo.hasVideo() + || r == mARenderer + && mRendererController.isAudioRendererEnabled() + && mTracksInfo.hasAudio()) { + // Find the min value of the start time + startTime = Math.min(startTime, r.getFirstSamplePTS()); + } + } + if (DEBUG) { + Log.d( + LOGTAG, + "seeking : " + + positionUs / 1000 + + " (ms); startTime : " + + startTime / 1000 + + " (ms)"); + } + assertTrue(startTime != Long.MAX_VALUE && startTime != Long.MIN_VALUE); + mPlayer.seekTo(positionUs / 1000 - startTime / 1000); + } catch (final Exception e) { + if (mReleasing) { + return false; + } + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code()); + } + return false; + } + return true; + }); + } + + // Called on HLSDemuxer's TaskQueue + @Override + public synchronized long getNextKeyFrameTime() { + final long nextKeyFrameTime = + mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE; + return nextKeyFrameTime; + } + + // Called on Gecko's main thread. + @Override + public synchronized void suspend() { + runOnPlayerThread( + () -> { + if (mExoplayerSuspended) { + return; + } + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + if (DEBUG) { + Log.d(LOGTAG, "suspend player id : " + mPlayerId); + } + suspendExoplayer(); + } + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void resume() { + runOnPlayerThread( + () -> { + if (!mExoplayerSuspended) { + return; + } + if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + if (DEBUG) { + Log.d(LOGTAG, "resume player id : " + mPlayerId); + } + resumeExoplayer(); + } + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void play() { + runOnPlayerThread( + () -> { + if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "MediaDecoder played."); + } + mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PLAYING; + resumeExoplayer(); + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void pause() { + runOnPlayerThread( + () -> { + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "MediaDecoder paused."); + } + mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PAUSED; + suspendExoplayer(); + }); + } + + private void suspendExoplayer() { + assertTrue(isPlayerThread()); + + if (mPlayer == null) { + return; + } + mExoplayerSuspended = true; + if (DEBUG) { + Log.d(LOGTAG, "suspend Exoplayer"); + } + mPlayer.setPlayWhenReady(false); + } + + private void resumeExoplayer() { + assertTrue(isPlayerThread()); + + if (mPlayer == null) { + return; + } + mExoplayerSuspended = false; + if (DEBUG) { + Log.d(LOGTAG, "resume Exoplayer"); + } + mPlayer.setPlayWhenReady(true); + } + + // Called on Gecko's main thread, when HLSDemuxer or HLSResource destructs. + @Override + public void release() { + if (DEBUG) { + Log.d(LOGTAG, "releasing ... id : " + mPlayerId); + } + + synchronized (this) { + if (mReleasing) { + return; + } else { + mReleasing = true; + } + } + + runOnPlayerThread( + () -> { + if (mPlayer != null) { + mPlayer.removeListener(this); + mPlayer.stop(); + mPlayer.release(); + mVRenderer = null; + mARenderer = null; + mPlayer = null; + } + if (mThread != null) { + mThread.quit(); + mThread = null; + } + mDemuxerCallbacks = null; + mResourceCallbacks = null; + mIsPlayerInitDone = false; + mIsDemuxerInitDone = false; + }); + } + + private void runOnPlayerThread(final Runnable task) { + assertTrue(mMainHandler != null); + if (isPlayerThread()) { + task.run(); + } else { + mMainHandler.post(task); + } + } + + private boolean isPlayerThread() { + return Thread.currentThread() == mMainHandler.getLooper().getThread(); + } + + private T awaitPlayerThread(final Callable task) { + assertTrue(!isPlayerThread()); + + try { + final FutureTask wait = new FutureTask(task); + mMainHandler.post(wait); + return wait.get(); + } catch (final Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java new file mode 100644 index 0000000000..ecb7b93d61 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.BaseRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +public abstract class GeckoHlsRendererBase extends BaseRenderer { + protected static final int QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD = 1000000; // 1sec + protected final FormatHolder mFormatHolder = new FormatHolder(); + /* + * DEBUG/LOGTAG will be set in the 2 subclass GeckoHlsAudioRenderer and + * GeckoHlsVideoRenderer, and we still wants to log message in the base class + * GeckoHlsRendererBase, so neither 'static' nor 'final' are applied to them. + */ + protected boolean DEBUG; + protected String LOGTAG; + // Notify GeckoHlsPlayer about renderer's status, i.e. data has arrived. + protected GeckoHlsPlayer.ComponentEventDispatcher mPlayerEventDispatcher; + + protected ConcurrentLinkedQueue mDemuxedInputSamples = + new ConcurrentLinkedQueue<>(); + + protected ByteBuffer mInputBuffer = null; + protected ArrayList mFormats = new ArrayList(); + protected boolean mInitialized = false; + protected boolean mWaitingForData = true; + protected boolean mInputStreamEnded = false; + protected long mFirstSampleStartTime = Long.MIN_VALUE; + + protected abstract void createInputBuffer() throws ExoPlaybackException; + + protected abstract void handleReconfiguration(DecoderInputBuffer bufferForRead); + + protected abstract void handleFormatRead(DecoderInputBuffer bufferForRead) + throws ExoPlaybackException; + + protected abstract void handleEndOfStream(DecoderInputBuffer bufferForRead); + + protected abstract void handleSamplePreparation(DecoderInputBuffer bufferForRead); + + protected abstract void resetRenderer(); + + protected abstract boolean clearInputSamplesQueue(); + + protected abstract void notifyPlayerInputFormatChanged(Format newFormat); + + private DecoderInputBuffer mBufferForRead = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + private final DecoderInputBuffer mFlagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance(); + + protected void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public GeckoHlsRendererBase( + final int trackType, final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) { + super(trackType); + mPlayerEventDispatcher = eventDispatcher; + } + + private boolean isQueuedEnoughData() { + if (mDemuxedInputSamples.isEmpty()) { + return false; + } + + final Iterator iter = mDemuxedInputSamples.iterator(); + long firstPTS = 0; + if (iter.hasNext()) { + final GeckoHLSSample sample = iter.next(); + firstPTS = sample.info.presentationTimeUs; + } + long lastPTS = firstPTS; + while (iter.hasNext()) { + final GeckoHLSSample sample = iter.next(); + lastPTS = sample.info.presentationTimeUs; + } + return Math.abs(lastPTS - firstPTS) > QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD; + } + + public Format getFormat(final int index) { + assertTrue(index >= 0); + final Format fmt = index < mFormats.size() ? mFormats.get(index) : null; + if (DEBUG) { + Log.d(LOGTAG, "getFormat : index = " + index + ", format : " + fmt); + } + return fmt; + } + + public synchronized long getFirstSamplePTS() { + return mFirstSampleStartTime; + } + + public synchronized ConcurrentLinkedQueue getQueuedSamples(final int number) { + final ConcurrentLinkedQueue samples = + new ConcurrentLinkedQueue(); + + GeckoHLSSample sample = null; + final int queuedSize = mDemuxedInputSamples.size(); + for (int i = 0; i < queuedSize; i++) { + if (i >= number) { + break; + } + sample = mDemuxedInputSamples.poll(); + samples.offer(sample); + } + + sample = samples.isEmpty() ? null : samples.peek(); + if (sample == null) { + if (DEBUG) { + Log.d(LOGTAG, "getQueuedSamples isEmpty, mWaitingForData = true !"); + } + mWaitingForData = true; + } else if (mFirstSampleStartTime == Long.MIN_VALUE) { + mFirstSampleStartTime = sample.info.presentationTimeUs; + if (DEBUG) { + Log.d(LOGTAG, "mFirstSampleStartTime = " + mFirstSampleStartTime); + } + } + return samples; + } + + protected void handleDrmInitChanged(final Format oldFormat, final Format newFormat) { + final Object oldDrmInit = oldFormat == null ? null : oldFormat.drmInitData; + final Object newDrnInit = newFormat.drmInitData; + + // TODO: Notify MFR if the content is encrypted or not. + if (newDrnInit != oldDrmInit) { + if (newDrnInit != null) { + } else { + } + } + } + + protected boolean canReconfigure(final Format oldFormat, final Format newFormat) { + // Referring to ExoPlayer's MediaCodecBaseRenderer, the default is set + // to false. Only override it in video renderer subclass. + return false; + } + + protected void prepareReconfiguration() { + // Referring to ExoPlayer's MediaCodec related renderers, only video + // renderer handles this. + } + + protected void updateCSDInfo(final Format format) { + // do nothing. + } + + protected void onInputFormatChanged(final Format newFormat) throws ExoPlaybackException { + Format oldFormat; + try { + oldFormat = mFormats.get(mFormats.size() - 1); + } catch (final IndexOutOfBoundsException e) { + oldFormat = null; + } + if (DEBUG) { + Log.d(LOGTAG, "[onInputFormatChanged] old : " + oldFormat + " => new : " + newFormat); + } + mFormats.add(newFormat); + handleDrmInitChanged(oldFormat, newFormat); + + if (mInitialized && canReconfigure(oldFormat, newFormat)) { + prepareReconfiguration(); + } else { + resetRenderer(); + maybeInitRenderer(); + } + + updateCSDInfo(newFormat); + notifyPlayerInputFormatChanged(newFormat); + } + + protected void maybeInitRenderer() throws ExoPlaybackException { + if (mInitialized || mFormats.size() == 0) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "Initializing ... "); + } + try { + createInputBuffer(); + mInitialized = true; + } catch (final OutOfMemoryError e) { + throw ExoPlaybackException.createForRenderer( + new RuntimeException(e), + getIndex(), + mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1), + RendererCapabilities.FORMAT_HANDLED); + } + } + + /* + * The place we get demuxed data from HlsMediaSource(ExoPlayer). + * The data will then be converted to GeckoHLSSample and deliver to + * GeckoHlsDemuxerWrapper for further use. + * If the return value is ture, that means a GeckoHLSSample is queued + * successfully. We can try to feed more samples into queue. + * If the return value is false, that means we might encounter following + * situation 1) not initialized 2) input stream is ended 3) queue is full. + * 4) format changed. 5) exception happened. + */ + protected synchronized boolean feedInputBuffersQueue() throws ExoPlaybackException { + if (!mInitialized || mInputStreamEnded || isQueuedEnoughData()) { + // Need to reinitialize the renderer or the input stream has ended + // or we just reached the maximum queue size. + return false; + } + + mBufferForRead.data = mInputBuffer; + if (mBufferForRead.data != null) { + mBufferForRead.clear(); + } + + handleReconfiguration(mBufferForRead); + + // Read data from HlsMediaSource + int result = C.RESULT_NOTHING_READ; + try { + result = readSource(mFormatHolder, mBufferForRead, false); + } catch (final Exception e) { + Log.e(LOGTAG, "[feedInput] Exception when readSource :", e); + return false; + } + + if (result == C.RESULT_NOTHING_READ) { + return false; + } + + if (result == C.RESULT_FORMAT_READ) { + handleFormatRead(mBufferForRead); + return true; + } + + // We've read a buffer. + if (mBufferForRead.isEndOfStream()) { + if (DEBUG) { + Log.d(LOGTAG, "Now we're at the End Of Stream."); + } + handleEndOfStream(mBufferForRead); + return false; + } + + mBufferForRead.flip(); + + handleSamplePreparation(mBufferForRead); + + maybeNotifyDataArrived(); + return true; + } + + private void maybeNotifyDataArrived() { + if (mWaitingForData && isQueuedEnoughData()) { + if (DEBUG) { + Log.d(LOGTAG, "onDataArrived"); + } + mPlayerEventDispatcher.onDataArrived(getTrackType()); + mWaitingForData = false; + } + } + + private void readFormat() throws ExoPlaybackException { + mFlagsOnlyBuffer.clear(); + final int result = readSource(mFormatHolder, mFlagsOnlyBuffer, true); + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(mFormatHolder.format); + } + } + + @Override + protected void onEnabled(final boolean joining) { + // Do nothing. + } + + @Override + protected void onDisabled() { + mFormats.clear(); + resetRenderer(); + } + + @Override + public boolean isReady() { + return mFormats.size() != 0; + } + + @Override + public boolean isEnded() { + return mInputStreamEnded; + } + + @Override + protected synchronized void onPositionReset(final long positionUs, final boolean joining) { + if (DEBUG) { + Log.d(LOGTAG, "onPositionReset : positionUs = " + positionUs); + } + mInputStreamEnded = false; + if (mInitialized) { + clearInputSamplesQueue(); + } + } + + /* + * This is called by ExoPlayerImplInternal.java. + * ExoPlayer checks the status of renderer, i.e. isReady() / isEnded(), and + * calls renderer.render by passing its wall clock time. + */ + @Override + public void render(final long positionUs, final long elapsedRealtimeUs) + throws ExoPlaybackException { + if (BuildConfig.DEBUG_BUILD) { + Log.d(LOGTAG, "positionUs = " + positionUs + ", mInputStreamEnded = " + mInputStreamEnded); + } + if (mInputStreamEnded) { + return; + } + if (mFormats.size() == 0) { + readFormat(); + } + + maybeInitRenderer(); + while (feedInputBuffersQueue()) { + // Do nothing + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java new file mode 100644 index 0000000000..f2917ccbcc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java @@ -0,0 +1,518 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Build; +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +public class GeckoHlsVideoRenderer extends GeckoHlsRendererBase { + /* + * By configuring these states, initialization data is provided for + * ExoPlayer's HlsMediaSource to parse HLS bitstream and then provide samples + * starting with an Access Unit Delimiter including SPS/PPS for TS, + * and provide samples starting with an AUD without SPS/PPS for FMP4. + */ + private enum RECONFIGURATION_STATE { + NONE, + WRITE_PENDING, + QUEUE_PENDING + } + + private boolean mRendererReconfigured; + private RECONFIGURATION_STATE mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + + // A list of the formats which may be included in the bitstream. + private Format[] mStreamFormats; + // The max width/height/inputBufferSize for specific codec format. + private CodecMaxValues mCodecMaxValues; + // A temporary queue for samples whose duration is not calculated yet. + private ConcurrentLinkedQueue 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 decoderInfos = null; + try { + final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT; + decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (decoderInfos == null || decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + + boolean decoderCapable = false; + MediaCodecInfo info = null; + for (final MediaCodecInfo i : decoderInfos) { + if (i.isCodecSupported(format)) { + decoderCapable = true; + info = i; + } + } + if (decoderCapable && format.width > 0 && format.height > 0) { + if (Build.VERSION.SDK_INT < 21) { + try { + decoderCapable = + format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize(); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (!decoderCapable) { + if (DEBUG) { + Log.d(LOGTAG, "Check [legacyFrameSize, " + format.width + "x" + format.height + "]"); + } + } + } else { + decoderCapable = + info.isVideoSizeAndRateSupportedV21(format.width, format.height, format.frameRate); + } + } + + return RendererCapabilities.create( + decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES, + info != null && info.adaptive ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS, + TUNNELING_NOT_SUPPORTED); + } + + @Override + protected final void createInputBuffer() throws ExoPlaybackException { + assertTrue(mFormats.size() > 0); + // Calculate maximum size which might be used for target format. + final Format currentFormat = mFormats.get(mFormats.size() - 1); + mCodecMaxValues = getCodecMaxValues(currentFormat, mStreamFormats); + // Create a buffer with maximal size for reading source. + // Note : Though we are able to dynamically enlarge buffer size by + // creating DecoderInputBuffer with specific BufferReplacementMode, we + // still allocate a calculated max size buffer for it at first to reduce + // runtime overhead. + try { + mInputBuffer = ByteBuffer.wrap(new byte[mCodecMaxValues.inputSize]); + } catch (final OutOfMemoryError e) { + Log.e(LOGTAG, "cannot allocate input buffer of size " + mCodecMaxValues.inputSize, e); + throw ExoPlaybackException.createForRenderer( + new Exception(e), + getIndex(), + mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1), + RendererCapabilities.FORMAT_HANDLED); + } + } + + @Override + protected void resetRenderer() { + if (DEBUG) { + Log.d(LOGTAG, "[resetRenderer] mInitialized = " + mInitialized); + } + if (mInitialized) { + mRendererReconfigured = false; + mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + mInputBuffer = null; + mCSDInfo = null; + mInitialized = false; + } + } + + @Override + protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) { + // For adaptive reconfiguration OMX decoders expect all reconfiguration + // data to be supplied at the start of the buffer that also contains + // the first frame in the new format. + assertTrue(mFormats.size() > 0); + if (mRendererReconfigurationState == RECONFIGURATION_STATE.WRITE_PENDING) { + if (bufferForRead.data == null) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][WRITE_PENDING] bufferForRead.data is not initialized."); + } + return; + } + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][WRITE_PENDING] put initialization data"); + } + final Format currentFormat = mFormats.get(mFormats.size() - 1); + for (int i = 0; i < currentFormat.initializationData.size(); i++) { + final byte[] data = currentFormat.initializationData.get(i); + bufferForRead.data.put(data); + } + mRendererReconfigurationState = RECONFIGURATION_STATE.QUEUE_PENDING; + } + } + + @Override + protected void handleFormatRead(final DecoderInputBuffer bufferForRead) + throws ExoPlaybackException { + if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] 2 formats in a row."); + } + // We received two formats in a row. Clear the current buffer of any reconfiguration data + // associated with the first format. + bufferForRead.clear(); + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + onInputFormatChanged(mFormatHolder.format); + } + + @Override + protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) { + if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] isEndOfStream."); + } + // We received a new format immediately before the end of the stream. We need to clear + // the corresponding reconfiguration data from the current buffer, but re-write it into + // a subsequent buffer if there are any (e.g. if the user seeks backwards). + bufferForRead.clear(); + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + mInputStreamEnded = true; + final GeckoHLSSample sample = GeckoHLSSample.EOS; + calculatDuration(sample); + } + + @Override + protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) { + final int csdInfoSize = mCSDInfo != null ? mCSDInfo.length : 0; + final int dataSize = bufferForRead.data.limit(); + final int size = bufferForRead.isKeyFrame() ? csdInfoSize + dataSize : dataSize; + final byte[] realData = new byte[size]; + if (bufferForRead.isKeyFrame()) { + // Prepend the CSD information to the sample if it's a key frame. + System.arraycopy(mCSDInfo, 0, realData, 0, csdInfoSize); + bufferForRead.data.get(realData, csdInfoSize, dataSize); + } else { + bufferForRead.data.get(realData, 0, dataSize); + } + final ByteBuffer buffer = ByteBuffer.wrap(realData); + mInputBuffer = bufferForRead.data; + mInputBuffer.clear(); + + final CryptoInfo cryptoInfo = + bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null; + final BufferInfo bufferInfo = new BufferInfo(); + // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags. + int flags = 0; + flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; + flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0; + bufferInfo.set(0, size, bufferForRead.timeUs, flags); + + assertTrue(mFormats.size() > 0); + // We add a new format in the list once format changes, so the formatIndex + // should indicate to the last(latest) format. + final GeckoHLSSample sample = + GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1); + + // There's no duration information from the ExoPlayer's sample, we need + // to calculate it. + calculatDuration(sample); + mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + } + + @Override + protected void onPositionReset(final long positionUs, final boolean joining) { + super.onPositionReset(positionUs, joining); + if (mInitialized && mRendererReconfigured && mFormats.size() != 0) { + if (DEBUG) { + Log.d(LOGTAG, "[onPositionReset] WRITE_PENDING"); + } + // Any reconfiguration data that we put shortly before the reset + // may be invalid. We avoid this issue by sending reconfiguration + // data following every position reset. + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + } + + @Override + protected boolean clearInputSamplesQueue() { + if (DEBUG) { + Log.d(LOGTAG, "clearInputSamplesQueue"); + } + mDemuxedInputSamples.clear(); + mDemuxedNoDurationSamples.clear(); + return true; + } + + @Override + protected boolean canReconfigure(final Format oldFormat, final Format newFormat) { + final boolean canReconfig = + areAdaptationCompatible(oldFormat, newFormat) + && newFormat.width <= mCodecMaxValues.width + && newFormat.height <= mCodecMaxValues.height + && newFormat.maxInputSize <= mCodecMaxValues.inputSize; + if (DEBUG) { + Log.d(LOGTAG, "[canReconfigure] : " + canReconfig); + } + return canReconfig; + } + + @Override + protected void prepareReconfiguration() { + if (DEBUG) { + Log.d(LOGTAG, "[onInputFormatChanged] starting reconfiguration !"); + } + mRendererReconfigured = true; + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + + @Override + protected void updateCSDInfo(final Format format) { + int size = 0; + for (int i = 0; i < format.initializationData.size(); i++) { + size += format.initializationData.get(i).length; + } + int startPos = 0; + mCSDInfo = new byte[size]; + for (int i = 0; i < format.initializationData.size(); i++) { + final byte[] data = format.initializationData.get(i); + System.arraycopy(data, 0, mCSDInfo, startPos, data.length); + startPos += data.length; + } + if (DEBUG) { + Log.d(LOGTAG, "mCSDInfo [" + Utils.bytesToHex(mCSDInfo) + "]"); + } + } + + @Override + protected void notifyPlayerInputFormatChanged(final Format newFormat) { + mPlayerEventDispatcher.onVideoInputFormatChanged(newFormat); + } + + private void calculateSamplesWithin(final GeckoHLSSample[] samples, final int range) { + // Calculate the first 'range' elements. + for (int i = 0; i < range; i++) { + // Comparing among samples in the window. + for (int j = -2; j < 14; j++) { + if (i + j >= 0 + && i + j < range + && samples[i + j].info.presentationTimeUs > samples[i].info.presentationTimeUs) { + samples[i].duration = + Math.min( + samples[i].duration, + samples[i + j].info.presentationTimeUs - samples[i].info.presentationTimeUs); + } + } + } + } + + private void calculatDuration(final GeckoHLSSample inputSample) { + /* + * NOTE : + * Since we customized renderer as a demuxer. Here we're not able to + * obtain duration from the DecoderInputBuffer as there's no duration inside. + * So we calcualte it by referring to nearby samples' timestamp. + * A temporary queue |mDemuxedNoDurationSamples| is used to queue demuxed + * samples from HlsMediaSource which have no duration information at first. + * We're choosing 16 as the comparing window size, because it's commonly + * used as a GOP size. + * Considering there're 16 demuxed samples in the _no duration_ queue already, + * e.g. |-2|-1|0|1|2|3|4|5|6|...|13| + * Once a new demuxed(No duration) sample X (17th) is put into the + * temporary queue, + * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|X| + * we are able to calculate the correct duration for sample 0 by finding + * the closest but greater pts than sample 0 among these 16 samples, + * here, let's say sample -2 to 13. + */ + if (inputSample != null) { + mDemuxedNoDurationSamples.offer(inputSample); + } + final int sizeOfNoDura = mDemuxedNoDurationSamples.size(); + // A calculation window we've ever found suitable for both HLS TS & FMP4. + final int range = sizeOfNoDura >= 17 ? 17 : sizeOfNoDura; + final GeckoHLSSample[] inputArray = + mDemuxedNoDurationSamples.toArray(new GeckoHLSSample[sizeOfNoDura]); + if (range >= 17 && !mInputStreamEnded) { + calculateSamplesWithin(inputArray, range); + + final GeckoHLSSample toQueue = mDemuxedNoDurationSamples.poll(); + mDemuxedInputSamples.offer(toQueue); + if (BuildConfig.DEBUG_BUILD) { + Log.d( + LOGTAG, + "Demuxed sample PTS : " + + toQueue.info.presentationTimeUs + + ", duration :" + + toQueue.duration + + ", isKeyFrame(" + + toQueue.isKeyFrame() + + ", formatIndex(" + + toQueue.formatIndex + + "), queue size : " + + mDemuxedInputSamples.size() + + ", NoDuQueue size : " + + mDemuxedNoDurationSamples.size()); + } + } else if (mInputStreamEnded) { + calculateSamplesWithin(inputArray, sizeOfNoDura); + + // NOTE : We're not able to calculate the duration for the last sample. + // A workaround here is to assign a close duration to it. + long prevDuration = 33333; + GeckoHLSSample sample = null; + for (sample = mDemuxedNoDurationSamples.poll(); + sample != null; + sample = mDemuxedNoDurationSamples.poll()) { + if (sample.duration == Long.MAX_VALUE) { + sample.duration = prevDuration; + if (DEBUG) { + Log.d(LOGTAG, "Adjust the PTS of the last sample to " + sample.duration + " (us)"); + } + } + prevDuration = sample.duration; + if (DEBUG) { + Log.d( + LOGTAG, + "last loop to offer samples - PTS : " + + sample.info.presentationTimeUs + + ", Duration : " + + sample.duration + + ", isEOS : " + + sample.isEOS()); + } + mDemuxedInputSamples.offer(sample); + } + } + } + + // Return the time of first keyframe sample in the queue. + // If there's no key frame in the queue, return the MAX_VALUE so + // MFR won't mistake for that which the decode is getting slow. + public long getNextKeyFrameTime() { + long nextKeyFrameTime = Long.MAX_VALUE; + for (final GeckoHLSSample sample : mDemuxedInputSamples) { + if (sample != null && (sample.info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + nextKeyFrameTime = sample.info.presentationTimeUs; + break; + } + } + return nextKeyFrameTime; + } + + @Override + protected void onStreamChanged(final Format[] formats, final long offsetUs) { + mStreamFormats = formats; + } + + private static CodecMaxValues getCodecMaxValues( + final Format format, final Format[] streamFormats) { + int maxWidth = format.width; + int maxHeight = format.height; + int maxInputSize = getMaxInputSize(format); + for (final Format streamFormat : streamFormats) { + if (areAdaptationCompatible(format, streamFormat)) { + maxWidth = Math.max(maxWidth, streamFormat.width); + maxHeight = Math.max(maxHeight, streamFormat.height); + maxInputSize = Math.max(maxInputSize, getMaxInputSize(streamFormat)); + } + } + return new CodecMaxValues(maxWidth, maxHeight, maxInputSize); + } + + private static int getMaxInputSize(final Format format) { + if (format.maxInputSize != Format.NO_VALUE) { + // The format defines an explicit maximum input size. + return format.maxInputSize; + } + + if (format.width == Format.NO_VALUE || format.height == Format.NO_VALUE) { + // We can't infer a maximum input size without video dimensions. + return Format.NO_VALUE; + } + + // Attempt to infer a maximum input size from the format. + final int maxPixels; + final int minCompressionRatio; + switch (format.sampleMimeType) { + case MimeTypes.VIDEO_H264: + // Round up width/height to an integer number of macroblocks. + maxPixels = ((format.width + 15) / 16) * ((format.height + 15) / 16) * 16 * 16; + minCompressionRatio = 2; + break; + default: + // Leave the default max input size. + return Format.NO_VALUE; + } + // Estimate the maximum input size assuming three channel 4:2:0 subsampled input frames. + return (maxPixels * 3) / (2 * minCompressionRatio); + } + + private static boolean areAdaptationCompatible(final Format first, final Format second) { + return first.sampleMimeType.equals(second.sampleMimeType) + && getRotationDegrees(first) == getRotationDegrees(second); + } + + private static int getRotationDegrees(final Format format) { + return format.rotationDegrees == Format.NO_VALUE ? 0 : format.rotationDegrees; + } + + private static final class CodecMaxValues { + public final int width; + public final int height; + public final int inputSize; + + public CodecMaxValues(final int width, final int height, final int inputSize) { + this.width = width; + this.height = height; + this.inputSize = inputSize; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java new file mode 100644 index 0000000000..875a90c1dd --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.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.media; + +import android.media.MediaCrypto; + +public interface GeckoMediaDrm { + public interface Callbacks { + void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + void onSessionUpdated(int promiseId, byte[] sessionId); + + void onSessionClosed(int promiseId, byte[] sessionId); + + void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + void onSessionError(byte[] sessionId, String message); + + void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + + // All failure cases should go through this function. + void onRejectPromise(int promiseId, String message); + } + + void setCallbacks(Callbacks callbacks); + + void createSession(int createSessionToken, int promiseId, String initDataType, byte[] initData); + + void updateSession(int promiseId, String sessionId, byte[] response); + + void closeSession(int promiseId, String sessionId); + + void release(); + + MediaCrypto getMediaCrypto(); + + void setServerCertificate(final byte[] cert); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java new file mode 100644 index 0000000000..e5380bbb5c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java @@ -0,0 +1,771 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.DeniedByServerException; +import android.media.MediaCrypto; +import android.media.MediaDrm; +import android.media.NotProvisionedException; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import androidx.annotation.RequiresApi; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.UUID; +import org.mozilla.gecko.util.ProxySelector; + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +public class GeckoMediaDrmBridgeV21 implements GeckoMediaDrm { + protected final String LOGTAG; + private static final String INVALID_SESSION_ID = "Invalid"; + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + private static final int MAX_PROMISE_ID = Integer.MAX_VALUE; + // MediaDrm.KeyStatus information listener is supported on M+, adding a + // dummy key id to report key status. + private static final byte[] DUMMY_KEY_ID = new byte[] {0}; + + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + private UUID mSchemeUUID; + private Handler mHandler; + PostRequestTask mProvisionTask; + private HandlerThread mHandlerThread; + private ByteBuffer mCryptoSessionId; + + // mProvisioningPromiseId is great than 0 only during provisioning. + private int mProvisioningPromiseId; + private HashSet mSessionIds; + private HashMap mSessionMIMETypes; + private ArrayDeque mPendingCreateSessionDataQueue; + private PendingKeyRequest mPendingKeyRequest; + private GeckoMediaDrm.Callbacks mCallbacks; + + private MediaCrypto mCrypto; + protected MediaDrm mDrm; + + public static final int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/ + public static final int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/ + public static final int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/ + + // Store session data while provisioning + private static class PendingCreateSessionData { + public final int mToken; + public final int mPromiseId; + public final byte[] mInitData; + public final String mMimeType; + + private PendingCreateSessionData( + final int token, final int promiseId, final byte[] initData, final String mimeType) { + mToken = token; + mPromiseId = promiseId; + mInitData = initData; + mMimeType = mimeType; + } + } + + private static class PendingKeyRequest { + public final ByteBuffer mSession; + public final byte[] mData; + public final String mMimeType; + + private PendingKeyRequest(final ByteBuffer session, final byte[] data, final String mimeType) { + mSession = session; + mData = data; + mMimeType = mimeType; + } + } + + public boolean isSecureDecoderComonentRequired(final String mimeType) { + if (mCrypto != null) { + return mCrypto.requiresSecureDecoderComponent(mimeType); + } + return false; + } + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + @SuppressLint("WrongConstant") + private void configureVendorSpecificProperty() { + assertTrue(mDrm != null); + if (mDrm == null) { + return; + } + // Support L3 for now + mDrm.setPropertyString("securityLevel", "L3"); + // Refer to chromium, set multi-session mode for Widevine. + if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) { + mDrm.setPropertyString("privacyMode", "enable"); + mDrm.setPropertyString("sessionSharing", "enable"); + } + } + + GeckoMediaDrmBridgeV21(final String keySystem) throws Exception { + LOGTAG = getClass().getSimpleName(); + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21 ctor"); + + mProvisioningPromiseId = 0; + mSessionIds = new HashSet(); + mSessionMIMETypes = new HashMap(); + mPendingCreateSessionDataQueue = new ArrayDeque(); + + mSchemeUUID = convertKeySystemToSchemeUUID(keySystem); + mCryptoSessionId = null; + + if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString()); + + // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions + // threw by the following steps. + mDrm = new MediaDrm(mSchemeUUID); + configureVendorSpecificProperty(); + mDrm.setOnEventListener(new MediaDrmListener()); + try { + // ensureMediaCryptoCreated may cause NotProvisionedException for the first time use. + // Need to start provisioning with a dummy promise id. + ensureMediaCryptoCreated(); + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage()); + startProvisioning(MAX_PROMISE_ID); + } + } + + @Override + public void setCallbacks(final GeckoMediaDrm.Callbacks callbacks) { + assertTrue(callbacks != null); + mCallbacks = callbacks; + } + + @Override + public void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + if (mProvisioningPromiseId > 0 && mCrypto == null) { + if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !"); + savePendingCreateSessionData( + createSessionToken, promiseId, + initData, initDataType); + return; + } + + ByteBuffer sessionId = null; + try { + final boolean hasMediaCrypto = ensureMediaCryptoCreated(); + if (!hasMediaCrypto) { + onRejectPromise(promiseId, "MediaCrypto intance is not created !"); + return; + } + + sessionId = openSession(); + if (sessionId == null) { + onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !"); + return; + } + + final MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType); + if (request == null) { + mDrm.closeSession(sessionId.array()); + onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !"); + return; + } + onSessionCreated(createSessionToken, promiseId, sessionId.array(), request.getData()); + onSessionMessage(sessionId.array(), LICENSE_REQUEST_INITIAL, request.getData()); + mSessionMIMETypes.put(sessionId, initDataType); + mSessionIds.add(sessionId); + if (DEBUG) + Log.d( + LOGTAG, + " StringID : " + new String(sessionId.array(), UTF_8) + " is put into mSessionIds "); + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage()); + if (sessionId != null) { + // The promise of this createSession will be either resolved + // or rejected after provisioning. + mDrm.closeSession(sessionId.array()); + } + savePendingCreateSessionData( + createSessionToken, promiseId, + initData, initDataType); + startProvisioning(promiseId); + } + } + + @Override + public void updateSession(final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8)); + if (!sessionExists(session)) { + onRejectPromise(promiseId, "Invalid session during updateSession."); + return; + } + + try { + final byte[] keySetId = mDrm.provideKeyResponse(session.array(), response); + if (DEBUG) { + final HashMap infoMap = mDrm.queryKeyStatus(session.array()); + for (final String strKey : infoMap.keySet()) { + final String strValue = infoMap.get(strKey); + Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")"); + } + } + HandleKeyStatusChangeByDummyKey(sessionId); + onSessionUpdated(promiseId, session.array()); + return; + } catch (final NotProvisionedException | DeniedByServerException | IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:", e); + onSessionError(session.array(), "Got exception during updateSession."); + onRejectPromise(promiseId, "Got exception during updateSession."); + } + release(); + return; + } + + @Override + public void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8)); + mSessionIds.remove(session); + mDrm.closeSession(session.array()); + onSessionClosed(promiseId, session.array()); + } + + @Override + public void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + if (mProvisionTask != null) { + mProvisionTask.cancel(true); + mProvisionTask = null; + } + if (mProvisioningPromiseId > 0) { + onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session."); + mProvisioningPromiseId = 0; + } + if (mPendingKeyRequest != null) { + mPendingKeyRequest = null; + } + while (!mPendingCreateSessionDataQueue.isEmpty()) { + final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + if (pendingData != null) { + onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions."); + } + } + mPendingCreateSessionDataQueue = null; + + if (mDrm != null) { + for (final ByteBuffer session : mSessionIds) { + mDrm.closeSession(session.array()); + } + mDrm.release(); + mDrm = null; + } + mSessionIds.clear(); + mSessionIds = null; + mSessionMIMETypes.clear(); + mSessionMIMETypes = null; + + mCryptoSessionId = null; + if (mCrypto != null) { + mCrypto.release(); + mCrypto = null; + } + if (mHandlerThread != null) { + mHandlerThread.quitSafely(); + mHandlerThread = null; + } + mHandler = null; + } + + @Override + public MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + return mCrypto; + } + + @SuppressLint("WrongConstant") + @Override + public void setServerCertificate(final byte[] cert) { + if (DEBUG) Log.d(LOGTAG, "setServerCertificate()"); + if (mDrm == null) { + throw new IllegalStateException("MediaDrm instance doesn't exist !!"); + } + mDrm.setPropertyByteArray("serviceCertificate", cert); + return; + } + + protected void HandleKeyStatusChangeByDummyKey(final String sessionId) { + final SessionKeyInfo[] keyInfos = new SessionKeyInfo[1]; + keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID, MediaDrm.KeyStatus.STATUS_USABLE); + onSessionBatchedKeyChanged(sessionId.getBytes(), keyInfos); + if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId); + } + + protected void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + } + + protected void onSessionUpdated(final int promiseId, final byte[] sessionId) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionUpdated(promiseId, sessionId); + } + } + + protected void onSessionClosed(final int promiseId, final byte[] sessionId) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionClosed(promiseId, sessionId); + } + } + + protected void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + } + + protected void onSessionError(final byte[] sessionId, final String message) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionError(sessionId, message); + } + } + + protected void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } + + protected void onRejectPromise(final int promiseId, final String message) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onRejectPromise(promiseId, message); + } + } + + private MediaDrm.KeyRequest getKeyRequest( + final ByteBuffer aSession, final byte[] data, final String mimeType) + throws android.media.NotProvisionedException { + if (mProvisioningPromiseId > 0) { + if (DEBUG) Log.d(LOGTAG, "Now provisioning"); + return null; + } + + try { + final HashMap optionalParameters = new HashMap(); + return mDrm.getKeyRequest( + aSession.array(), data, mimeType, MediaDrm.KEY_TYPE_STREAMING, optionalParameters); + } catch (final Exception e) { + Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e); + } + return null; + } + + private class MediaDrmListener implements MediaDrm.OnEventListener { + @Override + public void onEvent( + final MediaDrm mediaDrm, + final byte[] sessionArray, + final int event, + final int extra, + final byte[] data) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()"); + if (sessionArray == null) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session."); + return; + } + final ByteBuffer session = ByteBuffer.wrap(sessionArray); + if (!sessionExists(session)) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session."); + return; + } + // On L, these events are treated as exceptions and handled correspondingly. + // Leaving this code block for logging message. + switch (event) { + case MediaDrm.EVENT_PROVISION_REQUIRED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED"); + break; + case MediaDrm.EVENT_KEY_REQUIRED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_KEY_REQUIRED, sessionId=" + new String(session.array(), UTF_8)); + final String mimeType = mSessionMIMETypes.get(session); + MediaDrm.KeyRequest request = null; + try { + request = getKeyRequest(session, data, mimeType); + } catch (final android.media.NotProvisionedException e) { + Log.w(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED, Device not provisioned.", e); + startProvisioning(MAX_PROMISE_ID); + mPendingKeyRequest = new PendingKeyRequest(session, data, mimeType); + return; + } + requestLicense(sessionArray, request); + break; + case MediaDrm.EVENT_KEY_EXPIRED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + new String(session.array(), UTF_8)); + break; + case MediaDrm.EVENT_VENDOR_DEFINED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + new String(session.array(), UTF_8)); + break; + case MediaDrm.EVENT_SESSION_RECLAIMED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_SESSION_RECLAIMED, sessionId=" + + new String(session.array(), UTF_8)); + break; + default: + if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event); + return; + } + } + } + + private ByteBuffer openSession() throws android.media.NotProvisionedException { + try { + final byte[] sessionId = mDrm.openSession(); + // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in + // case the underlying byte[] is modified. + return ByteBuffer.wrap(sessionId.clone()); + } catch (final android.media.NotProvisionedException e) { + // Throw NotProvisionedException so that we can startProvisioning(). + throw e; + } catch (final java.lang.RuntimeException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage()); + release(); + return null; + } catch (final android.media.MediaDrmException e) { + // Other MediaDrmExceptions (e.g. ResourceBusyException) are not + // recoverable. + release(); + return null; + } + } + + protected boolean sessionExists(final ByteBuffer session) { + if (mCryptoSessionId == null) { + if (DEBUG) + Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created."); + return false; + } + if (session == null) { + if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !"); + return false; + } + return !session.equals(mCryptoSessionId) && mSessionIds.contains(session); + } + + private class PostRequestTask extends AsyncTask { + private static final String LOGTAG = "PostRequestTask"; + + private int mPromiseId; + private String mURL; + private byte[] mDrmRequest; + private byte[] mResponseBody; + + PostRequestTask(final int promiseId, final String url, final byte[] drmRequest) { + this.mPromiseId = promiseId; + this.mURL = url; + this.mDrmRequest = drmRequest; + } + + @Override + protected Void doInBackground(final Void... params) { + HttpURLConnection urlConnection = null; + BufferedReader in = null; + try { + final URI finalURI = + new URI(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8")); + urlConnection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(finalURI); + urlConnection.setRequestMethod("POST"); + if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURI.toString()); + + // Add data + urlConnection.setRequestProperty("Accept", "*/*"); + urlConnection.setRequestProperty("User-Agent", getCDMUserAgent()); + urlConnection.setRequestProperty("Content-Type", "application/json"); + + // Execute HTTP Post Request + urlConnection.connect(); + + final int responseCode = urlConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), UTF_8)); + String inputLine; + final StringBuffer response = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + mResponseBody = String.valueOf(response).getBytes(UTF_8); + if (DEBUG) Log.d(LOGTAG, "Provisioning, response received."); + if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length); + } else { + Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode); + } + } catch (final IOException e) { + Log.e(LOGTAG, "Got exception during posting provisioning request ...", e); + } catch (final URISyntaxException e) { + Log.e(LOGTAG, "Got exception during creating uri ...", e); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + try { + if (in != null) { + in.close(); + } + } catch (final IOException e) { + Log.e(LOGTAG, "Exception during closing in ...", e); + } + } + return null; + } + + @Override + protected void onPostExecute(final Void v) { + onProvisionResponse(mPromiseId, mResponseBody); + } + } + + private boolean provideProvisionResponse(final byte[] response) { + if (response == null || response.length == 0) { + if (DEBUG) Log.d(LOGTAG, "Invalid provision response."); + return false; + } + + try { + mDrm.provideProvisionResponse(response); + return true; + } catch (final android.media.DeniedByServerException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } catch (final java.lang.IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } + return false; + } + + private void savePendingCreateSessionData( + final int token, final int promiseId, final byte[] initData, final String mime) { + if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId); + mPendingCreateSessionDataQueue.offer( + new PendingCreateSessionData(token, promiseId, initData, mime)); + } + + private void processPendingCreateSessionData() { + if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... "); + + assertTrue(mProvisioningPromiseId == 0); + try { + while (!mPendingCreateSessionDataQueue.isEmpty()) { + final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + if (pendingData == null) { + return; + } + if (DEBUG) + Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId); + + createSession( + pendingData.mToken, + pendingData.mPromiseId, + pendingData.mMimeType, + pendingData.mInitData); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e); + } + } + + private void resumePendingOperations() { + if (mHandlerThread == null) { + mHandlerThread = new HandlerThread("PendingSessionOpsThread"); + mHandlerThread.start(); + } + if (mHandler == null) { + mHandler = new Handler(mHandlerThread.getLooper()); + } + mHandler.post( + new Runnable() { + @Override + public void run() { + if (mPendingKeyRequest != null) { + MediaDrm.KeyRequest request = null; + try { + request = + getKeyRequest( + mPendingKeyRequest.mSession, + mPendingKeyRequest.mData, + mPendingKeyRequest.mMimeType); + } catch (final NotProvisionedException e) { + Log.e(LOGTAG, "Cannot get key request after provisioning!"); + return; + } finally { + mPendingKeyRequest = null; + } + requestLicense(mPendingKeyRequest.mSession.array(), request); + } else { + processPendingCreateSessionData(); + } + } + }); + } + + private void requestLicense(final byte[] session, final MediaDrm.KeyRequest request) { + if (request == null) { + Log.e(LOGTAG, "null key request when requesting license"); + return; + } + // The EME spec says the messageType is only for optimization and optional. + // Send 'License_request' as default when it's not available. + int requestType = LICENSE_REQUEST_INITIAL; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestType = request.getRequestType(); + } + onSessionMessage(session, requestType, request.getData()); + } + + // Only triggered when failed on {openSession, getKeyRequest} + private void startProvisioning(final int promiseId) { + if (DEBUG) Log.d(LOGTAG, "startProvisioning()"); + if (mProvisioningPromiseId > 0) { + // Already in provisioning. + return; + } + try { + mProvisioningPromiseId = promiseId; + final MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest(); + mProvisionTask = new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData()); + mProvisionTask.execute(); + } catch (final Exception e) { + onRejectPromise(promiseId, "Exception happened in startProvisioning !"); + mProvisioningPromiseId = 0; + } + } + + private void onProvisionResponse(final int promiseId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()"); + mProvisionTask = null; + mProvisioningPromiseId = 0; + final boolean success = provideProvisionResponse(response); + if (success) { + // Promise will either be resovled / rejected in createSession during + // resuming operations. + resumePendingOperations(); + } else { + onRejectPromise(promiseId, "Failed to provide provision response."); + } + } + + private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException { + if (mCrypto != null) { + return true; + } + try { + mCryptoSessionId = openSession(); + if (mCryptoSessionId == null) { + if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto"); + return false; + } + + if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) { + final byte[] cryptoSessionId = mCryptoSessionId.array(); + mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId); + mSessionIds.add(mCryptoSessionId); + if (DEBUG) + Log.d( + LOGTAG, + "MediaCrypto successfully created! - SId " + + INVALID_SESSION_ID + + ", " + + new String(cryptoSessionId, UTF_8)); + return true; + } else { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme."); + return false; + } + } catch (final android.media.MediaCryptoException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage()); + release(); + return false; + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) + Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage()); + throw e; + } + } + + private UUID convertKeySystemToSchemeUUID(final String keySystem) { + if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) { + return WIDEVINE_SCHEME_UUID; + } + if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem); + return new UUID(0L, 0L); + } + + private String getCDMUserAgent() { + // This user agent is found and hard-coded in Android(L) source code and + // Chromium project. Not sure if it's gonna change in the future. + final String ua = "Widevine CDM v1.0"; + return ua; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java new file mode 100644 index 0000000000..bee2635a81 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import static android.os.Build.VERSION_CODES.M; + +import android.annotation.TargetApi; +import android.media.MediaDrm; +import android.util.Log; +import java.util.List; + +@TargetApi(M) +public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 { + private static final boolean DEBUG = false; + + GeckoMediaDrmBridgeV23(final String keySystem) throws Exception { + super(keySystem); + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor"); + mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null); + } + + private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener { + @Override + public void onKeyStatusChange( + final MediaDrm mediaDrm, + final byte[] sessionId, + final List keyInformation, + final boolean hasNewUsableKey) { + if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey); + if (keyInformation.size() == 0) { + return; + } + final SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()]; + for (int i = 0; i < keyInformation.size(); i++) { + final MediaDrm.KeyStatus keyStatus = keyInformation.get(i); + keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(), keyStatus.getStatusCode()); + } + onSessionBatchedKeyChanged(sessionId, keyInfos); + if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + new String(sessionId)); + } + } + + @Override + protected void HandleKeyStatusChangeByDummyKey(final String sessionId) { + // MediaDrm.KeyStatus information listener is supported on M+, there is no need to use + // dummy key id to report key status anymore. + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java new file mode 100644 index 0000000000..47278115d3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import androidx.annotation.NonNull; +import java.util.ArrayList; + +public final class GeckoPlayerFactory { + public static final ArrayList sPlayerList = new ArrayList(); + + static synchronized BaseHlsPlayer getPlayer() { + try { + final Class cls = Class.forName("org.mozilla.gecko.media.GeckoHlsPlayer"); + final BaseHlsPlayer player = (BaseHlsPlayer) cls.newInstance(); + sPlayerList.add(player); + return player; + } catch (final Exception e) { + Log.e("GeckoPlayerFactory", "Class GeckoHlsPlayer not found or failed to create", e); + } + return null; + } + + static synchronized BaseHlsPlayer getPlayer(final int id) { + for (final BaseHlsPlayer player : sPlayerList) { + if (player.getId() == id) { + return player; + } + } + Log.w("GeckoPlayerFactory", "No player found with id : " + id); + return null; + } + + static synchronized void removePlayer(final @NonNull BaseHlsPlayer player) { + final int index = sPlayerList.indexOf(player); + if (index >= 0) { + sPlayerList.remove(player); + Log.d("GeckoPlayerFactory", "HlsPlayer with id(" + player.getId() + ") is removed."); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java new file mode 100644 index 0000000000..c641c58354 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import org.mozilla.gecko.annotation.WrapForJNI; + +// A subset of the class VideoInfo in dom/media/MediaInfo.h +@WrapForJNI +public final class GeckoVideoInfo { + public final byte[] codecSpecificData; + public final byte[] extraData; + public final int displayWidth; + public final int displayHeight; + public final int pictureWidth; + public final int pictureHeight; + public final int rotation; + public final int stereoMode; + public final long duration; + public final String mimeType; + + public GeckoVideoInfo( + final int displayWidth, + final int displayHeight, + final int pictureWidth, + final int pictureHeight, + final int rotation, + final int stereoMode, + final long duration, + final String mimeType, + final byte[] extraData, + final byte[] codecSpecificData) { + this.displayWidth = displayWidth; + this.displayHeight = displayHeight; + this.pictureWidth = pictureWidth; + this.pictureHeight = pictureHeight; + this.rotation = rotation; + this.stereoMode = stereoMode; + this.duration = duration; + this.mimeType = mimeType; + this.extraData = extraData; + this.codecSpecificData = codecSpecificData; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java new file mode 100644 index 0000000000..7c5102c63d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java @@ -0,0 +1,490 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import android.view.Surface; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; + +// Implement async API using MediaCodec sync mode (API v16). +// This class uses internal worker thread/handler (mBufferPoller) to poll +// input and output buffer and notifies the client through callbacks. +final class JellyBeanAsyncCodec implements AsyncCodec { + private static final String LOGTAG = "GeckoAsyncCodecAPIv16"; + private static final boolean DEBUG = false; + + private static final int ERROR_CODEC = -10000; + + private abstract class CancelableHandler extends Handler { + private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL' + + protected CancelableHandler(final Looper looper) { + super(looper); + } + + protected void cancel() { + removeCallbacksAndMessages(null); + sendEmptyMessage(MSG_CANCELLATION); + // Wait until handleMessageLocked() is done. + synchronized (this) { + } + } + + protected boolean isCanceled() { + return hasMessages(MSG_CANCELLATION); + } + + // Subclass should implement this and return true if it handles msg. + // Warning: Never, ever call super.handleMessage() in this method! + protected abstract boolean handleMessageLocked(Message msg); + + public final void handleMessage(final Message msg) { + // Block cancel() during handleMessageLocked(). + synchronized (this) { + if (isCanceled() || handleMessageLocked(msg)) { + return; + } + } + + switch (msg.what) { + case MSG_CANCELLATION: + // Just a marker. Nothing to do here. + if (DEBUG) { + Log.d( + LOGTAG, + "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this); + } + break; + default: + super.handleMessage(msg); + break; + } + } + } + + // A handler to invoke AsyncCodec.Callbacks methods. + private final class CallbackSender extends CancelableHandler { + private static final int MSG_INPUT_BUFFER_AVAILABLE = 1; + private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2; + private static final int MSG_OUTPUT_FORMAT_CHANGE = 3; + private static final int MSG_ERROR = 4; + private Callbacks mCallbacks; + + private CallbackSender(final Looper looper, final Callbacks callbacks) { + super(looper); + mCallbacks = callbacks; + } + + public void notifyInputBuffer(final int index) { + if (isCanceled()) { + return; + } + + final Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE); + msg.arg1 = index; + processMessage(msg); + } + + private void processMessage(final Message msg) { + if (Looper.myLooper() == getLooper()) { + handleMessage(msg); + } else { + sendMessage(msg); + } + } + + public void notifyOutputBuffer(final int index, final MediaCodec.BufferInfo info) { + if (isCanceled()) { + return; + } + + final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info); + msg.arg1 = index; + processMessage(msg); + } + + public void notifyOutputFormat(final MediaFormat format) { + if (isCanceled()) { + return; + } + processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format)); + } + + public void notifyError(final int result) { + Log.e(LOGTAG, "codec error:" + result); + processMessage(obtainMessage(MSG_ERROR, result, 0)); + } + + protected boolean handleMessageLocked(final Message msg) { + switch (msg.what) { + case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index. + mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this, msg.arg1); + break; + case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info. + mCallbacks.onOutputBufferAvailable( + JellyBeanAsyncCodec.this, msg.arg1, (MediaCodec.BufferInfo) msg.obj); + break; + case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format. + mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this, (MediaFormat) msg.obj); + break; + case MSG_ERROR: // arg1: error code. + mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1); + break; + default: + return false; + } + + return true; + } + } + + // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(), + // with 10ms time-out. Once triggered and successfully gets a buffer, it + // will schedule next polling until EOS or failure. To prevent it from + // automatically polling more buffer, use cancel() it inherits from + // CancelableHandler. + private final class BufferPoller extends CancelableHandler { + private static final int MSG_POLL_INPUT_BUFFERS = 1; + private static final int MSG_POLL_OUTPUT_BUFFERS = 2; + + private static final long DEQUEUE_TIMEOUT_US = 10000; + + public BufferPoller(final Looper looper) { + super(looper); + } + + private void schedulePollingIfNotCanceled(final int what) { + if (isCanceled()) { + return; + } + + schedulePolling(what); + } + + private void schedulePolling(final int what) { + if (needsBuffer(what)) { + sendEmptyMessage(what); + } + } + + private boolean needsBuffer(final int what) { + if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) { + return false; + } + + if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) { + return false; + } + + return true; + } + + protected boolean handleMessageLocked(final Message msg) { + try { + switch (msg.what) { + case MSG_POLL_INPUT_BUFFERS: + pollInputBuffer(); + break; + case MSG_POLL_OUTPUT_BUFFERS: + pollOutputBuffer(); + break; + default: + return false; + } + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + } + + return true; + } + + private void pollInputBuffer() { + final int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + if (result >= 0) { + mCallbackSender.notifyInputBuffer(result); + } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) { + mBufferPoller.schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } else { + mCallbackSender.notifyError(result); + } + } + + private void pollOutputBuffer() { + boolean dequeueMoreBuffer = true; + final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + final int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US); + if (result >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + mOutputEnded = true; + } + mCallbackSender.notifyOutputBuffer(result, info); + } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + mOutputBuffers = mCodec.getOutputBuffers(); + } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + mOutputBuffers = mCodec.getOutputBuffers(); + mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat()); + } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) { + // When input ended, keep polling remaining output buffer until EOS. + dequeueMoreBuffer = mInputEnded; + } else { + mCallbackSender.notifyError(result); + dequeueMoreBuffer = false; + } + + if (dequeueMoreBuffer) { + schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS); + } + } + } + + private MediaCodec mCodec; + private ByteBuffer[] mInputBuffers; + private ByteBuffer[] mOutputBuffers; + private AsyncCodec.Callbacks mCallbacks; + private CallbackSender mCallbackSender; + + private BufferPoller mBufferPoller; + private volatile boolean mInputEnded; + private volatile boolean mOutputEnded; + + // Must be called on a thread with looper. + /* package */ JellyBeanAsyncCodec(final String name) throws IOException { + mCodec = MediaCodec.createByCodecName(name); + initBufferPoller(name + " buffer poller"); + } + + private void initBufferPoller(final String name) { + if (mBufferPoller != null) { + Log.e(LOGTAG, "poller already initialized"); + return; + } + final HandlerThread thread = new HandlerThread(name); + thread.start(); + mBufferPoller = new BufferPoller(thread.getLooper()); + if (DEBUG) { + Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId()); + } + } + + @Override + public void setCallbacks(final AsyncCodec.Callbacks callbacks, final Handler handler) { + if (callbacks == null) { + return; + } + + Looper looper = (handler == null) ? null : handler.getLooper(); + if (looper == null) { + // Use this thread if no handler supplied. + looper = Looper.myLooper(); + } + if (looper == null) { + // This thread has no looper. Use poller thread. + looper = mBufferPoller.getLooper(); + } + mCallbackSender = new CallbackSender(looper, callbacks); + if (DEBUG) { + Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender); + } + } + + @Override + public void configure( + final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) { + assertCallbacks(); + + mCodec.configure(format, surface, crypto, flags); + } + + @Override + public boolean isAdaptivePlaybackSupported(final String mimeType) { + return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType); + } + + @Override + public boolean isTunneledPlaybackSupported(final String mimeType) { + try { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP + && mCodec + .getCodecInfo() + .getCapabilitiesForType(mimeType) + .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } catch (final Exception e) { + return false; + } + } + + private void assertCallbacks() { + if (mCallbackSender == null) { + throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks()."); + } + } + + @Override + public void start() { + assertCallbacks(); + + mCodec.start(); + mInputEnded = false; + mOutputEnded = false; + mInputBuffers = mCodec.getInputBuffers(); + resumeReceivingInputs(); + mOutputBuffers = mCodec.getOutputBuffers(); + } + + @Override + public void resumeReceivingInputs() { + for (int i = 0; i < mInputBuffers.length; i++) { + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + } + + @Override + public final void setBitrate(final int bps) { + if (android.os.Build.VERSION.SDK_INT >= 19) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps); + mCodec.setParameters(params); + } + } + + @Override + public final void queueInputBuffer( + final int index, + final int offset, + final int size, + final long presentationTimeUs, + final int flags) { + assertCallbacks(); + + mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + && ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0)) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mCodec.setParameters(params); + } + + try { + mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + return; + } + + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + + @Override + public final void queueSecureInputBuffer( + final int index, + final int offset, + final MediaCodec.CryptoInfo cryptoInfo, + final long presentationTimeUs, + final int flags) { + assertCallbacks(); + + mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + + try { + mCodec.queueSecureInputBuffer(index, offset, cryptoInfo, presentationTimeUs, flags); + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + return; + } + + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS); + } + + @Override + public final void releaseOutputBuffer(final int index, final boolean render) { + assertCallbacks(); + + mCodec.releaseOutputBuffer(index, render); + } + + @Override + public final ByteBuffer getInputBuffer(final int index) { + assertCallbacks(); + + return mInputBuffers[index]; + } + + @Override + public final ByteBuffer getOutputBuffer(final int index) { + assertCallbacks(); + + return mOutputBuffers[index]; + } + + @Override + public MediaFormat getInputFormat() { + return null; + } + + @Override + public void flush() { + assertCallbacks(); + + mInputEnded = false; + mOutputEnded = false; + cancelPendingTasks(); + mCodec.flush(); + } + + private void cancelPendingTasks() { + mBufferPoller.cancel(); + mCallbackSender.cancel(); + } + + @Override + public void stop() { + assertCallbacks(); + + cancelPendingTasks(); + mCodec.stop(); + } + + @Override + public void release() { + assertCallbacks(); + + cancelPendingTasks(); + mCallbackSender = null; + mCodec.release(); + stopBufferPoller(); + } + + private void stopBufferPoller() { + if (mBufferPoller == null) { + Log.e(LOGTAG, "no initialized poller."); + return; + } + + mBufferPoller.getLooper().quit(); + mBufferPoller = null; + + if (DEBUG) { + Log.d(LOGTAG, "stop poller " + this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java new file mode 100644 index 0000000000..8afc96109d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.Surface; +import androidx.annotation.NonNull; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +/* package */ final class LollipopAsyncCodec implements AsyncCodec { + private final MediaCodec mCodec; + + private class CodecCallback extends MediaCodec.Callback { + private final Forwarder mForwarder; + + private class Forwarder extends Handler { + private static final int MSG_INPUT_BUFFER_AVAILABLE = 1; + private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2; + private static final int MSG_OUTPUT_FORMAT_CHANGE = 3; + private static final int MSG_ERROR = 4; + + private final Callbacks mTarget; + + private Forwarder(final Looper looper, final Callbacks target) { + super(looper); + mTarget = target; + } + + @Override + public void handleMessage(final Message msg) { + switch (msg.what) { + case MSG_INPUT_BUFFER_AVAILABLE: + mTarget.onInputBufferAvailable(LollipopAsyncCodec.this, msg.arg1); // index + break; + case MSG_OUTPUT_BUFFER_AVAILABLE: + mTarget.onOutputBufferAvailable( + LollipopAsyncCodec.this, + msg.arg1, // index + (MediaCodec.BufferInfo) msg.obj); // buffer info + break; + case MSG_OUTPUT_FORMAT_CHANGE: + mTarget.onOutputFormatChanged( + LollipopAsyncCodec.this, (MediaFormat) msg.obj); // output format + break; + case MSG_ERROR: + mTarget.onError(LollipopAsyncCodec.this, msg.arg1); // error code + break; + default: + super.handleMessage(msg); + } + } + + private void onInput(final int index) { + notify(obtainMessage(MSG_INPUT_BUFFER_AVAILABLE, index, 0)); + } + + private void notify(final Message msg) { + if (Looper.myLooper() == getLooper()) { + handleMessage(msg); + } else { + sendMessage(msg); + } + } + + private void onOutput(final int index, final MediaCodec.BufferInfo info) { + final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, index, 0, info); + notify(msg); + } + + private void onOutputFormatChanged(final MediaFormat format) { + notify(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format)); + } + + private void onError(final MediaCodec.CodecException e) { + e.printStackTrace(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + notify(obtainMessage(MSG_ERROR, e.getErrorCode())); + } else { + notify(obtainMessage(MSG_ERROR, e.getLocalizedMessage())); + } + } + } + + private CodecCallback(final Callbacks callbacks, final Handler handler) { + Looper looper = (handler == null) ? null : handler.getLooper(); + if (looper == null) { + // Use this thread if no handler supplied. + looper = Looper.myLooper(); + } + if (looper == null) { + // This thread has no looper. Use main thread. + looper = Looper.getMainLooper(); + } + + mForwarder = new Forwarder(looper, callbacks); + } + + @Override + public void onInputBufferAvailable(@NonNull final MediaCodec codec, final int index) { + mForwarder.onInput(index); + } + + @Override + public void onOutputBufferAvailable( + @NonNull final MediaCodec codec, + final int index, + @NonNull final MediaCodec.BufferInfo info) { + mForwarder.onOutput(index, info); + } + + @Override + public void onOutputFormatChanged( + @NonNull final MediaCodec codec, @NonNull final MediaFormat format) { + mForwarder.onOutputFormatChanged(format); + } + + @Override + public void onError( + @NonNull final MediaCodec codec, @NonNull final MediaCodec.CodecException e) { + mForwarder.onError(e); + } + } + + /* package */ LollipopAsyncCodec(final String name) throws IOException { + mCodec = MediaCodec.createByCodecName(name); + } + + @Override + public void setCallbacks(final Callbacks callbacks, final Handler handler) { + if (callbacks == null) { + return; + } + + mCodec.setCallback(new CodecCallback(callbacks, handler)); + } + + @Override + public void configure( + final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) { + mCodec.configure(format, surface, crypto, flags); + } + + @Override + public boolean isAdaptivePlaybackSupported(final String mimeType) { + return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType); + } + + @Override + public boolean isTunneledPlaybackSupported(final String mimeType) { + try { + return mCodec + .getCodecInfo() + .getCapabilitiesForType(mimeType) + .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } catch (final Exception e) { + return false; + } + } + + @Override + public void start() { + mCodec.start(); + } + + @Override + public void stop() { + mCodec.stop(); + } + + @Override + public void flush() { + mCodec.flush(); + } + + @Override + public void resumeReceivingInputs() { + mCodec.start(); + } + + @Override + public void setBitrate(final int bps) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps); + mCodec.setParameters(params); + } + + @Override + public void release() { + mCodec.release(); + } + + @Override + public ByteBuffer getInputBuffer(final int index) { + return mCodec.getInputBuffer(index); + } + + @Override + public ByteBuffer getOutputBuffer(final int index) { + return mCodec.getOutputBuffer(index); + } + + @Override + public MediaFormat getInputFormat() { + return mCodec.getInputFormat(); + } + + @Override + public void queueInputBuffer( + final int index, + final int offset, + final int size, + final long presentationTimeUs, + final int flags) { + if ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mCodec.setParameters(params); + } + mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } + + @Override + public void queueSecureInputBuffer( + final int index, + final int offset, + final MediaCodec.CryptoInfo info, + final long presentationTimeUs, + final int flags) { + mCodec.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); + } + + @Override + public void releaseOutputBuffer(final int index, final boolean render) { + mCodec.releaseOutputBuffer(index, render); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java new file mode 100644 index 0000000000..7be8be6236 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java @@ -0,0 +1,298 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.SuppressLint; +import android.media.MediaCrypto; +import android.media.MediaDrm; +import android.os.Build; +import android.util.Log; +import java.util.ArrayList; +import java.util.UUID; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +public final class MediaDrmProxy { + private static final String LOGTAG = "GeckoMediaDrmProxy"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + @WrapForJNI private static final String AAC = "audio/mp4a-latm"; + @WrapForJNI private static final String AVC = "video/avc"; + @WrapForJNI private static final String VORBIS = "audio/vorbis"; + @WrapForJNI private static final String VP8 = "video/x-vnd.on2.vp8"; + @WrapForJNI private static final String VP9 = "video/x-vnd.on2.vp9"; + @WrapForJNI private static final String OPUS = "audio/opus"; + @WrapForJNI private static final String FLAC = "audio/flac"; + + public static final ArrayList sProxyList = new ArrayList(); + + // A flag to avoid using the native object that has been destroyed. + private boolean mDestroyed; + private GeckoMediaDrm mImpl; + private String mDrmStubId; + + private static boolean isSystemSupported() { + // Support versions >= Marshmallow + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (DEBUG) + Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT); + return false; + } + return true; + } + + @SuppressLint("NewApi") + @WrapForJNI + public static boolean isSchemeSupported(final String keySystem) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID) + && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID); + } + if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem); + return false; + } + + @SuppressLint("NewApi") + @WrapForJNI + public static boolean IsCryptoSchemeSupported(final String keySystem, final String container) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container); + } + if (DEBUG) + Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container); + return false; + } + + // Interface for callback to native. + public interface Callbacks { + void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + void onSessionUpdated(int promiseId, byte[] sessionId); + + void onSessionClosed(int promiseId, byte[] sessionId); + + void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + void onSessionError(byte[] sessionId, String message); + + // MediaDrm.KeyStatus is available in API level 23(M) + // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html + // For compatibility between L and M above, we'll unwrap the KeyStatus structure + // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy). + void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + + void onRejectPromise(int promiseId, String message); + } // Callbacks + + public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks { + @WrapForJNI(calledFrom = "gecko") + NativeMediaDrmProxyCallbacks() {} + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionCreated( + int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionUpdated(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionClosed(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionError(byte[] sessionId, String message); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onRejectPromise(int promiseId, String message); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // NativeMediaDrmProxyCallbacks + + // A proxy to callback from LocalMediaDrmBridge to native instance. + public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks { + private final Callbacks mNativeCallbacks; + private final MediaDrmProxy mProxy; + + public MediaDrmProxyCallbacks(final MediaDrmProxy proxy, final Callbacks callbacks) { + mNativeCallbacks = callbacks; + mProxy = proxy; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionUpdated(promiseId, sessionId); + } + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionClosed(promiseId, sessionId); + } + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionError(sessionId, message); + } + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onRejectPromise(promiseId, message); + } + } + } // MediaDrmProxyCallbacks + + public boolean isDestroyed() { + return mDestroyed; + } + + @WrapForJNI(calledFrom = "gecko") + public static MediaDrmProxy create(final String keySystem, final Callbacks nativeCallbacks) { + final MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks); + return proxy; + } + + MediaDrmProxy(final String keySystem, final Callbacks nativeCallbacks) { + if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy"); + try { + mDrmStubId = UUID.randomUUID().toString(); + final IMediaDrmBridge remoteBridge = + RemoteManager.getInstance().createRemoteMediaDrmBridge(keySystem, mDrmStubId); + mImpl = new RemoteMediaDrmBridge(remoteBridge); + mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks)); + sProxyList.add(this); + } catch (final Exception e) { + Log.e(LOGTAG, "Constructing MediaDrmProxy ... error", e); + } + } + + @WrapForJNI + private void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId); + mImpl.createSession(createSessionToken, promiseId, initDataType, initData); + } + + @WrapForJNI + private void updateSession(final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) + Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.updateSession(promiseId, sessionId, response); + } + + @WrapForJNI + private void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) + Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.closeSession(promiseId, sessionId); + } + + @WrapForJNI(calledFrom = "gecko") + private String getStubId() { + return mDrmStubId; + } + + @WrapForJNI + public boolean setServerCertificate(final byte[] cert) { + try { + mImpl.setServerCertificate(cert); + return true; + } catch (final RuntimeException e) { + return false; + } + } + + // Get corresponding MediaCrypto object by a generated UUID for MediaCodec. + // Will be called on MediaFormatReader's TaskQueue. + @WrapForJNI + public static MediaCrypto getMediaCrypto(final String stubId) { + for (final MediaDrmProxy proxy : sProxyList) { + if (proxy.getStubId().equals(stubId)) { + return proxy.getMediaCryptoFromBridge(); + } + } + if (DEBUG) Log.d(LOGTAG, " NULL crytpo "); + return null; + } + + @WrapForJNI // Called when natvie object is destroyed. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mDestroyed) { + return; + } + mDestroyed = true; + release(); + } + + private void release() { + if (DEBUG) Log.d(LOGTAG, "release"); + sProxyList.remove(this); + mImpl.release(); + } + + private MediaCrypto getMediaCryptoFromBridge() { + return mImpl != null ? mImpl.getMediaCrypto() : null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java new file mode 100644 index 0000000000..ef4fdc6932 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.os.Process; +import android.os.RemoteException; +import android.util.Log; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.geckoview.BuildConfig; + +public final class MediaManager extends Service { + private static final String LOGTAG = "GeckoMediaManager"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + private static boolean sNativeLibLoaded; + private int mNumActiveRequests = 0; + + private Binder mBinder = + new IMediaManager.Stub() { + @Override + public ICodec createCodec() throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "request codec. Current active requests:" + mNumActiveRequests); + mNumActiveRequests++; + return new Codec(); + } + + @Override + public IMediaDrmBridge createRemoteMediaDrmBridge( + final String keySystem, final String stubId) throws RemoteException { + if (DEBUG) + Log.d(LOGTAG, "request DRM bridge. Current active requests:" + mNumActiveRequests); + mNumActiveRequests++; + return new RemoteMediaDrmBridgeStub(keySystem, stubId); + } + + @Override + public void endRequest() { + if (DEBUG) Log.d(LOGTAG, "end request. Current active requests:" + mNumActiveRequests); + if (mNumActiveRequests > 0) { + mNumActiveRequests--; + } else { + final RuntimeException e = + new RuntimeException("unmatched codec/DRM bridge creation and ending calls!"); + Log.e(LOGTAG, "Error:", e); + } + } + }; + + @Override + public synchronized void onCreate() { + if (!sNativeLibLoaded) { + GeckoLoader.doLoadLibrary(this, "mozglue"); + GeckoLoader.suppressCrashDialog(); + sNativeLibLoaded = true; + } + } + + @Override + public IBinder onBind(final Intent intent) { + return mBinder; + } + + @Override + public boolean onUnbind(final Intent intent) { + Log.i(LOGTAG, "Media service has been unbound. Stopping."); + stopSelf(); + if (mNumActiveRequests != 0) { + // Not unbound by RemoteManager -- caller process is dead. + Log.w(LOGTAG, "unbound while client still active."); + Process.killProcess(Process.myPid()); + } + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java new file mode 100644 index 0000000000..62026f534f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.MediaFormat; +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import java.util.LinkedList; +import java.util.List; +import java.util.NoSuchElementException; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.TelemetryUtils; +import org.mozilla.gecko.gfx.GeckoSurface; + +public final class RemoteManager implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteManager"; + private static final boolean DEBUG = false; + private static RemoteManager sRemoteManager = null; + + public static synchronized RemoteManager getInstance() { + if (sRemoteManager == null) { + sRemoteManager = new RemoteManager(); + } + + sRemoteManager.init(); + return sRemoteManager; + } + + private List mCodecs = new LinkedList(); + private List mDrmBridges = new LinkedList(); + + private volatile IMediaManager mRemote; + + private final class RemoteConnection implements ServiceConnection { + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + if (DEBUG) Log.d(LOGTAG, "service connected"); + try { + service.linkToDeath(RemoteManager.this, 0); + } catch (final RemoteException e) { + e.printStackTrace(); + } + synchronized (this) { + mRemote = IMediaManager.Stub.asInterface(service); + notify(); + } + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + if (DEBUG) Log.d(LOGTAG, "service disconnected"); + unlink(); + } + + private boolean connect() { + final Context appCtxt = GeckoAppShell.getApplicationContext(); + appCtxt.bindService( + new Intent(appCtxt, MediaManager.class), + mConnection, + Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT); + waitConnect(); + return mRemote != null; + } + + // Wait up to 5s. + private synchronized void waitConnect() { + int waitCount = 0; + while (mRemote == null && waitCount < 5) { + try { + wait(1000); + waitCount++; + } catch (final InterruptedException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + if (DEBUG) { + Log.d( + LOGTAG, + "wait ~" + waitCount + "s for connection: " + (mRemote == null ? "fail" : "ok")); + } + } + + private synchronized void waitDisconnect() { + while (mRemote != null) { + try { + wait(1000); + } catch (final InterruptedException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + } + + private synchronized void unlink() { + if (mRemote == null) { + return; + } + try { + mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0); + } catch (final NoSuchElementException e) { + Log.w(LOGTAG, "death recipient already released"); + } + mRemote = null; + notify(); + } + } + + RemoteConnection mConnection = new RemoteConnection(); + + private synchronized boolean init() { + if (mRemote != null) { + return true; + } + + if (DEBUG) Log.d(LOGTAG, "init remote manager " + this); + return mConnection.connect(); + } + + public synchronized CodecProxy createCodec( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final CodecProxy.Callbacks callbacks, + final String drmStubId) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize"); + return null; + } + try { + final ICodec remote = mRemote.createCodec(); + final CodecProxy proxy = + CodecProxy.createCodecProxy(isEncoder, format, surface, callbacks, drmStubId); + if (proxy.init(remote)) { + mCodecs.add(proxy); + return proxy; + } else { + return null; + } + } catch (final RemoteException e) { + e.printStackTrace(); + return null; + } + } + + public synchronized IMediaDrmBridge createRemoteMediaDrmBridge( + final String keySystem, final String stubId) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize"); + return null; + } + try { + final IMediaDrmBridge remoteBridge = mRemote.createRemoteMediaDrmBridge(keySystem, stubId); + mDrmBridges.add(remoteBridge); + return remoteBridge; + } catch (final RemoteException e) { + Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e); + return null; + } + } + + @Override + public void binderDied() { + Log.e(LOGTAG, "remote codec is dead"); + TelemetryUtils.addToHistogram("MEDIA_DECODING_PROCESS_CRASH", 1); + handleRemoteDeath(); + } + + private synchronized void handleRemoteDeath() { + mConnection.waitDisconnect(); + + if (init() && recoverRemoteCodec()) { + notifyError(false); + } else { + notifyError(true); + } + } + + private synchronized void notifyError(final boolean fatal) { + for (final CodecProxy proxy : mCodecs) { + proxy.reportError(fatal); + } + } + + private synchronized boolean recoverRemoteCodec() { + if (DEBUG) Log.d(LOGTAG, "recover codec"); + boolean ok = true; + try { + for (final CodecProxy proxy : mCodecs) { + ok &= proxy.init(mRemote.createCodec()); + } + return ok; + } catch (final RemoteException e) { + return false; + } + } + + public void releaseCodec(final CodecProxy proxy) throws DeadObjectException, RemoteException { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet"); + return; + } + proxy.deinit(); + synchronized (this) { + if (mCodecs.remove(proxy)) { + try { + mRemote.endRequest(); + releaseIfNeeded(); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "fail to report remote codec disconnection"); + } + } + } + } + + private void releaseIfNeeded() { + if (!mCodecs.isEmpty() || !mDrmBridges.isEmpty()) { + return; + } + + if (DEBUG) Log.d(LOGTAG, "release remote manager " + this); + mConnection.unlink(); + final Context appCtxt = GeckoAppShell.getApplicationContext(); + appCtxt.unbindService(mConnection); + } + + public void onRemoteMediaDrmBridgeReleased(final IMediaDrmBridge remote) { + if (!mDrmBridges.contains(remote)) { + Log.e(LOGTAG, "Try to release unknown remote MediaDrm bridge: " + remote); + return; + } + + synchronized (this) { + if (mDrmBridges.remove(remote)) { + try { + mRemote.endRequest(); + releaseIfNeeded(); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "Fail to report remote DRM bridge disconnection"); + } + } + } + } +} // RemoteManager diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java new file mode 100644 index 0000000000..b90f720300 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCrypto; +import android.util.Log; + +final class RemoteMediaDrmBridge implements GeckoMediaDrm { + private static final String LOGTAG = "RemoteMediaDrmBridge"; + private static final boolean DEBUG = false; + private CallbacksForwarder mCallbacksFwd; + private IMediaDrmBridge mRemote; + + // Forward callbacks from remote bridge stub to MediaDrmProxy. + private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub { + private final GeckoMediaDrm.Callbacks mProxyCallbacks; + + CallbacksForwarder(final Callbacks callbacks) { + assertTrue(callbacks != null); + mProxyCallbacks = callbacks; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + mProxyCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + mProxyCallbacks.onSessionUpdated(promiseId, sessionId); + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + mProxyCallbacks.onSessionClosed(promiseId, sessionId); + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + mProxyCallbacks.onSessionError(sessionId, message); + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + mProxyCallbacks.onRejectPromise(promiseId, message); + } + } // CallbacksForwarder + + /* package-private */ static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public RemoteMediaDrmBridge(final IMediaDrmBridge remoteBridge) { + assertTrue(remoteBridge != null); + mRemote = remoteBridge; + } + + @Override + public synchronized void setCallbacks(final Callbacks callbacks) { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(callbacks != null); + assertTrue(mRemote != null); + + mCallbacksFwd = new CallbacksForwarder(callbacks); + try { + mRemote.setCallbacks(mCallbacksFwd); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception during setCallbacks", e); + } + } + + @Override + public synchronized void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + + try { + mRemote.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while creating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session."); + } + } + + @Override + public synchronized void updateSession( + final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + + try { + mRemote.updateSession(promiseId, sessionId, response); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while updating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session."); + } + } + + @Override + public synchronized void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + + try { + mRemote.closeSession(promiseId, sessionId); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while closing remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session."); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + + try { + mRemote.release(); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e); + } + RemoteManager.getInstance().onRemoteMediaDrmBridgeReleased(mRemote); + mRemote = null; + mCallbacksFwd = null; + } + + @Override + public synchronized MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!"); + assertTrue(false); + return null; + } + + @Override + public synchronized void setServerCertificate(final byte[] cert) { + try { + mRemote.setServerCertificate(cert); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while setting server certificate.", e); + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java new file mode 100644 index 0000000000..f466529388 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCrypto; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import java.util.ArrayList; + +final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub + implements IBinder.DeathRecipient { + private static final String LOGTAG = "RemoteDrmBridgeStub"; + private static final boolean DEBUG = false; + private volatile IMediaDrmBridgeCallbacks mCallbacks = null; + + // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21. + private GeckoMediaDrm mBridge = null; + + // mStubId is initialized during stub construction. It should be a unique + // string which is generated in MediaDrmProxy in Fennec App process and is + // used for Codec to obtain corresponding MediaCrypto as input to achieve + // decryption. + // The generated stubId will be delivered to Codec via a code path starting + // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec. + private String mStubId = ""; + + public static final ArrayList mBridgeStubs = + new ArrayList(); + + private String getId() { + return mStubId; + } + + private MediaCrypto getMediaCryptoFromBridge() { + return mBridge != null ? mBridge.getMediaCrypto() : null; + } + + public static synchronized MediaCrypto getMediaCrypto(final String stubId) { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + + for (int i = 0; i < mBridgeStubs.size(); i++) { + if (mBridgeStubs.get(i) != null && mBridgeStubs.get(i).getId().equals(stubId)) { + return mBridgeStubs.get(i).getMediaCryptoFromBridge(); + } + } + return null; + } + + // Callback to RemoteMediaDrmBridge. + private final class Callbacks implements GeckoMediaDrm.Callbacks { + private IMediaDrmBridgeCallbacks mRemoteCallbacks; + + public Callbacks(final IMediaDrmBridgeCallbacks remote) { + mRemoteCallbacks = remote; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionCreated()"); + try { + mRemoteCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()"); + try { + mRemoteCallbacks.onSessionUpdated(promiseId, sessionId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionClosed()"); + try { + mRemoteCallbacks.onSessionClosed(promiseId, sessionId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionMessage()"); + try { + mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + if (DEBUG) Log.d(LOGTAG, "onSessionError()"); + try { + mRemoteCallbacks.onSessionError(sessionId, message); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()"); + try { + mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + if (DEBUG) Log.d(LOGTAG, "onRejectPromise()"); + try { + mRemoteCallbacks.onRejectPromise(promiseId, message); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + } + + /* package-private */ void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + RemoteMediaDrmBridgeStub(final String keySystem, final String stubId) throws RemoteException { + if (Build.VERSION.SDK_INT < 21) { + Log.e(LOGTAG, "Pre-Lollipop should never enter here!!"); + throw new RemoteException("Error, unsupported version!"); + } + try { + if (Build.VERSION.SDK_INT < 23) { + mBridge = new GeckoMediaDrmBridgeV21(keySystem); + } else { + mBridge = new GeckoMediaDrmBridgeV23(keySystem); + } + mStubId = stubId; + mBridgeStubs.add(this); + } catch (final Exception e) { + throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation."); + } + } + + @Override + public synchronized void setCallbacks(final IMediaDrmBridgeCallbacks callbacks) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(mBridge != null); + assertTrue(callbacks != null); + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + mBridge.setCallbacks(new Callbacks(mCallbacks)); + } + + @Override + public synchronized void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to createSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to createSession."); + } + } + + @Override + public synchronized void updateSession( + final int promiseId, final String sessionId, final byte[] response) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.updateSession(promiseId, sessionId, response); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to updateSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to updateSession."); + } + } + + @Override + public synchronized void closeSession(final int promiseId, final String sessionId) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.closeSession(promiseId, sessionId); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to closeSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to closeSession."); + } + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Binder died !!"); + try { + release(); + } catch (final Exception e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + mBridgeStubs.remove(this); + if (mBridge != null) { + mBridge.release(); + mBridge = null; + } + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + mStubId = ""; + } + + @Override + public synchronized void setServerCertificate(final byte[] cert) { + try { + mBridge.setServerCertificate(cert); + } catch (final IllegalStateException e) { + Log.e(LOGTAG, "Failed to setServerCertificate.", e); + throw e; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java new file mode 100644 index 0000000000..baa6737427 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java @@ -0,0 +1,291 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.SuppressLint; +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Build; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.ChecksSdkIntAtLeast; +import java.lang.reflect.Field; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; + +// Parcelable carrying input/output sample data and info cross process. +public final class Sample implements Parcelable { + public static final Sample EOS; + + static { + final BufferInfo eosInfo = new BufferInfo(); + EOS = new Sample(); + EOS.info.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + + @WrapForJNI public long session; + + public static final int NO_BUFFER = -1; + + public int bufferId = NO_BUFFER; + @WrapForJNI public BufferInfo info = new BufferInfo(); + public CryptoInfo cryptoInfo; + + // Simple Linked list for recycling objects. + // Used to nodify Sample objects. Do not marshal/unmarshal. + private Sample mNext; + private static Sample sPool = new Sample(); + private static int sPoolSize = 1; + + private Sample() {} + + private void readInfo(final Parcel in) { + final int offset = in.readInt(); + final int size = in.readInt(); + final long pts = in.readLong(); + final int flags = in.readInt(); + + info.set(offset, size, pts, flags); + } + + private void readCrypto(final Parcel in) { + final int hasCryptoInfo = in.readInt(); + if (hasCryptoInfo == 0) { + cryptoInfo = null; + return; + } + + final byte[] iv = in.createByteArray(); + final byte[] key = in.createByteArray(); + final int mode = in.readInt(); + final int[] numBytesOfClearData = in.createIntArray(); + final int[] numBytesOfEncryptedData = in.createIntArray(); + final int numSubSamples = in.readInt(); + + if (cryptoInfo == null) { + cryptoInfo = new CryptoInfo(); + } + cryptoInfo.set(numSubSamples, numBytesOfClearData, numBytesOfEncryptedData, key, iv, mode); + if (supportsCryptoPattern()) { + final int numEncryptBlocks = in.readInt(); + final int numSkipBlocks = in.readInt(); + cryptoInfo.setPattern(new CryptoInfo.Pattern(numEncryptBlocks, numSkipBlocks)); + } + } + + 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); + if (supportsCryptoPattern()) { + final CryptoInfo.Pattern pattern = getCryptoPatternCompat(crypto); + if (pattern == null) { + return; + } + cryptoInfo.setPattern(pattern); + } + } + + @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 CREATOR = + new Creator() { + @Override + public Sample createFromParcel(final Parcel in) { + return obtainSample(in); + } + + @Override + public Sample[] newArray(final int size) { + return new Sample[size]; + } + + private Sample obtainSample(final Parcel in) { + final Sample s = obtain(); + s.session = in.readLong(); + s.bufferId = in.readInt(); + s.readInfo(in); + s.readCrypto(in); + return s; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int parcelableFlags) { + dest.writeLong(session); + dest.writeInt(bufferId); + writeInfo(dest); + writeCrypto(dest); + } + + private void writeInfo(final Parcel dest) { + dest.writeInt(info.offset); + dest.writeInt(info.size); + dest.writeLong(info.presentationTimeUs); + dest.writeInt(info.flags); + } + + private void writeCrypto(final Parcel dest) { + if (cryptoInfo != null) { + dest.writeInt(1); + dest.writeByteArray(cryptoInfo.iv); + dest.writeByteArray(cryptoInfo.key); + dest.writeInt(cryptoInfo.mode); + dest.writeIntArray(cryptoInfo.numBytesOfClearData); + dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData); + dest.writeInt(cryptoInfo.numSubSamples); + if (supportsCryptoPattern()) { + final CryptoInfo.Pattern pattern = getCryptoPatternCompat(cryptoInfo); + if (pattern != null) { + dest.writeInt(pattern.getEncryptBlocks()); + dest.writeInt(pattern.getSkipBlocks()); + } else { + // Couldn't get pattern - write default values + dest.writeInt(0); + dest.writeInt(0); + } + } + } else { + dest.writeInt(0); + } + } + + public static byte[] byteArrayFromBuffer( + final ByteBuffer buffer, final int offset, final int size) { + if (buffer == null || buffer.capacity() == 0 || size == 0) { + return null; + } + if (buffer.hasArray() && offset == 0 && buffer.array().length == size) { + return buffer.array(); + } + final int length = Math.min(offset + size, buffer.capacity()) - offset; + final byte[] bytes = new byte[length]; + buffer.position(offset); + buffer.get(bytes); + return bytes; + } + + @Override + public String toString() { + if (isEOS()) { + return "EOS sample"; + } + + final StringBuilder str = new StringBuilder(); + str.append("{ session#:") + .append(session) + .append(", buffer#") + .append(bufferId) + .append(", info=") + .append("{ offset=") + .append(info.offset) + .append(", size=") + .append(info.size) + .append(", pts=") + .append(info.presentationTimeUs) + .append(", flags=") + .append(Integer.toHexString(info.flags)) + .append(" }") + .append(" }"); + return str.toString(); + } + + @ChecksSdkIntAtLeast(api = android.os.Build.VERSION_CODES.N) + public static boolean supportsCryptoPattern() { + return Build.VERSION.SDK_INT >= 24; + } + + @SuppressLint("DiscouragedPrivateApi") + public static CryptoInfo.Pattern getCryptoPatternCompat(final CryptoInfo cryptoInfo) { + if (!supportsCryptoPattern()) { + return null; + } + // getPattern() added in API 31: + // https://developer.android.com/reference/android/media/MediaCodec.CryptoInfo#getPattern() + if (Build.VERSION.SDK_INT >= 31) { + return cryptoInfo.getPattern(); + } + + // CryptoInfo.Pattern added in API 24: + // https://developer.android.com/reference/android/media/MediaCodec.CryptoInfo.Pattern + if (Build.VERSION.SDK_INT >= 24) { + try { + // Without getPattern(), no way to access the pattern without reflection. + // https://cs.android.com/android/platform/superproject/+/android-11.0.0_r1:frameworks/base/media/java/android/media/MediaCodec.java;l=2718;drc=3c715d5778e15dc84082e63dc65b382d31fe8e45 + final Field patternField = CryptoInfo.class.getDeclaredField("pattern"); + patternField.setAccessible(true); + return (CryptoInfo.Pattern) patternField.get(cryptoInfo); + } catch (final NoSuchFieldException | IllegalAccessException e) { + return null; + } + } + return null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java new file mode 100644 index 0000000000..e6b242708d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.os.Parcel; +import android.os.Parcelable; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.SharedMemory; + +public final class SampleBuffer implements Parcelable { + private SharedMemory mSharedMem; + + /* package */ + public SampleBuffer(final SharedMemory sharedMem) { + mSharedMem = sharedMem; + } + + protected SampleBuffer(final Parcel in) { + mSharedMem = in.readParcelable(SampleBuffer.class.getClassLoader()); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeParcelable(mSharedMem, flags); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SampleBuffer createFromParcel(final Parcel in) { + return new SampleBuffer(in); + } + + @Override + public SampleBuffer[] newArray(final int size) { + return new SampleBuffer[size]; + } + }; + + public int capacity() { + return mSharedMem != null ? mSharedMem.getSize() : 0; + } + + public void readFromByteBuffer(final ByteBuffer src, final int offset, final int size) + throws IOException { + if (!src.isDirect()) { + throw new IOException("SharedMemBuffer only support reading from direct byte buffer."); + } + try { + nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size); + mSharedMem.flush(); + } catch (final NullPointerException e) { + throw new IOException(e); + } + } + + private static native void nativeReadFromDirectBuffer( + ByteBuffer src, long dest, int offset, int size); + + @WrapForJNI + public void writeToByteBuffer(final ByteBuffer dest, final int offset, final int size) + throws IOException { + if (!dest.isDirect()) { + throw new IOException("SharedMemBuffer only support writing to direct byte buffer."); + } + try { + nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size); + } catch (final NullPointerException e) { + throw new IOException(e); + } + } + + private static native void nativeWriteToDirectBuffer( + long src, ByteBuffer dest, int offset, int size); + + public void dispose() { + if (mSharedMem != null) { + mSharedMem.dispose(); + mSharedMem = null; + } + } + + @WrapForJNI + public boolean isValid() { + return mSharedMem != null; + } + + @Override + public String toString() { + return "Buffer: " + mSharedMem; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java new file mode 100644 index 0000000000..a2101b3aeb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.util.SparseArray; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.mozglue.SharedMemory; + +final class SamplePool { + private static final class Impl { + private final String mName; + private int mDefaultBufferSize = 4096; + private final List mRecycledSamples = new ArrayList<>(); + private final boolean mBufferless; + + private int mNextBufferId = Sample.NO_BUFFER + 1; + private SparseArray mBuffers = new SparseArray<>(); + + private Impl(final String name, final boolean bufferless) { + mName = name; + mBufferless = bufferless; + } + + private void setDefaultBufferSize(final int size) { + if (mBufferless) { + throw new IllegalStateException("Setting buffer size of a bufferless pool is not allowed"); + } + mDefaultBufferSize = size; + } + + private synchronized Sample obtain(final int size) { + if (!mRecycledSamples.isEmpty()) { + return mRecycledSamples.remove(0); + } + + if (mBufferless) { + return Sample.obtain(); + } else { + return allocateSampleAndBuffer(size); + } + } + + private Sample allocateSampleAndBuffer(final int size) { + final int id = mNextBufferId++; + try { + final SharedMemory shm = new SharedMemory(id, Math.max(size, mDefaultBufferSize)); + mBuffers.put((Integer) id, new SampleBuffer(shm)); + final Sample s = Sample.obtain(); + s.bufferId = id; + return s; + } catch (final NoSuchMethodException | IOException e) { + mBuffers.delete(id); + throw new UnsupportedOperationException(e); + } + } + + private synchronized SampleBuffer getBuffer(final int id) { + return mBuffers.get(id); + } + + private synchronized void recycle(final Sample recycled) { + if (mBufferless || isUsefulSample(recycled)) { + mRecycledSamples.add(recycled); + } else { + disposeSample(recycled); + } + } + + private boolean isUsefulSample(final Sample sample) { + return mBuffers.get(sample.bufferId).capacity() >= mDefaultBufferSize; + } + + private synchronized void clear() { + for (final Sample s : mRecycledSamples) { + disposeSample(s); + } + mRecycledSamples.clear(); + + for (int i = 0; i < mBuffers.size(); ++i) { + mBuffers.valueAt(i).dispose(); + } + mBuffers.clear(); + } + + private void disposeSample(final Sample sample) { + if (sample.bufferId != Sample.NO_BUFFER) { + mBuffers.get(sample.bufferId).dispose(); + mBuffers.delete(sample.bufferId); + } + sample.dispose(); + } + + @Override + protected void finalize() { + clear(); + } + } + + private final Impl mInputs; + private final Impl mOutputs; + + /* package */ SamplePool(final String name, final boolean renderToSurface) { + mInputs = new Impl(name + " input sample pool", false); + // Buffers are useless when rendering to surface. + mOutputs = new Impl(name + " output sample pool", renderToSurface); + } + + /* package */ void setInputBufferSize(final int size) { + mInputs.setDefaultBufferSize(size); + } + + /* package */ void setOutputBufferSize(final int size) { + mOutputs.setDefaultBufferSize(size); + } + + /* package */ Sample obtainInput(final int size) { + final Sample input = mInputs.obtain(size); + input.info.set(0, 0, 0, 0); + return input; + } + + /* package */ Sample obtainOutput(final MediaCodec.BufferInfo info) { + final Sample output = mOutputs.obtain(info.size); + output.info.set(0, info.size, info.presentationTimeUs, info.flags); + return output; + } + + /* package */ void recycleInput(final Sample sample) { + sample.cryptoInfo = null; + mInputs.recycle(sample); + } + + /* package */ void recycleOutput(final Sample sample) { + mOutputs.recycle(sample); + } + + /* package */ void reset() { + mInputs.clear(); + mOutputs.clear(); + } + + /* package */ SampleBuffer getInputBuffer(final int id) { + return mInputs.getBuffer(id); + } + + /* package */ SampleBuffer getOutputBuffer(final int id) { + return mOutputs.getBuffer(id); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java new file mode 100644 index 0000000000..5e70a6f2a7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class SessionKeyInfo implements Parcelable { + @WrapForJNI public byte[] keyId; + + @WrapForJNI public int status; + + @WrapForJNI + public SessionKeyInfo(final byte[] keyId, final int status) { + this.keyId = keyId; + this.status = status; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int parcelableFlags) { + dest.writeByteArray(keyId); + dest.writeInt(status); + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SessionKeyInfo createFromParcel(final Parcel in) { + return new SessionKeyInfo(in); + } + + @Override + public SessionKeyInfo[] newArray(final int size) { + return new SessionKeyInfo[size]; + } + }; + + private SessionKeyInfo(final Parcel src) { + keyId = src.createByteArray(); + status = src.readInt(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java new file mode 100644 index 0000000000..5cc32e127c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; + +public class Utils { + public static long getThreadId() { + final Thread t = Thread.currentThread(); + return t.getId(); + } + + public static String getThreadSignature() { + final Thread t = Thread.currentThread(); + final long l = t.getId(); + final String name = t.getName(); + final long p = t.getPriority(); + final String gname = t.getThreadGroup().getName(); + return (name + ":(id)" + l + ":(priority)" + p + ":(group)" + gname); + } + + public static void logThreadSignature() { + Log.d("ThreadUtils", getThreadSignature()); + } + + private static final char[] hexArray = "0123456789ABCDEF".toCharArray(); + + public static String bytesToHex(final byte[] bytes) { + final char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + final int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java new file mode 100644 index 0000000000..701780171e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java @@ -0,0 +1,440 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.mozglue; + +import android.content.Context; +import android.os.Build; +import android.os.Environment; +import android.util.Log; +import dalvik.system.BaseDexClassLoader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.RobocopTarget; + +public final class GeckoLoader { + private static final String LOGTAG = "GeckoLoader"; + + private static File sGREDir; + + /* Synchronized on GeckoLoader.class. */ + private static boolean sSQLiteLibsLoaded; + private static boolean sNSSLibsLoaded; + private static boolean sMozGlueLoaded; + + private GeckoLoader() { + // prevent instantiation + } + + public static File getGREDir(final Context context) { + if (sGREDir == null) { + sGREDir = new File(context.getApplicationInfo().dataDir); + } + return sGREDir; + } + + private static void setupDownloadEnvironment(final Context context) { + try { + File downloadDir = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File updatesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + if (downloadDir == null) { + downloadDir = new File(Environment.getExternalStorageDirectory().getPath(), "download"); + } + if (updatesDir == null) { + updatesDir = downloadDir; + } + putenv("DOWNLOADS_DIRECTORY=" + downloadDir.getPath()); + putenv("UPDATES_DIRECTORY=" + updatesDir.getPath()); + } catch (final Exception e) { + Log.w(LOGTAG, "No download directory found.", e); + } + } + + private static void delTree(final File file) { + if (file.isDirectory()) { + final File[] children = file.listFiles(); + for (final File child : children) { + delTree(child); + } + } + file.delete(); + } + + private static File getTmpDir(final Context context) { + // It's important that this folder is in the cache directory so users can actually + // clear it when it gets too big. + return new File(context.getCacheDir(), "gecko_temp"); + } + + private static String escapeDoubleQuotes(final String str) { + return str.replaceAll("\"", "\\\""); + } + + private static void setupInitialPrefs(final Map prefs) { + if (prefs != null) { + final StringBuilder prefsEnv = new StringBuilder("MOZ_DEFAULT_PREFS="); + for (final String key : prefs.keySet()) { + final Object value = prefs.get(key); + if (value == null) { + continue; + } + prefsEnv.append(String.format("pref(\"%s\",", escapeDoubleQuotes(key))); + if (value instanceof String) { + prefsEnv.append(String.format("\"%s\"", escapeDoubleQuotes(value.toString()))); + } else if (value instanceof Boolean) { + prefsEnv.append((Boolean) value ? "true" : "false"); + } else { + prefsEnv.append(value.toString()); + } + + prefsEnv.append(");\n"); + } + + putenv(prefsEnv.toString()); + } + } + + @SuppressWarnings("deprecation") // for Build.CPU_ABI + public static synchronized void setupGeckoEnvironment( + final Context context, + final boolean isChildProcess, + final String profilePath, + final Collection env, + final Map prefs, + final boolean xpcshell) { + for (final String e : env) { + putenv(e); + } + + putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName()); + + if (!isChildProcess) { + setupDownloadEnvironment(context); + + // profile home path + putenv("HOME=" + profilePath); + + // setup the downloads path + File f = Environment.getDownloadCacheDirectory(); + putenv("EXTERNAL_STORAGE=" + f.getPath()); + + // setup the app-specific cache path + f = context.getCacheDir(); + putenv("CACHE_DIRECTORY=" + f.getPath()); + + f = context.getExternalFilesDir(null); + if (f != null) { + putenv("PUBLIC_STORAGE=" + f.getPath()); + } + + if (Build.VERSION.SDK_INT >= 17) { + final android.os.UserManager um = + (android.os.UserManager) context.getSystemService(Context.USER_SERVICE); + if (um != null) { + putenv( + "MOZ_ANDROID_USER_SERIAL_NUMBER=" + + um.getSerialNumberForUser(android.os.Process.myUserHandle())); + } else { + Log.d( + LOGTAG, + "Unable to obtain user manager service on a device with SDK version " + + Build.VERSION.SDK_INT); + } + } + + setupInitialPrefs(prefs); + } + + // Xpcshell tests set up their own temp directory + if (!xpcshell) { + // setup the tmp path + final File f = getTmpDir(context); + if (!f.exists()) { + f.mkdirs(); + } + putenv("TMPDIR=" + f.getPath()); + } + + putenv("LANG=" + Locale.getDefault().toString()); + + final Class crashHandler = GeckoAppShell.getCrashHandlerService(); + if (crashHandler != null) { + putenv( + "MOZ_ANDROID_CRASH_HANDLER=" + context.getPackageName() + "/" + crashHandler.getName()); + } + + putenv("MOZ_ANDROID_DEVICE_SDK_VERSION=" + Build.VERSION.SDK_INT); + putenv("MOZ_ANDROID_CPU_ABI=" + Build.CPU_ABI); + + // env from extras could have reset out linker flags; set them again. + loadLibsSetupLocked(context); + } + + // Adapted from + // https://source.chromium.org/chromium/chromium/src/+/main:base/android/java/src/org/chromium/base/BundleUtils.java;l=196;drc=c0fedddd4a1444653235912cfae3d44b544ded01 + private static String getLibraryPath(final String libraryName) { + // Due to b/171269960 isolated split class loaders have an empty library path, so check + // the base module class loader first which loaded GeckoAppShell. If the library is not + // found there, attempt to construct the correct library path from the split. + String path = + ((BaseDexClassLoader) GeckoAppShell.class.getClassLoader()).findLibrary(libraryName); + if (path != null) { + return path; + } + + // SplitCompat is installed on the application context, so check there for library paths + // which were added to that ClassLoader. + final ClassLoader classLoader = GeckoAppShell.getApplicationContext().getClassLoader(); + if (classLoader instanceof BaseDexClassLoader) { + path = ((BaseDexClassLoader) classLoader).findLibrary(libraryName); + if (path != null) { + return path; + } + } + + throw new RuntimeException("Could not find mozglue path."); + } + + private static String getLibraryBase() { + final String mozglue = getLibraryPath("mozglue"); + final int lastSlash = mozglue.lastIndexOf('/'); + if (lastSlash < 0) { + throw new IllegalStateException("Invalid library path for libmozglue.so: " + mozglue); + } + final String base = mozglue.substring(0, lastSlash); + Log.i(LOGTAG, "Library base=" + base); + return base; + } + + private static void loadLibsSetupLocked(final Context context) { + putenv("GRE_HOME=" + getGREDir(context).getPath()); + putenv("MOZ_ANDROID_LIBDIR=" + getLibraryBase()); + } + + @RobocopTarget + public static synchronized void loadSQLiteLibs(final Context context) { + if (sSQLiteLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadSQLiteLibsNative(); + sSQLiteLibsLoaded = true; + } + + public static synchronized void loadNSSLibs(final Context context) { + if (sNSSLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadNSSLibsNative(); + sNSSLibsLoaded = true; + } + + @SuppressWarnings("deprecation") + private static String getCPUABI() { + return android.os.Build.CPU_ABI; + } + + /** + * Copy a library out of our APK. + * + * @param context a Context. + * @param lib the name of the library; e.g., "mozglue". + * @param outDir the output directory for the .so. No trailing slash. + * @return true on success, false on failure. + */ + private static boolean extractLibrary( + final Context context, final String lib, final String outDir) { + final String apkPath = context.getApplicationInfo().sourceDir; + + // Sanity check. + if (!apkPath.endsWith(".apk")) { + Log.w(LOGTAG, "sourceDir is not an APK."); + return false; + } + + // Try to extract the named library from the APK. + final File outDirFile = new File(outDir); + if (!outDirFile.isDirectory()) { + if (!outDirFile.mkdirs()) { + Log.e(LOGTAG, "Couldn't create " + outDir); + return false; + } + } + + if (Build.VERSION.SDK_INT >= 21) { + final String[] abis = Build.SUPPORTED_ABIS; + for (final String abi : abis) { + if (tryLoadWithABI(lib, outDir, apkPath, abi)) { + return true; + } + } + return false; + } else { + final String abi = getCPUABI(); + return tryLoadWithABI(lib, outDir, apkPath, abi); + } + } + + private static boolean tryLoadWithABI( + final String lib, final String outDir, final String apkPath, final String abi) { + try { + final ZipFile zipFile = new ZipFile(new File(apkPath)); + try { + final String libPath = "lib/" + abi + "/lib" + lib + ".so"; + final ZipEntry entry = zipFile.getEntry(libPath); + if (entry == null) { + Log.w(LOGTAG, libPath + " not found in APK " + apkPath); + return false; + } + + final InputStream in = zipFile.getInputStream(entry); + try { + final String outPath = outDir + "/lib" + lib + ".so"; + final FileOutputStream out = new FileOutputStream(outPath); + final byte[] bytes = new byte[1024]; + int read; + + Log.d(LOGTAG, "Copying " + libPath + " to " + outPath); + boolean failed = false; + try { + while ((read = in.read(bytes, 0, 1024)) != -1) { + out.write(bytes, 0, read); + } + } catch (final Exception e) { + Log.w(LOGTAG, "Failing library copy.", e); + failed = true; + } finally { + out.close(); + } + + if (failed) { + // Delete the partial copy so we don't fail to load it. + // Don't bother to check the return value -- there's nothing + // we can do about a failure. + new File(outPath).delete(); + } else { + // Mark the file as executable. This doesn't seem to be + // necessary for the loader, but it's the normal state of + // affairs. + Log.d(LOGTAG, "Marking " + outPath + " as executable."); + new File(outPath).setExecutable(true); + } + + return !failed; + } finally { + in.close(); + } + } finally { + zipFile.close(); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to extract lib from APK.", e); + return false; + } + } + + private static boolean attemptLoad(final String path) { + try { + System.load(path); + return true; + } catch (final Throwable e) { + Log.wtf(LOGTAG, "Couldn't load " + path + ": " + e); + } + + return false; + } + + /** + * The first two attempts at loading a library: directly, and then using the app library path. + * + *

    Returns null or the cause exception. + */ + public static Throwable doLoadLibrary(final Context context, final String lib) { + try { + // Attempt 1: the way that should work. + System.loadLibrary(lib); + return null; + } catch (final Throwable e) { + final String libPath = getLibraryPath(lib); + // Does it even exist? + if (new File(libPath).exists()) { + if (attemptLoad(libPath)) { + // Success! + return null; + } + throw new RuntimeException( + "Library exists but couldn't load." + "Path: " + libPath + " lib: " + lib, e); + } + throw new RuntimeException( + "Library doesn't exist when it should." + "Path: " + libPath + " lib: " + lib, e); + } + } + + public static synchronized void loadMozGlue(final Context context) { + if (sMozGlueLoaded) { + return; + } + + doLoadLibrary(context, "mozglue"); + sMozGlueLoaded = true; + } + + public static synchronized void loadGeckoLibs(final Context context) { + loadLibsSetupLocked(context); + loadGeckoLibsNative(); + } + + @SuppressWarnings("serial") + public static class AbortException extends Exception { + public AbortException(final String msg) { + super(msg); + } + } + + @JNITarget + public static void abort(final String msg) { + final Thread thread = Thread.currentThread(); + final Thread.UncaughtExceptionHandler uncaughtHandler = thread.getUncaughtExceptionHandler(); + if (uncaughtHandler != null) { + uncaughtHandler.uncaughtException(thread, new AbortException(msg)); + } + } + + // These methods are implemented in mozglue/android/nsGeckoUtils.cpp + private static native void putenv(String map); + + // These methods are implemented in mozglue/android/APKOpen.cpp + public static native void nativeRun( + String[] args, + int prefsFd, + int prefMapFd, + int ipcFd, + int crashFd, + int crashAnnotationFd, + boolean xpcshell, + String outFilePath); + + private static native void loadGeckoLibsNative(); + + private static native void loadSQLiteLibsNative(); + + private static native void loadNSSLibsNative(); + + public static native void suppressCrashDialog(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java new file mode 100644 index 0000000000..3b0f8cc96b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.mozglue; + +// Class that all classes with native methods extend from. +public abstract class JNIObject { + // Pointer that references the native object. This is volatile because it may be accessed + // by multiple threads simultaneously. + private volatile long mHandle; + + // Dispose of any reference to a native object. + // + // If the native instance is destroyed from the native side, this should never be + // called, so you should throw an UnsupportedOperationException. If instead you + // want to destroy the native side from the Java end, make override this with + // a native call, and the right thing will be done in the native code. + protected abstract void disposeNative(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java new file mode 100644 index 0000000000..028cfd6590 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java @@ -0,0 +1,12 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.mozglue; + +public interface NativeReference { + public void release(); + + public boolean isReleased(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java new file mode 100644 index 0000000000..af8b62c382 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java @@ -0,0 +1,192 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.annotation.SuppressLint; +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; + +@SuppressLint("DiscouragedPrivateApi") +public class SharedMemory implements Parcelable { + private static final String LOGTAG = "GeckoShmem"; + private static final Method sGetFDMethod; + private ParcelFileDescriptor mDescriptor; + private int mSize; + private int mId; + private long mHandle; // The native pointer. + private boolean mIsMapped; + private MemoryFile mBackedFile; + + // MemoryFile.getFileDescriptor() is hidden. :( + static { + Method method = null; + try { + method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); + } catch (final NoSuchMethodException e) { + e.printStackTrace(); + } + sGetFDMethod = method; + } + + private SharedMemory(final Parcel in) { + mDescriptor = in.readFileDescriptor(); + mSize = in.readInt(); + mId = in.readInt(); + } + + public static final Creator CREATOR = + new Creator() { + @Override + public SharedMemory createFromParcel(final Parcel in) { + return new SharedMemory(in); + } + + @Override + public SharedMemory[] newArray(final int size) { + return new SharedMemory[size]; + } + }; + + @Override + public int describeContents() { + return CONTENTS_FILE_DESCRIPTOR; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + // We don't want ParcelFileDescriptor.writeToParcel() to close the fd. + dest.writeFileDescriptor(mDescriptor.getFileDescriptor()); + dest.writeInt(mSize); + dest.writeInt(mId); + } + + public SharedMemory(final int id, final int size) throws NoSuchMethodException, IOException { + if (sGetFDMethod == null) { + throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist."); + } + mBackedFile = new MemoryFile(null, size); + try { + final FileDescriptor fd = (FileDescriptor) sGetFDMethod.invoke(mBackedFile); + mDescriptor = ParcelFileDescriptor.dup(fd); + mSize = size; + mId = id; + mBackedFile.allowPurging(false); + } catch (final Exception e) { + e.printStackTrace(); + close(); + throw new IOException(e.getMessage()); + } + } + + public void flush() { + if (!mIsMapped) { + return; + } + + unmap(mHandle, mSize); + mHandle = 0; + mIsMapped = false; + } + + public void close() { + flush(); + + if (mDescriptor != null) { + try { + mDescriptor.close(); + } catch (final IOException e) { + e.printStackTrace(); + } + mDescriptor = null; + } + } + + // Should only be called by process that allocates shared memory. + public void dispose() { + if (!isValid()) { + return; + } + + close(); + + if (mBackedFile != null) { + mBackedFile.close(); + mBackedFile = null; + } + } + + private native void unmap(long address, int size); + + public boolean isValid() { + return mDescriptor != null; + } + + public int getSize() { + return mSize; + } + + private int getFD() { + return isValid() ? mDescriptor.getFd() : -1; + } + + public long getPointer() { + if (!isValid()) { + return 0; + } + + if (!mIsMapped) { + try { + mHandle = map(getFD(), mSize); + } catch (final NullPointerException e) { + Log.e(LOGTAG, "SharedMemory#" + mId + " error.", e); + throw e; + } + if (mHandle != 0) { + mIsMapped = true; + } + } + return mHandle; + } + + private native long map(int fd, int size); + + @Override + protected void finalize() throws Throwable { + if (mBackedFile != null) { + Log.w(LOGTAG, "dispose() not called before finalizing"); + } + dispose(); + + super.finalize(); + } + + @Override + public String toString() { + return "SHM(" + + getSize() + + " bytes): id=" + + mId + + ", backing=" + + mBackedFile + + ",fd=" + + mDescriptor; + } + + @Override + public boolean equals(final Object that) { + return (this == that) || ((that instanceof SharedMemory) && (hashCode() == that.hashCode())); + } + + @Override + public int hashCode() { + return mId; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja new file mode 100644 index 0000000000..fa2f336566 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja @@ -0,0 +1,19 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +public class GeckoChildProcessServices { + /* package */ static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = {{MOZ_ANDROID_CONTENT_SERVICE_COUNT}}; + public static final class gmplugin extends GeckoServiceChildProcess {} + public static final class socket extends GeckoServiceChildProcess {} + public static final class gpu extends GeckoServiceGpuProcess {} + public static final class utility extends GeckoServiceChildProcess {} + public static final class ipdlunittest extends GeckoServiceChildProcess {} + +{% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %} + public static final class tab{{ id }} extends GeckoServiceChildProcess {} +{% endfor %} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java new file mode 100644 index 0000000000..039396f9e8 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java @@ -0,0 +1,927 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.collection.ArrayMap; +import androidx.collection.ArraySet; +import androidx.collection.SimpleArrayMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoNetworkManager; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.GeckoThread.FileDescriptors; +import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.TelemetryUtils; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.CompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.gfx.RemoteSurfaceAllocator; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.process.ServiceAllocator.PriorityLevel; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.XPCOMEventTarget; +import org.mozilla.geckoview.GeckoResult; + +public final class GeckoProcessManager extends IProcessManager.Stub { + private static final String LOGTAG = "GeckoProcessManager"; + private static final GeckoProcessManager INSTANCE = new GeckoProcessManager(); + private static final int INVALID_PID = 0; + + // This id univocally identifies the current process manager instance + private final String mInstanceId; + + public static GeckoProcessManager getInstance() { + return INSTANCE; + } + + @WrapForJNI(calledFrom = "gecko") + private static void setEditableChildParent( + final IGeckoEditableChild child, final IGeckoEditableParent parent) { + try { + child.transferParent(parent); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Cannot set parent", e); + } + } + + @WrapForJNI(stubName = "GetEditableParent", dispatchTo = "gecko") + private static native void nativeGetEditableParent( + IGeckoEditableChild child, long contentId, long tabId); + + @Override // IProcessManager + public void getEditableParent( + final IGeckoEditableChild child, final long contentId, final long tabId) { + nativeGetEditableParent(child, contentId, tabId); + } + + /** + * Returns the surface allocator interface to be used by child processes to allocate Surfaces. The + * service bound to the returned interface may live in either the GPU process or parent process. + */ + @Override // IProcessManager + public ISurfaceAllocator getSurfaceAllocator() { + final GeckoResult gpuEnabled = GeckoAppShell.isGpuProcessEnabled(); + + try { + final GeckoResult allocator = new GeckoResult<>(); + if (gpuEnabled.poll(1000)) { + // The GPU process is enabled, so look it up and ask it for its surface allocator. + XPCOMEventTarget.runOnLauncherThread( + () -> { + final Selector selector = new Selector(GeckoProcessType.GPU); + final GpuProcessConnection conn = + (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector); + if (conn != null) { + allocator.complete(conn.getSurfaceAllocator()); + } else { + // If we cannot find a GPU process, it has probably been killed and not yet + // restarted. Return null here, and allow the caller to try again later. + // We definitely do *not* want to return the parent process allocator instead, as + // that will result in surfaces being allocated in the parent process, which + // therefore won't be usable when the GPU process is eventually launched. + allocator.complete(null); + } + }); + } else { + // The GPU process is disabled, so return the parent process allocator instance. + allocator.complete(RemoteSurfaceAllocator.getInstance(0)); + } + return allocator.poll(100); + } catch (final Throwable e) { + Log.e(LOGTAG, "Error in getSurfaceAllocator", e); + return null; + } + } + + @WrapForJNI + public static CompositorSurfaceManager getCompositorSurfaceManager() { + final Selector selector = new Selector(GeckoProcessType.GPU); + final GpuProcessConnection conn = + (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return null; + } + return conn.getCompositorSurfaceManager(); + } + + /** Gecko uses this class to uniquely identify a process managed by GeckoProcessManager. */ + public static final class Selector { + private final GeckoProcessType mType; + private final int mPid; + + @WrapForJNI + private Selector(@NonNull final GeckoProcessType type, final int pid) { + if (pid == INVALID_PID) { + throw new RuntimeException("Invalid PID"); + } + + mType = type; + mPid = pid; + } + + @WrapForJNI + private Selector(@NonNull final GeckoProcessType type) { + mType = type; + mPid = INVALID_PID; + } + + public GeckoProcessType getType() { + return mType; + } + + public int getPid() { + return mPid; + } + + @Override + public boolean equals(final Object obj) { + if (obj == null) { + return false; + } + + if (obj == ((Object) this)) { + return true; + } + + final Selector other = (Selector) obj; + return mType == other.mType && mPid == other.mPid; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {mType, mPid}); + } + } + + private static final class IncompleteChildConnectionException extends RuntimeException { + public IncompleteChildConnectionException(@NonNull final String msg) { + super(msg); + } + } + + /** + * Maintains state pertaining to an individual child process. Inheriting from + * ServiceAllocator.InstanceInfo enables this class to work with ServiceAllocator. + */ + private static class ChildConnection extends ServiceAllocator.InstanceInfo { + private IChildProcess mChild; + private GeckoResult 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 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 bindResult = mPendingBind; + mPendingBind = null; + unbind().accept(v -> bindResult.completeExceptionally(e)); + return bindResult; + } + + public GeckoResult 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 unbind() { + XPCOMEventTarget.assertOnLauncherThread(); + + if (mPendingBind != null) { + // We called unbind() while bind() was still pending completion + return mPendingBind.then(child -> unbind()); + } + + if (mChild == null) { + // Not bound in the first place + return GeckoResult.fromValue(null); + } + + unbindService(); + + return GeckoResult.fromValue(null); + } + + @Override + protected void onBinderConnected(final IBinder service) { + XPCOMEventTarget.assertOnLauncherThread(); + + final IChildProcess child = IChildProcess.Stub.asInterface(service); + try { + mPid = child.getPid(); + onBinderConnected(child); + } catch (final DeadObjectException e) { + unbindService(); + + // mPendingBind might be null if a bind was initiated by the system (eg Service Restart) + if (mPendingBind != null) { + mPendingBind.completeExceptionally(e); + mPendingBind = null; + } + + return; + } catch (final RemoteException e) { + throw new RuntimeException(e); + } + + mChild = child; + GeckoProcessManager.INSTANCE.mConnections.onBindComplete(this); + + // mPendingBind might be null if a bind was initiated by the system (eg Service Restart) + if (mPendingBind != null) { + mPendingBind.complete(mChild); + mPendingBind = null; + } + } + + // Subclasses of ChildConnection can override this method to make any IChildProcess calls + // specific to their process type immediately after connection. + protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {} + + @Override + protected void onReleaseResources() { + XPCOMEventTarget.assertOnLauncherThread(); + + // NB: This must happen *before* resetting mPid! + GeckoProcessManager.INSTANCE.mConnections.removeConnection(this); + + mChild = null; + mPid = INVALID_PID; + } + } + + private static class NonContentConnection extends ChildConnection { + public NonContentConnection( + @NonNull final ServiceAllocator allocator, @NonNull final GeckoProcessType type) { + super(allocator, type, PriorityLevel.FOREGROUND); + if (type == GeckoProcessType.CONTENT) { + throw new AssertionError("Attempt to create a NonContentConnection as CONTENT"); + } + } + + protected void onAppForeground() { + setPriorityLevel(PriorityLevel.FOREGROUND); + } + + protected void onAppBackground() { + setPriorityLevel(PriorityLevel.IDLE); + } + } + + private static final class GpuProcessConnection extends NonContentConnection { + private CompositorSurfaceManager mCompositorSurfaceManager; + private ISurfaceAllocator mSurfaceAllocator; + + // Unique ID used to identify each GPU process instance. Will always be non-zero, + // and unlike the process' pid cannot be the same value for successive instances. + private int mUniqueGpuProcessId; + // Static counter used to initialize each instance's mUniqueGpuProcessId + private static int sUniqueGpuProcessIdCounter = 0; + + public GpuProcessConnection(@NonNull final ServiceAllocator allocator) { + super(allocator, GeckoProcessType.GPU); + + // Initialize the unique ID ensuring we skip 0 (as that is reserved for parent process + // allocators). + if (sUniqueGpuProcessIdCounter == 0) { + sUniqueGpuProcessIdCounter++; + } + mUniqueGpuProcessId = sUniqueGpuProcessIdCounter++; + } + + @Override + protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException { + mCompositorSurfaceManager = new CompositorSurfaceManager(child.getCompositorSurfaceManager()); + mSurfaceAllocator = child.getSurfaceAllocator(mUniqueGpuProcessId); + } + + public CompositorSurfaceManager getCompositorSurfaceManager() { + return mCompositorSurfaceManager; + } + + public ISurfaceAllocator getSurfaceAllocator() { + return mSurfaceAllocator; + } + } + + private static final class SocketProcessConnection extends NonContentConnection { + private boolean mIsForeground = true; + private boolean mIsNetworkUp = true; + + public SocketProcessConnection(@NonNull final ServiceAllocator allocator) { + super(allocator, GeckoProcessType.SOCKET); + GeckoProcessManager.INSTANCE.mConnections.enableNetworkNotifications(); + } + + public void onNetworkStateChange(final boolean isNetworkUp) { + mIsNetworkUp = isNetworkUp; + prioritize(); + } + + @Override + protected void onAppForeground() { + mIsForeground = true; + prioritize(); + } + + @Override + protected void onAppBackground() { + mIsForeground = false; + prioritize(); + } + + private static final PriorityLevel[][] sPriorityStates = initPriorityStates(); + + private static PriorityLevel[][] initPriorityStates() { + final PriorityLevel[][] states = new PriorityLevel[2][2]; + // Background, no network + states[0][0] = PriorityLevel.IDLE; + // Background, network + states[0][1] = PriorityLevel.BACKGROUND; + // Foreground, no network + states[1][0] = PriorityLevel.IDLE; + // Foreground, network + states[1][1] = PriorityLevel.FOREGROUND; + return states; + } + + private void prioritize() { + final PriorityLevel nextPriority = + sPriorityStates[mIsForeground ? 1 : 0][mIsNetworkUp ? 1 : 0]; + setPriorityLevel(nextPriority); + } + } + + private static final class ContentConnection extends ChildConnection { + private static final String TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME = + "GV_CONTENT_PROCESS_LIFETIME_MS"; + + private TelemetryUtils.UptimeTimer mLifetimeTimer = null; + + public ContentConnection( + @NonNull final ServiceAllocator allocator, @NonNull final PriorityLevel initialPriority) { + super(allocator, GeckoProcessType.CONTENT, initialPriority); + } + + @Override + protected void onBinderConnected(final IBinder service) { + mLifetimeTimer = new TelemetryUtils.UptimeTimer(TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME); + super.onBinderConnected(service); + } + + @Override + protected void onReleaseResources() { + if (mLifetimeTimer != null) { + mLifetimeTimer.stop(); + mLifetimeTimer = null; + } + + super.onReleaseResources(); + } + } + + /** This class manages the state surrounding existing connections and their priorities. */ + private static final class ConnectionManager extends JNIObject { + // Connections to non-content processes + private final ArrayMap mNonContentConnections; + // Mapping of pid to content process + private final SimpleArrayMap mContentPids; + // Set of initialized content process connections + private final ArraySet mContentConnections; + // Set of bound but uninitialized content connections + private final ArraySet mNonStartedContentConnections; + // Allocator for service IDs + private final ServiceAllocator mServiceAllocator; + private boolean mIsObservingNetwork = false; + + public ConnectionManager() { + mNonContentConnections = new ArrayMap(); + mContentPids = new SimpleArrayMap(); + mContentConnections = new ArraySet(); + mNonStartedContentConnections = new ArraySet(); + mServiceAllocator = new ServiceAllocator(); + + // Attach to native once JNI is ready. + if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) { + attachTo(this); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.JNI_READY, ConnectionManager.class, "attachTo", this); + } + } + + private void enableNetworkNotifications() { + if (mIsObservingNetwork) { + return; + } + + mIsObservingNetwork = true; + + // Ensure that GeckoNetworkManager is monitoring network events so that we can + // prioritize the socket process. + ThreadUtils.runOnUiThread( + () -> { + GeckoNetworkManager.getInstance().enableNotifications(); + }); + + observeNetworkNotifications(); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void attachTo(ConnectionManager instance); + + @WrapForJNI(dispatchTo = "gecko") + private native void observeNetworkNotifications(); + + @WrapForJNI(calledFrom = "gecko") + private void onBackground() { + XPCOMEventTarget.runOnLauncherThread(() -> onAppBackgroundInternal()); + } + + @WrapForJNI(calledFrom = "gecko") + private void onForeground() { + XPCOMEventTarget.runOnLauncherThread(() -> onAppForegroundInternal()); + } + + @WrapForJNI(calledFrom = "gecko") + private void onNetworkStateChange(final boolean isUp) { + XPCOMEventTarget.runOnLauncherThread(() -> onNetworkStateChangeInternal(isUp)); + } + + @Override + protected native void disposeNative(); + + private void onAppBackgroundInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + + for (final NonContentConnection conn : mNonContentConnections.values()) { + conn.onAppBackground(); + } + } + + private void onAppForegroundInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + + for (final NonContentConnection conn : mNonContentConnections.values()) { + conn.onAppForeground(); + } + } + + private void onNetworkStateChangeInternal(final boolean isUp) { + XPCOMEventTarget.assertOnLauncherThread(); + + final SocketProcessConnection conn = + (SocketProcessConnection) mNonContentConnections.get(GeckoProcessType.SOCKET); + if (conn == null) { + return; + } + + conn.onNetworkStateChange(isUp); + } + + private void removeContentConnection(@NonNull final ChildConnection conn) { + if (!mContentConnections.remove(conn)) { + throw new RuntimeException("Attempt to remove non-registered connection"); + } + mNonStartedContentConnections.remove(conn); + + final int pid; + + try { + pid = conn.getPid(); + } catch (final IncompleteChildConnectionException e) { + // conn lost its binding before it was able to retrieve its pid. It follows that + // mContentPids does not have an entry for this connection, so we can just return. + return; + } + + if (pid == INVALID_PID) { + return; + } + + final ChildConnection removed = mContentPids.remove(Integer.valueOf(pid)); + if (removed != null && removed != conn) { + throw new RuntimeException( + "Integrity error - connection mismatch for pid " + Integer.toString(pid)); + } + } + + public void removeConnection(@NonNull final ChildConnection conn) { + XPCOMEventTarget.assertOnLauncherThread(); + + if (conn.getType() == GeckoProcessType.CONTENT) { + removeContentConnection(conn); + return; + } + + final ChildConnection removed = mNonContentConnections.remove(conn.getType()); + if (removed != conn) { + throw new RuntimeException( + "Integrity error - connection mismatch for process type " + conn.getType().toString()); + } + } + + /** Saves any state information that was acquired upon start completion. */ + public void onBindComplete(@NonNull final ChildConnection conn) { + if (conn.getType() == GeckoProcessType.CONTENT) { + final int pid = conn.getPid(); + if (pid == INVALID_PID) { + throw new AssertionError( + "PID is invalid even though our caller just successfully retrieved it after binding"); + } + + mContentPids.put(pid, (ContentConnection) conn); + } + } + + /** Retrieve the ChildConnection for an already running content process. */ + private ContentConnection getExistingContentConnection(@NonNull final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + if (selector.getType() != GeckoProcessType.CONTENT) { + throw new IllegalArgumentException("Selector is not for content!"); + } + + return mContentPids.get(selector.getPid()); + } + + /** Unconditionally create a new content connection for the specified priority. */ + private ContentConnection getNewContentConnection(@NonNull final PriorityLevel newPriority) { + final ContentConnection result = new ContentConnection(mServiceAllocator, newPriority); + mContentConnections.add(result); + + return result; + } + + /** Retrieve the ChildConnection for an already running child process of any type. */ + public ChildConnection getExistingConnection(@NonNull final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + + final GeckoProcessType type = selector.getType(); + + if (type == GeckoProcessType.CONTENT) { + return getExistingContentConnection(selector); + } + + return mNonContentConnections.get(type); + } + + /** + * Retrieve a ChildConnection for a content process for the purposes of starting. If there are + * any preloaded content processes already running, we will use one of those. Otherwise we will + * allocate a new ChildConnection. + */ + private ChildConnection getContentConnectionForStart() { + XPCOMEventTarget.assertOnLauncherThread(); + + if (mNonStartedContentConnections.isEmpty()) { + return getNewContentConnection(PriorityLevel.FOREGROUND); + } + + final ChildConnection conn = + mNonStartedContentConnections.removeAt(mNonStartedContentConnections.size() - 1); + conn.setPriorityLevel(PriorityLevel.FOREGROUND); + return conn; + } + + /** Retrieve or create a new child process for the specified non-content process. */ + private ChildConnection getNonContentConnection(@NonNull final GeckoProcessType type) { + XPCOMEventTarget.assertOnLauncherThread(); + if (type == GeckoProcessType.CONTENT) { + throw new IllegalArgumentException("Content processes not supported by this method"); + } + + NonContentConnection connection = mNonContentConnections.get(type); + if (connection == null) { + if (type == GeckoProcessType.SOCKET) { + connection = new SocketProcessConnection(mServiceAllocator); + } else if (type == GeckoProcessType.GPU) { + connection = new GpuProcessConnection(mServiceAllocator); + } else { + connection = new NonContentConnection(mServiceAllocator, type); + } + + mNonContentConnections.put(type, connection); + } + + return connection; + } + + /** Retrieve a ChildConnection for the purposes of starting a new child process. */ + public ChildConnection getConnectionForStart(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + return getContentConnectionForStart(); + } + + return getNonContentConnection(type); + } + + /** Retrieve a ChildConnection for the purposes of preloading a new child process. */ + public ChildConnection getConnectionForPreload(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + final ContentConnection conn = getNewContentConnection(PriorityLevel.BACKGROUND); + mNonStartedContentConnections.add(conn); + return conn; + } + + return getNonContentConnection(type); + } + } + + private final ConnectionManager mConnections; + + private GeckoProcessManager() { + mConnections = new ConnectionManager(); + mInstanceId = UUID.randomUUID().toString(); + } + + public void preload(final GeckoProcessType... types) { + XPCOMEventTarget.launcherThread() + .execute( + () -> { + for (final GeckoProcessType type : types) { + final ChildConnection connection = mConnections.getConnectionForPreload(type); + connection.bind(); + } + }); + } + + public void crashChild(@NonNull final Selector selector) { + XPCOMEventTarget.launcherThread() + .execute( + () -> { + final ChildConnection conn = mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.bind() + .accept( + proc -> { + try { + proc.crash(); + } catch (final RemoteException e) { + } + }); + }); + } + + @WrapForJNI + private static void shutdownProcess(final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.unbind(); + } + + @WrapForJNI + private static void setProcessPriority( + @NonNull final Selector selector, + @NonNull final PriorityLevel priorityLevel, + final int relativeImportance) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.setPriorityLevel(priorityLevel, relativeImportance); + }); + } + + @WrapForJNI + private static GeckoResult start( + final GeckoProcessType type, + final String[] args, + final int prefsFd, + final int prefMapFd, + final int ipcFd, + final int crashFd, + final int crashAnnotationFd) { + final GeckoResult result = new GeckoResult<>(); + final StartInfo info = + new StartInfo( + type, + GeckoThread.InitInfo.builder() + .args(args) + .userSerialNumber(System.getenv("MOZ_ANDROID_USER_SERIAL_NUMBER")) + .extras(GeckoThread.getActiveExtras()) + .flags(filterFlagsForChild(GeckoThread.getActiveFlags())) + .fds( + FileDescriptors.builder() + .prefs(prefsFd) + .prefMap(prefMapFd) + .ipc(ipcFd) + .crashReporter(crashFd) + .crashAnnotation(crashAnnotationFd) + .build()) + .build()); + + XPCOMEventTarget.runOnLauncherThread( + () -> { + INSTANCE + .start(info) + .accept(result::complete, result::completeExceptionally) + .finally_(info.pfds::close); + }); + + return result; + } + + private static int filterFlagsForChild(final int flags) { + return flags & GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER; + } + + private static class StartInfo { + final GeckoProcessType type; + final String crashHandler; + final GeckoThread.InitInfo init; + + final ParcelFileDescriptors pfds; + + private StartInfo(final GeckoProcessType type, final GeckoThread.InitInfo initInfo) { + this.type = type; + this.init = initInfo; + crashHandler = + GeckoAppShell.getCrashHandlerService() != null + ? GeckoAppShell.getCrashHandlerService().getName() + : null; + // The native side owns the File Descriptors so we cannot call adopt here. + pfds = ParcelFileDescriptors.from(initInfo.fds); + } + } + + private static final int MAX_RETRIES = 3; + + private GeckoResult start(final StartInfo info) { + return start(info, new ArrayList<>()); + } + + private GeckoResult retry( + final StartInfo info, final List retryLog, final Throwable error) { + retryLog.add(error); + + if (error instanceof StartException) { + final StartException startError = (StartException) error; + if (startError.errorCode == IChildProcess.STARTED_BUSY) { + // This process is owned by a different runtime, so we can't use + // it. We will keep retrying indefinitely until we find a non-busy process. + // Note: this strategy is pretty bad, we go through each process in + // sequence until one works, the multiple runtime case is test-only + // for now, so that's ok. We can improve on this if we eventually + // end up needing something fancier. + return start(info, retryLog); + } + } + + // If we couldn't unbind there's something very wrong going on and we bail + // immediately. + if (retryLog.size() >= MAX_RETRIES || error instanceof UnbindException) { + return GeckoResult.fromException(fromRetryLog(retryLog)); + } + + return start(info, retryLog); + } + + private String serializeLog(final List retryLog) { + if (retryLog == null || retryLog.size() == 0) { + return "Empty log."; + } + + final StringBuilder message = new StringBuilder(); + + for (final Throwable error : retryLog) { + if (error instanceof UnbindException) { + message.append("Could not unbind: "); + } else if (error instanceof StartException) { + message.append("Cannot restart child: "); + } else { + message.append("Error while binding: "); + } + message.append(error); + message.append(";"); + } + + return message.toString(); + } + + private RuntimeException fromRetryLog(final List retryLog) { + return new RuntimeException(serializeLog(retryLog), retryLog.get(retryLog.size() - 1)); + } + + private GeckoResult start(final StartInfo info, final List retryLog) { + return startInternal(info).then(GeckoResult::fromValue, error -> retry(info, retryLog, error)); + } + + private static class StartException extends RuntimeException { + public final int errorCode; + + public StartException(final int errorCode, final int pid) { + super("Could not start process, errorCode: " + errorCode + " PID: " + pid); + this.errorCode = errorCode; + } + } + + private GeckoResult startInternal(final StartInfo info) { + XPCOMEventTarget.assertOnLauncherThread(); + + final ChildConnection connection = mConnections.getConnectionForStart(info.type); + return connection + .bind() + .map( + child -> { + final int result = + child.start( + this, + mInstanceId, + info.init.args, + info.init.extras, + info.init.flags, + info.init.userSerialNumber, + info.crashHandler, + info.pfds.prefs, + info.pfds.prefMap, + info.pfds.ipc, + info.pfds.crashReporter, + info.pfds.crashAnnotation); + if (result == IChildProcess.STARTED_OK) { + return connection.getPid(); + } else { + throw new StartException(result, connection.getPid()); + } + }) + .then(GeckoResult::fromValue, error -> handleBindError(connection, error)); + } + + private GeckoResult handleBindError( + final ChildConnection connection, final Throwable error) { + return connection + .unbind() + .then( + unused -> GeckoResult.fromException(error), + unbindError -> GeckoResult.fromException(new UnbindException(unbindError))); + } + + private static class UnbindException extends RuntimeException { + public UnbindException(final Throwable cause) { + super(cause); + } + } +} // GeckoProcessManager diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java new file mode 100644 index 0000000000..812a27614c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import org.mozilla.gecko.annotation.WrapForJNI; + +@WrapForJNI +public enum GeckoProcessType { + // These need to match the stringified names from the GeckoProcessType enum + PARENT("default"), + PLUGIN("plugin"), + CONTENT("tab"), + IPDLUNITTEST("ipdlunittest"), + GMPLUGIN("gmplugin"), + GPU("gpu"), + VR("vr"), + RDD("rdd"), + SOCKET("socket"), + REMOTESANDBOXBROKER("sandboxbroker"), + FORKSERVER("forkserver"), + UTILITY("utility"); + + private final String mGeckoName; + + private GeckoProcessType(final String geckoName) { + mGeckoName = geckoName; + } + + @Override + public String toString() { + return mGeckoName; + } + + @WrapForJNI + private static final GeckoProcessType fromInt(final int type) { + return values()[type]; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java new file mode 100644 index 0000000000..e030a47c74 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java @@ -0,0 +1,213 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.RemoteException; +import android.util.Log; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.GeckoThread.FileDescriptors; +import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.ICompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoServiceChildProcess extends Service { + private static final String LOGTAG = "ServiceChildProcess"; + + private static IProcessManager sProcessManager; + private static String sOwnerProcessId; + private final MemoryController mMemoryController = new MemoryController(); + + // Makes sure we don't reuse this process + private static boolean sCreateCalled; + + @WrapForJNI(calledFrom = "gecko") + private static void getEditableParent( + final IGeckoEditableChild child, final long contentId, final long tabId) { + try { + sProcessManager.getEditableParent(child, contentId, tabId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Cannot get editable", e); + } + } + + @Override + public void onCreate() { + super.onCreate(); + Log.i(LOGTAG, "onCreate"); + + if (sCreateCalled) { + // We don't support reusing processes, and this could get us in a really weird state, + // so let's throw here. + throw new RuntimeException("Cannot reuse process."); + } + sCreateCalled = true; + + GeckoAppShell.setApplicationContext(getApplicationContext()); + GeckoThread.launch(); // Preload Gecko. + } + + protected static class ChildProcessBinder extends IChildProcess.Stub { + @Override + public int getPid() { + return Process.myPid(); + } + + @Override + public int start( + final IProcessManager procMan, + final String mainProcessId, + final String[] args, + final Bundle extras, + final int flags, + final String userSerialNumber, + final String crashHandlerService, + final ParcelFileDescriptor prefsPfd, + final ParcelFileDescriptor prefMapPfd, + final ParcelFileDescriptor ipcPfd, + final ParcelFileDescriptor crashReporterPfd, + final ParcelFileDescriptor crashAnnotationPfd) { + + final ParcelFileDescriptors pfds = + ParcelFileDescriptors.builder() + .prefs(prefsPfd) + .prefMap(prefMapPfd) + .ipc(ipcPfd) + .crashReporter(crashReporterPfd) + .crashAnnotation(crashAnnotationPfd) + .build(); + + synchronized (GeckoServiceChildProcess.class) { + if (sOwnerProcessId != null && !sOwnerProcessId.equals(mainProcessId)) { + Log.w( + LOGTAG, + "This process belongs to a different GeckoRuntime owner: " + + sOwnerProcessId + + " process: " + + mainProcessId); + // We need to close the File Descriptors here otherwise we will leak them causing a + // shutdown hang. + pfds.close(); + return IChildProcess.STARTED_BUSY; + } + if (sProcessManager != null) { + Log.e(LOGTAG, "Child process already started"); + pfds.close(); + return IChildProcess.STARTED_FAIL; + } + sProcessManager = procMan; + sOwnerProcessId = mainProcessId; + } + + final FileDescriptors fds = pfds.detach(); + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + if (crashHandlerService != null) { + try { + @SuppressWarnings("unchecked") + final Class crashHandler = + (Class) Class.forName(crashHandlerService); + + // Native crashes are reported through pipes, so we don't have to + // do anything special for that. + GeckoAppShell.setCrashHandlerService(crashHandler); + GeckoAppShell.ensureCrashHandling(crashHandler); + } catch (final ClassNotFoundException e) { + Log.w(LOGTAG, "Couldn't find crash handler service " + crashHandlerService); + } + } + + final GeckoThread.InitInfo info = + GeckoThread.InitInfo.builder() + .args(args) + .extras(extras) + .flags(flags) + .userSerialNumber(userSerialNumber) + .fds(fds) + .build(); + + if (GeckoThread.init(info)) { + GeckoThread.launch(); + } + } + }); + return IChildProcess.STARTED_OK; + } + + @Override + public void crash() { + GeckoThread.crash(); + } + + @Override + public ICompositorSurfaceManager getCompositorSurfaceManager() { + Log.e( + LOGTAG, "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process"); + throw new AssertionError( + "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process."); + } + + @Override + public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) { + Log.e(LOGTAG, "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process"); + throw new AssertionError( + "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process."); + } + } + + protected Binder createBinder() { + return new ChildProcessBinder(); + } + + private final Binder mBinder = createBinder(); + + @Override + public void onDestroy() { + Log.i(LOGTAG, "Destroying GeckoServiceChildProcess"); + System.exit(0); + } + + @Override + public IBinder onBind(final Intent intent) { + // Calling stopSelf ensures that whenever the client unbinds the process dies immediately. + stopSelf(); + return mBinder; + } + + @Override + public void onTrimMemory(final int level) { + mMemoryController.onTrimMemory(level); + + // This is currently a no-op in Service, but let's future-proof. + super.onTrimMemory(level); + } + + @Override + public void onLowMemory() { + mMemoryController.onLowMemory(); + super.onLowMemory(); + } + + /** + * Returns the surface allocator interface that should be used by this process to allocate + * Surfaces, for consumption in either the GPU process or parent process. + */ + public static ISurfaceAllocator getSurfaceAllocator() throws RemoteException { + return sProcessManager.getSurfaceAllocator(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java new file mode 100644 index 0000000000..e4312c7e67 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java @@ -0,0 +1,63 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.os.Binder; +import android.util.SparseArray; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.ICompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.gfx.RemoteSurfaceAllocator; + +public class GeckoServiceGpuProcess extends GeckoServiceChildProcess { + private static final String LOGTAG = "ServiceGpuProcess"; + + private static final class GpuProcessBinder extends GeckoServiceChildProcess.ChildProcessBinder { + @Override + public ICompositorSurfaceManager getCompositorSurfaceManager() { + return RemoteCompositorSurfaceManager.getInstance(); + } + + @Override + public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) { + return RemoteSurfaceAllocator.getInstance(allocatorId); + } + } + + @Override + protected Binder createBinder() { + return new GpuProcessBinder(); + } + + public static final class RemoteCompositorSurfaceManager extends ICompositorSurfaceManager.Stub { + private static RemoteCompositorSurfaceManager mInstance; + + @WrapForJNI + private static synchronized RemoteCompositorSurfaceManager getInstance() { + if (mInstance == null) { + mInstance = new RemoteCompositorSurfaceManager(); + } + return mInstance; + } + + private final SparseArray mSurfaces = new SparseArray(); + + @Override + public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) { + if (surface != null) { + mSurfaces.put(widgetId, surface); + } else { + mSurfaces.remove(widgetId); + } + } + + @WrapForJNI + public synchronized Surface getCompositorSurface(final int widgetId) { + return mSurfaces.get(widgetId); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java new file mode 100644 index 0000000000..f2dcb7a52b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.content.ComponentCallbacks2; +import android.content.res.Configuration; +import android.util.Log; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoAppShell; + +public class MemoryController implements ComponentCallbacks2 { + private static final String LOGTAG = "MemoryController"; + private long mLastLowMemoryNotificationTime = 0; + + // Allowed elapsed time between full GCs while under constant memory pressure + private static final long LOW_MEMORY_ONGOING_RESET_TIME_MS = 10000; + + private static final int LOW = 0; + private static final int MODERATE = 1; + private static final int CRITICAL = 2; + + private int memoryLevelFromTrim(final int level) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE + || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + return CRITICAL; + } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { + return MODERATE; + } + return LOW; + } + + public void onTrimMemory(final int level) { + Log.i(LOGTAG, "onTrimMemory(" + level + ")"); + onMemoryNotification(memoryLevelFromTrim(level)); + } + + @Override + public void onConfigurationChanged(final @NonNull Configuration newConfig) {} + + public void onLowMemory() { + Log.i(LOGTAG, "onLowMemory"); + onMemoryNotification(CRITICAL); + } + + private void onMemoryNotification(final int level) { + if (level == LOW) { + // The trim level is too low to be actionable + return; + } + + // See nsIMemory.idl for descriptions of the various arguments to the "memory-pressure" + // observer. + final String observerArg; + + final long currentNotificationTime = System.currentTimeMillis(); + if (level == CRITICAL + || (currentNotificationTime - mLastLowMemoryNotificationTime) + >= LOW_MEMORY_ONGOING_RESET_TIME_MS) { + // We do a full "low-memory" notification for both new and last-ditch onTrimMemory requests. + observerArg = "low-memory"; + mLastLowMemoryNotificationTime = currentNotificationTime; + } else { + // If it has been less than ten seconds since the last time we sent a "low-memory" + // notification, we send a "low-memory-ongoing" notification instead. + // This prevents Gecko from re-doing full GC's repeatedly over and over in succession, + // as they are expensive and quickly result in diminishing returns. + observerArg = "low-memory-ongoing"; + } + + GeckoAppShell.notifyObservers("memory-pressure", observerArg); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java new file mode 100644 index 0000000000..8058d71601 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java @@ -0,0 +1,613 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.NonNull; +import java.security.SecureRandom; +import java.util.BitSet; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map.Entry; +import java.util.Set; +import java.util.UUID; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.XPCOMEventTarget; + +/* package */ final class ServiceAllocator { + private static final String LOGTAG = "ServiceAllocator"; + private static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = + GeckoChildProcessServices.MAX_NUM_ISOLATED_CONTENT_SERVICES; + + private static boolean hasQApis() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + /** + * Possible priority levels that are available to child services. Each one maps to a flag that is + * passed into Context.bindService(). + */ + @WrapForJNI + public static enum PriorityLevel { + FOREGROUND(Context.BIND_IMPORTANT), + BACKGROUND(0), + IDLE(Context.BIND_WAIVE_PRIORITY); + + private final int mAndroidFlag; + + private PriorityLevel(final int androidFlag) { + mAndroidFlag = androidFlag; + } + + public int getAndroidFlag() { + return mAndroidFlag; + } + } + + public static final class BindException extends RuntimeException { + public BindException(@NonNull final String msg) { + super(msg); + } + } + + private interface BindServiceDelegate { + boolean bindService(ServiceConnection binding, PriorityLevel priority); + + String getServiceName(); + } + + /** + * Abstract class that holds the essential per-service data that is required to work with + * ServiceAllocator. ServiceAllocator clients should extend this class when implementing their + * per-service connection objects. + */ + public abstract static class InstanceInfo { + private class Binding implements ServiceConnection { + /** + * This implementation of ServiceConnection.onServiceConnected simply bounces the connection + * notification over to the launcher thread (if it is not already on it). + */ + @Override + public final void onServiceConnected(final ComponentName name, final IBinder service) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + onBinderConnectedInternal(service); + }); + } + + /** + * This implementation of ServiceConnection.onServiceDisconnected simply bounces the + * disconnection notification over to the launcher thread (if it is not already on it). + */ + @Override + public final void onServiceDisconnected(final ComponentName name) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + onBinderConnectionLostInternal(); + }); + } + } + + private class DefaultBindDelegate implements BindServiceDelegate { + @Override + public boolean bindService( + @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent intent = new Intent(); + intent.setClassName(context, getServiceName()); + return bindServiceDefault(context, intent, binding, getAndroidFlags(priority)); + } + + @Override + public String getServiceName() { + return getSvcClassNameDefault(InstanceInfo.this); + } + } + + private class IsolatedBindDelegate implements BindServiceDelegate { + @Override + public boolean bindService( + @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent intent = new Intent(); + intent.setClassName(context, getServiceName()); + return bindServiceIsolated( + context, intent, getAndroidFlags(priority), getIdInternal(), binding); + } + + @Override + public String getServiceName() { + return ServiceUtils.buildIsolatedSvcName(getType()); + } + } + + private final ServiceAllocator mAllocator; + private final GeckoProcessType mType; + private final String mId; + private final EnumMap 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.class); + mBindDelegate = getBindServiceDelegate(); + + mCurrentPriority = initialPriority; + } + + private BindServiceDelegate getBindServiceDelegate() { + if (mType != GeckoProcessType.CONTENT) { + // Non-content services just use default binding + return this.new DefaultBindDelegate(); + } + + // Content services defer to the alloc policy + return mAllocator.mContentAllocPolicy.getBindServiceDelegate(this); + } + + public PriorityLevel getPriorityLevel() { + XPCOMEventTarget.assertOnLauncherThread(); + return mCurrentPriority; + } + + public boolean setPriorityLevel(@NonNull final PriorityLevel newPriority) { + return setPriorityLevel(newPriority, 0); + } + + public boolean setPriorityLevel( + @NonNull final PriorityLevel newPriority, final int relativeImportance) { + XPCOMEventTarget.assertOnLauncherThread(); + mCurrentPriority = newPriority; + mRelativeImportance = relativeImportance; + + // If we haven't bound yet then we can just return + if (mBindings.size() == 0) { + return true; + } + + // Otherwise we need to update our bindings + return updateBindings(); + } + + /** + * Only content services have unique IDs. This method throws if called for a non-content service + * type. + */ + public String getId() { + if (mId == null) { + throw new RuntimeException("This service does not have a unique id"); + } + + return mId; + } + + /** This method is infallible and returns an empty string for non-content services. */ + private String getIdInternal() { + return mId == null ? "" : mId; + } + + public boolean isContent() { + return mType == GeckoProcessType.CONTENT; + } + + public GeckoProcessType getType() { + return mType; + } + + protected boolean bindService() { + if (mIsDefunct) { + final String errorMsg = + "Attempt to bind a defunct InstanceInfo for " + mType + " child process"; + throw new BindException(errorMsg); + } + + return updateBindings(); + } + + /** + * Unbinds the service described by |this| and releases our unique ID. This method may safely be + * called multiple times even if we are already defunct. + */ + protected void unbindService() { + XPCOMEventTarget.assertOnLauncherThread(); + + // This could happen if a service death races with our attempt to shut it down. + if (mIsDefunct) { + return; + } + + final Context context = GeckoAppShell.getApplicationContext(); + + // Make a clone of mBindings to iterate over since we're going to mutate the original + final EnumMap cloned = mBindings.clone(); + for (final Entry entry : cloned.entrySet()) { + try { + context.unbindService(entry.getValue()); + } catch (final IllegalArgumentException e) { + // The binding was already dead. That's okay. + } + + mBindings.remove(entry.getKey()); + } + + if (mBindings.size() != 0) { + throw new IllegalStateException("Unable to release all bindings"); + } + + mIsDefunct = true; + mAllocator.release(this); + onReleaseResources(); + } + + private void onBinderConnectedInternal(@NonNull final IBinder service) { + XPCOMEventTarget.assertOnLauncherThread(); + // We only care about the first time this is called; subsequent bindings can be ignored. + if (mCalledConnected) { + return; + } + + mCalledConnected = true; + + onBinderConnected(service); + } + + private void onBinderConnectionLostInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + // We only care about the first time this is called; subsequent connection errors can be + // ignored. + if (mCalledConnectionLost) { + return; + } + + mCalledConnectionLost = true; + + onBinderConnectionLost(); + } + + protected abstract void onBinderConnected(@NonNull final IBinder service); + + protected abstract void onReleaseResources(); + + // Optionally overridable by subclasses, but this is a sane default + protected void onBinderConnectionLost() { + // The binding has lost its connection, but the binding itself might still be active. + // Gecko itself will request a process restart, so here we attempt to unbind so that + // Android does not try to automatically restart and reconnect the service. + unbindService(); + } + + /** + * This function relies on the fact that the PriorityLevel enum is ordered from highest priority + * to lowest priority. We examine the ordinal of the current priority setting, and then iterate + * across all possible priority levels, adjusting as necessary. Any priority levels whose + * ordinals are less than then current priority level ordinal must be unbound, while all + * priority levels whose ordinals are greater than or equal to the current priority level + * ordinal must be bound. + */ + @TargetApi(29) + private boolean updateBindings() { + XPCOMEventTarget.assertOnLauncherThread(); + int numBindSuccesses = 0; + int numBindFailures = 0; + int numUnbindSuccesses = 0; + + final Context context = GeckoAppShell.getApplicationContext(); + + // This code assumes that the order of the PriorityLevel enum is highest to lowest + final int curPriorityOrdinal = mCurrentPriority.ordinal(); + final PriorityLevel[] levels = PriorityLevel.values(); + + for (int curLevelIdx = 0; curLevelIdx < levels.length; ++curLevelIdx) { + final PriorityLevel curLevel = levels[curLevelIdx]; + final Binding existingBinding = mBindings.get(curLevel); + final boolean hasExistingBinding = existingBinding != null; + + if (curLevelIdx < curPriorityOrdinal) { + // Remove if present + if (hasExistingBinding) { + try { + context.unbindService(existingBinding); + ++numUnbindSuccesses; + mBindings.remove(curLevel); + } catch (final IllegalArgumentException e) { + // The binding was already dead. That's okay. + ++numUnbindSuccesses; + mBindings.remove(curLevel); + } + } + } else { + // Normally we only need to do a bind if we do not yet have an existing binding + // for this priority level. + boolean bindNeeded = !hasExistingBinding; + + // We only update the service group when the binding for this level already + // exists and no binds have occurred yet during the current updateBindings call. + if (hasExistingBinding && hasQApis() && (numBindSuccesses + numBindFailures) == 0) { + // NB: Right now we're passing 0 as the |group| argument, indicating that + // the process is not grouped with any other processes. Once we support + // Fission we should re-evaluate this. + context.updateServiceGroup(existingBinding, 0, mRelativeImportance); + // Now we need to call bindService with the existing binding to make this + // change take effect. + bindNeeded = true; + } + + if (bindNeeded) { + final Binding useBinding = hasExistingBinding ? existingBinding : this.new Binding(); + if (mBindDelegate.bindService(useBinding, curLevel)) { + ++numBindSuccesses; + if (!hasExistingBinding) { + mBindings.put(curLevel, useBinding); + } + } else { + ++numBindFailures; + } + } + } + } + + final String svcName = mBindDelegate.getServiceName(); + final StringBuilder builder = new StringBuilder(svcName); + builder + .append(" updateBindings: ") + .append(mCurrentPriority) + .append(" priority, ") + .append(mRelativeImportance) + .append(" importance, ") + .append(numBindSuccesses) + .append(" successful binds, ") + .append(numBindFailures) + .append(" failed binds, ") + .append(numUnbindSuccesses) + .append(" successful unbinds"); + Log.d(LOGTAG, builder.toString()); + + return numBindFailures == 0; + } + } + + private interface ContentAllocationPolicy { + /** + * @return BindServiceDelegate that will be used for binding a new content service. + */ + BindServiceDelegate getBindServiceDelegate(InstanceInfo info); + + /** + * Allocate an unused service ID for use by the caller. + * + * @return The new service id. + */ + String allocate(); + + /** + * Release a previously used service ID. + * + * @param id The service id being released. + */ + void release(final String id); + } + + /** + * This policy is intended for Android versions < 10, as well as for content process services + * that are not defined as isolated processes. In this case, the number of possible content + * service IDs has a fixed upper bound, so we use a BitSet to manage their allocation. + */ + private static final class DefaultContentPolicy implements ContentAllocationPolicy { + private final int mMaxNumSvcs; + private final BitSet mAllocator; + private final SecureRandom mRandom; + + public DefaultContentPolicy() { + mMaxNumSvcs = getContentServiceCount(); + mAllocator = new BitSet(mMaxNumSvcs); + mRandom = new SecureRandom(); + } + + @Override + public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) { + return info.new DefaultBindDelegate(); + } + + @Override + public String allocate() { + final int[] available = new int[mMaxNumSvcs]; + int size = 0; + for (int i = 0; i < mMaxNumSvcs; i++) { + if (!mAllocator.get(i)) { + available[size] = i; + size++; + } + } + + if (size == 0) { + throw new RuntimeException("No more content services available"); + } + + final int next = available[mRandom.nextInt(size)]; + mAllocator.set(next); + return Integer.toString(next); + } + + @Override + public void release(final String stringId) { + final int id = Integer.valueOf(stringId); + if (!mAllocator.get(id)) { + throw new IllegalStateException("Releasing an unallocated id=" + id); + } + + mAllocator.clear(id); + } + + /** + * @return The number of content services defined in our manifest. + */ + private static int getContentServiceCount() { + return ServiceUtils.getServiceCount( + GeckoAppShell.getApplicationContext(), GeckoProcessType.CONTENT); + } + } + + /** + * This policy is intended for Android versions >= 10 when our content process services are + * defined in our manifest as having isolated processes. Since isolated services share a single + * service definition, there is no longer an Android-induced hard limit on the number of content + * processes that may be started. We simply use a monotonically-increasing counter to generate + * unique instance IDs in this case. + */ + private static final class IsolatedContentPolicy implements ContentAllocationPolicy { + private final Set mRunningServiceIds = new HashSet<>(); + + @Override + public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) { + return info.new IsolatedBindDelegate(); + } + + /** + * We generate a new instance ID simply by incrementing a counter. We do track how many content + * services are currently active for the purposes of maintaining the configured limit on number + * of simultaneous content processes. + */ + @Override + public String allocate() { + if (mRunningServiceIds.size() >= MAX_NUM_ISOLATED_CONTENT_SERVICES) { + throw new RuntimeException("No more content services available"); + } + + final String newId = UUID.randomUUID().toString(); + mRunningServiceIds.add(newId); + return newId; + } + + /** Just drop the count of active services. */ + @Override + public void release(final String id) { + if (!mRunningServiceIds.remove(id)) { + throw new IllegalStateException("Releasing an unallocated id"); + } + } + } + + /** The policy used for allocating content processes. */ + private ContentAllocationPolicy mContentAllocPolicy = null; + + /** + * Allocate a service ID. + * + * @param type The type of service. + * @return Integer encapsulating the service ID, or null if no ID is necessary. + */ + private String allocate(@NonNull final GeckoProcessType type) { + XPCOMEventTarget.assertOnLauncherThread(); + if (type != GeckoProcessType.CONTENT) { + // No unique id necessary + return null; + } + + // Lazy initialization of mContentAllocPolicy to ensure that it is constructed on the + // launcher thread. + if (mContentAllocPolicy == null) { + if (canBindIsolated(GeckoProcessType.CONTENT)) { + mContentAllocPolicy = new IsolatedContentPolicy(); + } else { + mContentAllocPolicy = new DefaultContentPolicy(); + } + } + + return mContentAllocPolicy.allocate(); + } + + /** + * Free a defunct service's ID if necessary. + * + * @param info The InstanceInfo-derived object that contains essential information for tearing + * down the child service. + */ + private void release(@NonNull final InstanceInfo info) { + XPCOMEventTarget.assertOnLauncherThread(); + if (!info.isContent()) { + return; + } + + mContentAllocPolicy.release(info.getId()); + } + + /** + * Find out whether the desired service type is defined in our manifest as having an isolated + * process. + * + * @param type Service type to query + * @return true if this service type may use isolated binding, otherwise false. + */ + private static boolean canBindIsolated(@NonNull final GeckoProcessType type) { + if (!hasQApis()) { + return false; + } + + final Context context = GeckoAppShell.getApplicationContext(); + final int svcFlags = ServiceUtils.getServiceFlags(context, type); + return (svcFlags & ServiceInfo.FLAG_ISOLATED_PROCESS) != 0; + } + + /** Convert PriorityLevel into the flags argument to Context.bindService() et al */ + private static int getAndroidFlags(@NonNull final PriorityLevel priority) { + return Context.BIND_AUTO_CREATE | priority.getAndroidFlag(); + } + + /** Obtain the class name to use for service binding in the default (ie, non-isolated) case. */ + private static String getSvcClassNameDefault(@NonNull final InstanceInfo info) { + return ServiceUtils.buildSvcName(info.getType(), info.getIdInternal()); + } + + /** + * Wrapper for bindService() that utilizes the Context.bindService() overload that accepts an + * Executor argument, when available. Otherwise it falls back to the legacy overload. + */ + @TargetApi(29) + private static boolean bindServiceDefault( + @NonNull final Context context, + @NonNull final Intent intent, + @NonNull final ServiceConnection conn, + final int flags) { + if (hasQApis()) { + // We always specify the launcher thread as our Executor. + return context.bindService(intent, flags, XPCOMEventTarget.launcherThread(), conn); + } + + return context.bindService(intent, conn, flags); + } + + @TargetApi(29) + private static boolean bindServiceIsolated( + @NonNull final Context context, + @NonNull final Intent intent, + final int flags, + @NonNull final String instanceId, + @NonNull final ServiceConnection conn) { + // We always specify the launcher thread as our Executor. + return context.bindIsolatedService( + intent, flags, instanceId, XPCOMEventTarget.launcherThread(), conn); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java new file mode 100644 index 0000000000..695c69666b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import androidx.annotation.NonNull; + +/* package */ final class ServiceUtils { + private static final String DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX = "0"; + + private ServiceUtils() {} + + /** + * @return StringBuilder containing the name of a service class but not qualifed with any unique + * identifiers. + */ + private static StringBuilder startSvcName(@NonNull final GeckoProcessType type) { + final StringBuilder builder = new StringBuilder(GeckoChildProcessServices.class.getName()); + builder.append("$").append(type); + return builder; + } + + /** + * Given a service's GeckoProcessType, obtain the name of its class, including any qualifiers that + * are needed to uniquely identify its manifest definition. + */ + public static String buildSvcName( + @NonNull final GeckoProcessType type, final String... suffixes) { + final StringBuilder builder = startSvcName(type); + + for (final String suffix : suffixes) { + builder.append(suffix); + } + + return builder.toString(); + } + + /** + * Given a service's GeckoProcessType, obtain the name of its class to be used for the purpose of + * binding as an isolated service. + * + *

    Content services are defined in the manifest as "tab0" through "tabN" for some value of N. + * For the purposes of binding to an isolated content service, we simply need to repeatedly re-use + * the definition of "tab0", the "0" being stored as the + * DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX constant. + */ + public static String buildIsolatedSvcName(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + return buildSvcName(type, DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX); + } + + // Non-content services do not require any unique IDs + return buildSvcName(type); + } + + /** + * Given a service's GeckoProcessType, obtain the unqualified name of its class. + * + * @return The name of the class that hosts the implementation of the service corresponding to + * type, but without any unique identifiers that may be required to actually instantiate it. + */ + private static String buildSvcNamePrefix(@NonNull final GeckoProcessType type) { + return startSvcName(type).toString(); + } + + /** + * Extracts flags from the manifest definition of a service. + * + * @param context Context to use for extraction + * @param type Service type + * @return flags that are specified in the service's definition in our manifest. + * @see android.content.pm.ServiceInfo for explanation of the various flags. + */ + public static int getServiceFlags( + @NonNull final Context context, @NonNull final GeckoProcessType type) { + final ComponentName component = new ComponentName(context, buildIsolatedSvcName(type)); + final PackageManager pkgMgr = context.getPackageManager(); + + try { + final ServiceInfo svcInfo = pkgMgr.getServiceInfo(component, 0); + // svcInfo is never null + return svcInfo.flags; + } catch (final PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + } + + /** Obtain the list of all services defined for |context|. */ + private static ServiceInfo[] getServiceList(@NonNull final Context context) { + final PackageInfo packageInfo; + try { + packageInfo = + context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.GET_SERVICES); + } catch (final PackageManager.NameNotFoundException e) { + throw new AssertionError("Should not happen: Can't get package info of own package"); + } + return packageInfo.services; + } + + /** + * Count the number of service definitions in our manifest that satisfy bindings for a particular + * service type. + * + * @param context Context object to use for extracting the service definitions + * @param type The type of service to count + * @return The number of available service definitions. + */ + public static int getServiceCount( + @NonNull final Context context, @NonNull final GeckoProcessType type) { + final ServiceInfo[] svcList = getServiceList(context); + final String serviceNamePrefix = buildSvcNamePrefix(type); + + int result = 0; + for (final ServiceInfo svc : svcList) { + final String svcName = svc.name; + // If svcName starts with serviceNamePrefix, then both strings must either be equal + // or else the first subsequent character in svcName must be a digit. + // This guards against any future GeckoProcessType whose string representation shares + // a common prefix with another GeckoProcessType value. + if (svcName.startsWith(serviceNamePrefix) + && (svcName.length() == serviceNamePrefix.length() + || Character.isDigit(svcName.codePointAt(serviceNamePrefix.length())))) { + ++result; + } + } + + if (result <= 0) { + throw new RuntimeException("Could not count " + serviceNamePrefix + " services in manifest"); + } + + return result; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java new file mode 100644 index 0000000000..b8d7ea3107 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java @@ -0,0 +1,21 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +@RobocopTarget +public interface BundleEventListener { + /** + * Handles a message sent from Gecko. + * + * @param event The name of the event being sent. + * @param message The message data. + * @param callback The callback interface for this message. A callback is provided only if the + * originating call included a callback argument; otherwise, callback will be null. + */ + void handleMessage(String event, GeckoBundle message, EventCallback callback); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java new file mode 100644 index 0000000000..b030c8e67f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java @@ -0,0 +1,136 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.error.YAMLException; + +// Raptor writes a *-config.yaml file to specify Gecko runtime settings (e.g. +// the profile dir). This file gets deserialized into a DebugConfig object. +// Yaml uses reflection to create this class so we have to tell PG to keep it. +@ReflectionTarget +public class DebugConfig { + private static final String LOGTAG = "GeckoDebugConfig"; + + protected Map prefs; + protected Map env; + protected List 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 LoaderOptions options = new LoaderOptions(); + final Constructor constructor = new Constructor(DebugConfig.class, options); + final TypeDescription description = new TypeDescription(DebugConfig.class); + description.putMapPropertyType("prefs", String.class, Object.class); + description.putMapPropertyType("env", String.class, String.class); + description.putListPropertyType("args", String.class); + + final Yaml yaml = new Yaml(constructor); + yaml.addTypeDescription(description); + + final FileInputStream fileInputStream = new FileInputStream(configFile); + try { + return yaml.load(fileInputStream); + } catch (final YAMLException e) { + throw new ConfigException(e.getMessage()); + } finally { + try { + if (fileInputStream != null) { + ((Closeable) fileInputStream).close(); + } + } catch (final IOException e) { + } + } + } + + @Nullable + public Bundle mergeIntoExtras(final @Nullable Bundle extras) { + if (env == null) { + return extras; + } + + Log.d(LOGTAG, "Adding environment variables from debug config: " + env); + + final Bundle result = extras != null ? extras : new Bundle(); + + int c = 0; + while (result.getString("env" + c) != null) { + c += 1; + } + + for (final Map.Entry entry : env.entrySet()) { + result.putString("env" + c, entry.getKey() + "=" + entry.getValue()); + c += 1; + } + + return result; + } + + @Nullable + public String[] mergeIntoArgs(final @Nullable String[] initArgs) { + if (args == null) { + return initArgs; + } + + Log.d(LOGTAG, "Adding arguments from debug config: " + args); + + final ArrayList combinedArgs = new ArrayList<>(); + if (initArgs != null) { + combinedArgs.addAll(Arrays.asList(initArgs)); + } + combinedArgs.addAll(args); + + return combinedArgs.toArray(new String[combinedArgs.size()]); + } + + @Nullable + public Map mergeIntoPrefs(final @Nullable Map initPrefs) { + if (prefs == null) { + return initPrefs; + } + + Log.d(LOGTAG, "Adding prefs from debug config: " + prefs); + + final Map combinedPrefs = new HashMap<>(); + if (initPrefs != null) { + combinedPrefs.putAll(initPrefs); + } + combinedPrefs.putAll(prefs); + + return Collections.unmodifiableMap(combinedPrefs); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java new file mode 100644 index 0000000000..3ef469ac1b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java @@ -0,0 +1,58 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.geckoview.GeckoResult; + +/** + * Callback interface for Gecko requests. + * + *

    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 void resolveTo(final @Nullable GeckoResult response) { + if (response == null) { + sendSuccess(null); + return; + } + response.accept( + this::sendSuccess, + throwable -> { + // Don't propagate Errors, just crash + if (!(throwable instanceof Exception)) { + throw new GeckoResult.UncaughtException(throwable); + } + sendError(throwable.getMessage()); + }); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java new file mode 100644 index 0000000000..01b177fe21 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.os.Handler; +import android.os.Looper; + +final class GeckoBackgroundThread extends Thread { + private static final String LOOPER_NAME = "GeckoBackgroundThread"; + + // Guarded by 'GeckoBackgroundThread.class'. + private static Handler handler; + private static Thread thread; + + // The initial Runnable to run on the new thread. Its purpose + // is to avoid us having to wait for the new thread to start. + private Runnable mInitialRunnable; + + // Singleton, so private constructor. + private GeckoBackgroundThread(final Runnable initialRunnable) { + mInitialRunnable = initialRunnable; + } + + @Override + public void run() { + setName(LOOPER_NAME); + Looper.prepare(); + + synchronized (GeckoBackgroundThread.class) { + handler = new Handler(); + GeckoBackgroundThread.class.notifyAll(); + } + + if (mInitialRunnable != null) { + mInitialRunnable.run(); + mInitialRunnable = null; + } + + Looper.loop(); + } + + private static void startThread(final Runnable initialRunnable) { + thread = new GeckoBackgroundThread(initialRunnable); + thread.setDaemon(true); + thread.start(); + } + + // Get a Handler for a looper thread, or create one if it doesn't yet exist. + /*package*/ static synchronized Handler getHandler() { + if (thread == null) { + startThread(null); + } + + while (handler == null) { + try { + GeckoBackgroundThread.class.wait(); + } catch (final InterruptedException e) { + } + } + return handler; + } + + /*package*/ static synchronized void post(final Runnable runnable) { + if (thread == null) { + startThread(runnable); + return; + } + getHandler().post(runnable); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java new file mode 100644 index 0000000000..4ed37872f2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java @@ -0,0 +1,1164 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.collection.SimpleArrayMap; +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * A lighter-weight version of Bundle that adds support for type coercion (e.g. int to double) in + * order to better cooperate with JS objects. + */ +@RobocopTarget +public final class GeckoBundle implements Parcelable { + private static final String LOGTAG = "GeckoBundle"; + private static final boolean DEBUG = false; + + @WrapForJNI(calledFrom = "gecko") + private static final boolean[] EMPTY_BOOLEAN_ARRAY = new boolean[0]; + + private static final byte[] EMPTY_BYTE_ARRAY = new byte[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 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 byte array mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Byte array value + */ + public byte[] getByteArray(final String key) { + final Object value = mMap.get(key); + return value == null ? null : Array.getLength(value) == 0 ? EMPTY_BYTE_ARRAY : (byte[]) value; + } + + /** + * Returns the value associated with an int/double mapping as a long value, or defaultValue if the + * mapping does not exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Long value + */ + public long getLong(final String key, final long defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : ((Number) value).longValue(); + } + + /** + * Returns the value associated with an int/double mapping as a long value, or 0 if the mapping + * does not exist. + * + * @param key Key to look for. + * @return Long value + */ + public long getLong(final String key) { + return getLong(key, 0L); + } + + private static long[] getLongArray(final Object array) { + final int len = Array.getLength(array); + final long[] ret = new long[len]; + for (int i = 0; i < len; i++) { + ret[i] = ((Number) Array.get(array, i)).longValue(); + } + return ret; + } + + /** + * Returns the value associated with an int/double array mapping as a long array, or null if the + * mapping does not exist. + * + * @param key Key to look for. + * @return Long array value + */ + public long[] getLongArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 ? EMPTY_LONG_ARRAY : getLongArray(value); + } + + /** + * Returns the value associated with a String mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping value is null or mapping does not exist. + * @return String value + */ + public String getString(final String key, final String defaultValue) { + // If the key maps to null, technically we should return null because the mapping + // exists and null is a valid string value. However, people expect the default + // value to be returned instead, so we make an exception to return the default value. + final Object value = mMap.get(key); + return value == null ? defaultValue : (String) value; + } + + /** + * Returns the value associated with a String mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return String value + */ + public String getString(final String key) { + return getString(key, null); + } + + // The only case where we convert String[] to/from GeckoBundle[] is if every element + // is null. + private static int getNullArrayLength(final Object array) { + final int len = Array.getLength(array); + for (int i = 0; i < len; i++) { + if (Array.get(array, i) != null) { + throw new ClassCastException("Cannot cast array type"); + } + } + return len; + } + + /** + * Returns the value associated with a String array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return String array value + */ + public String[] getStringArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_STRING_ARRAY + : !(value instanceof String[]) + ? new String[getNullArrayLength(value)] + : (String[]) value; + } + + /* + * Returns the value associated with a RectF mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return RectF value + */ + public RectF getRectF(final String key) { + final GeckoBundle rectBundle = getBundle(key); + if (rectBundle == null) { + return null; + } + + return new RectF( + (float) rectBundle.getDouble("left"), + (float) rectBundle.getDouble("top"), + (float) rectBundle.getDouble("right"), + (float) rectBundle.getDouble("bottom")); + } + + /** + * Returns the value associated with a Point mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Point value + */ + public Point getPoint(final String key) { + final GeckoBundle ptBundle = getBundle(key); + if (ptBundle == null) { + return null; + } + + return new Point(ptBundle.getInt("x"), ptBundle.getInt("y")); + } + + /** + * Returns the value associated with a PointF mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Point value + */ + public PointF getPointF(final String key) { + final GeckoBundle ptBundle = getBundle(key); + if (ptBundle == null) { + return null; + } + + return new PointF((float) ptBundle.getDouble("x"), (float) ptBundle.getDouble("y")); + } + + /** + * Returns the value associated with a GeckoBundle mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return GeckoBundle value + */ + public GeckoBundle getBundle(final String key) { + return (GeckoBundle) mMap.get(key); + } + + /** + * Returns the value associated with a GeckoBundle array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return GeckoBundle array value + */ + public GeckoBundle[] getBundleArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_BUNDLE_ARRAY + : !(value instanceof GeckoBundle[]) + ? new GeckoBundle[getNullArrayLength(value)] + : (GeckoBundle[]) value; + } + + /** + * Returns whether this GeckoBundle has no mappings. + * + * @return True if no mapping exists. + */ + public boolean isEmpty() { + return mMap.isEmpty(); + } + + /** + * Returns an array of all mapped keys. + * + * @return String array containing all mapped keys. + */ + @WrapForJNI(calledFrom = "gecko") + public String[] keys() { + final int len = mMap.size(); + final String[] ret = new String[len]; + for (int i = 0; i < len; i++) { + ret[i] = mMap.keyAt(i); + } + return ret; + } + + @WrapForJNI(calledFrom = "gecko") + private Object[] values() { + final int len = mMap.size(); + final Object[] ret = new Object[len]; + for (int i = 0; i < len; i++) { + ret[i] = mMap.valueAt(i); + } + return ret; + } + + private void put(final String key, final Object value) { + // We intentionally disallow a generic put() method for type safety and sanity. For + // example, we assume elsewhere in the code that a value belongs to a small list of + // predefined types, and cannot be any arbitrary object. If you want to put an + // Object in the bundle, check the type of the Object first and call the + // corresponding put methods. For example, + // + // if (obj instanceof Integer) { + // bundle.putInt(key, (Integer) key); + // } else if (obj instanceof String) { + // bundle.putString(key, (String) obj); + // } else { + // throw new IllegalArgumentException("unexpected type"); + // } + throw new UnsupportedOperationException(); + } + + /** + * Map a key to a boolean value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBoolean(final String key, final boolean value) { + mMap.put(key, value); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final boolean[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final Boolean[] value) { + if (value == null) { + mMap.put(key, null); + return; + } + final boolean[] array = new boolean[value.length]; + for (int i = 0; i < value.length; i++) { + array[i] = value[i]; + } + mMap.put(key, array); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final Collection 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 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 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 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 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 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 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 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 CREATOR = + new Parcelable.Creator() { + @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..7e302a7c3d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java @@ -0,0 +1,397 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.SuppressLint; +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.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class HardwareCodecCapabilityUtils { + private static final String LOGTAG = "HardwareCodecCapability"; + + // List of supported HW VP8 encoders. + private static final String[] supportedVp8HwEncCodecPrefixes = {"OMX.qcom.", "OMX.Intel."}; + // List of supported HW VP8 decoders. + private static final String[] supportedVp8HwDecCodecPrefixes = { + "OMX.qcom.", "OMX.Nvidia.", "OMX.Exynos.", "c2.exynos", "OMX.Intel." + }; + private static final String VP8_MIME_TYPE = "video/x-vnd.on2.vp8"; + // List of supported HW VP9 codecs. + private static final String[] supportedVp9HwCodecPrefixes = { + "OMX.qcom.", "OMX.Exynos.", "c2.exynos" + }; + private static final String VP9_MIME_TYPE = "video/x-vnd.on2.vp9"; + // List of supported HW H.264 codecs. + private static final String[] supportedH264HwCodecPrefixes = { + "OMX.qcom.", + "OMX.Intel.", + "OMX.Exynos.", + "c2.exynos", + "OMX.Nvidia", + "OMX.SEC.", + "OMX.IMG.", + "OMX.k3.", + "OMX.hisi.", + "OMX.TI.", + "OMX.MTK." + }; + private static final String H264_MIME_TYPE = "video/avc"; + // NV12 color format supported by QCOM codec, but not declared in MediaCodec - + // see /hardware/qcom/media/mm-core/inc/OMX_QCOMExtns.h + private static final int COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m = 0x7FA30C04; + // Allowable color formats supported by codec - in order of preference. + private static final int[] supportedColorList = { + CodecCapabilities.COLOR_FormatYUV420Planar, + CodecCapabilities.COLOR_FormatYUV420SemiPlanar, + CodecCapabilities.COLOR_QCOM_FormatYUV420SemiPlanar, + COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m + }; + private static final int COLOR_FORMAT_NOT_SUPPORTED = -1; + private static final String[] adaptivePlaybackBlacklist = { + "GT-I9300", // S3 (I9300 / I9300I) + "SCH-I535", // S3 + "SGH-T999", // S3 (T-Mobile) + "SAMSUNG-SGH-T999", // S3 (T-Mobile) + "SGH-M919", // S4 + "GT-I9505", // S4 + "GT-I9515", // S4 + "SCH-R970", // S4 + "SGH-I337", // S4 + "SPH-L720", // S4 (Sprint) + "SAMSUNG-SGH-I337", // S4 + "GT-I9195", // S4 Mini + "300E5EV/300E4EV/270E5EV/270E4EV/2470EV/2470EE", + "LG-D605" // LG Optimus L9 II + }; + + private static MediaCodecInfo[] getCodecListWithOldAPI() { + int numCodecs = 0; + try { + numCodecs = MediaCodecList.getCodecCount(); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Failed to retrieve media codec count", e); + return new MediaCodecInfo[numCodecs]; + } + + final MediaCodecInfo[] codecList = new MediaCodecInfo[numCodecs]; + + for (int i = 0; i < numCodecs; ++i) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + codecList[i] = info; + } + + return codecList; + } + + // Return list of all codecs (decode + encode). + private static MediaCodecInfo[] getCodecList() { + final MediaCodecInfo[] codecList; + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + codecList = getCodecListWithOldAPI(); + } else { + final MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + codecList = list.getCodecInfos(); + } + return codecList; + } + + // Return list of all decoders. + private static MediaCodecInfo[] getDecoderInfos() { + final ArrayList decoderList = new ArrayList(); + for (final MediaCodecInfo info : getCodecList()) { + if (!info.isEncoder()) { + decoderList.add(info); + } + } + return decoderList.toArray(new MediaCodecInfo[0]); + } + + // Return list of all encoders. + private static MediaCodecInfo[] getEncoderInfos() { + final ArrayList encoderList = new ArrayList(); + for (final MediaCodecInfo info : getCodecList()) { + if (info.isEncoder()) { + encoderList.add(info); + } + } + return encoderList.toArray(new MediaCodecInfo[0]); + } + + // Return list of all decoder-supported MIME types without distinguishing + // between SW/HW support. + @WrapForJNI + public static String[] getDecoderSupportedMimeTypes() { + final Set mimeTypes = new HashSet<>(); + for (final MediaCodecInfo info : getDecoderInfos()) { + mimeTypes.addAll(Arrays.asList(info.getSupportedTypes())); + } + return mimeTypes.toArray(new String[0]); + } + + // Return list of all decoder-supported MIME types, each prefixed with + // either SW or HW indicating software or hardware support. + @WrapForJNI + public static String[] getDecoderSupportedMimeTypesWithAccelInfo() { + final Set mimeTypes = new HashSet<>(); + final String[] hwPrefixes = getAllSupportedHWCodecPrefixes(false); + + for (final MediaCodecInfo info : getDecoderInfos()) { + final String[] supportedTypes = info.getSupportedTypes(); + for (final String mimeType : info.getSupportedTypes()) { + boolean isHwPrefix = false; + for (final String prefix : hwPrefixes) { + if (info.getName().startsWith(prefix)) { + isHwPrefix = true; + break; + } + } + if (!isHwPrefix) { + mimeTypes.add("SW " + mimeType); + continue; + } + final CodecCapabilities caps = info.getCapabilitiesForType(mimeType); + if (getSupportsYUV420orNV12(caps) != COLOR_FORMAT_NOT_SUPPORTED) { + mimeTypes.add("HW " + mimeType); + } + } + } + for (final String typeit : mimeTypes) { + Log.d(LOGTAG, "MIME support: " + typeit); + } + return mimeTypes.toArray(new String[0]); + } + + public static boolean checkSupportsAdaptivePlayback( + final MediaCodec aCodec, final String aMimeType) { + // isFeatureSupported supported on API level >= 19. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT + || isAdaptivePlaybackBlacklisted(aMimeType)) { + return false; + } + + try { + final MediaCodecInfo info = aCodec.getCodecInfo(); + final MediaCodecInfo.CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType); + return capabilities != null + && capabilities.isFeatureSupported( + MediaCodecInfo.CodecCapabilities.FEATURE_AdaptivePlayback); + } catch (final IllegalArgumentException e) { + Log.e(LOGTAG, "Retrieve codec information failed", e); + } + return false; + } + + // See Bug1360626 and + // https://codereview.chromium.org/1869103002 for details. + private static boolean isAdaptivePlaybackBlacklisted(final String aMimeType) { + Log.d(LOGTAG, "The device ModelID is " + Build.MODEL); + if (!aMimeType.equals("video/avc") && !aMimeType.equals("video/avc1")) { + return false; + } + + if (!Build.MANUFACTURER.toLowerCase(Locale.ROOT).equals("samsung")) { + return false; + } + + for (final String model : adaptivePlaybackBlacklist) { + if (Build.MODEL.startsWith(model)) { + return true; + } + } + return false; + } + + // Check if a given MIME Type has HW decode or encode support. + public static boolean getHWCodecCapability(final String aMimeType, final boolean aIsEncoder) { + if (Build.VERSION.SDK_INT >= 20) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder() != aIsEncoder) { + continue; + } + String name = null; + for (final String mimeType : info.getSupportedTypes()) { + if (mimeType.equals(aMimeType)) { + name = info.getName(); + break; + } + } + if (name == null) { + continue; // No HW support in this codec; try the next one. + } + Log.d(LOGTAG, "Found candidate" + (aIsEncoder ? " encoder " : " decoder ") + name); + + // Check if this is supported codec. + final String[] hwList = getSupportedHWCodecPrefixes(aMimeType, aIsEncoder); + if (hwList == null) { + continue; + } + boolean supportedCodec = false; + for (final String codecPrefix : hwList) { + if (name.startsWith(codecPrefix)) { + supportedCodec = true; + break; + } + } + if (!supportedCodec) { + continue; + } + + // Check if codec supports either yuv420 or nv12. + final CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType); + for (final int colorFormat : capabilities.colorFormats) { + Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat)); + } + if (Build.VERSION.SDK_INT >= 24) { + for (final MediaCodecInfo.CodecProfileLevel pl : capabilities.profileLevels) { + Log.v( + LOGTAG, + " Profile: 0x" + + Integer.toHexString(pl.profile) + + "/Level=0x" + + Integer.toHexString(pl.level)); + } + } + final int codecColorFormat = getSupportsYUV420orNV12(capabilities); + if (codecColorFormat != COLOR_FORMAT_NOT_SUPPORTED) { + Log.d( + LOGTAG, + "Found target" + + (aIsEncoder ? " encoder " : " decoder ") + + name + + ". Color: 0x" + + Integer.toHexString(codecColorFormat)); + return true; + } + } + } + // No HW codec. + return false; + } + + // Check if codec supports YUV420 or NV12 + private static int getSupportsYUV420orNV12(final CodecCapabilities aCodecCaps) { + for (final int supportedColorFormat : supportedColorList) { + for (final int codecColorFormat : aCodecCaps.colorFormats) { + if (codecColorFormat == supportedColorFormat) { + return codecColorFormat; + } + } + } + return COLOR_FORMAT_NOT_SUPPORTED; + } + + // Check if MIME type string has HW prefix (encode or decode, VP8, VP9, and H264) + 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; + } + + // Return list of HW codec prefixes (encode or decode, VP8, VP9, and H264) + private static String[] getAllSupportedHWCodecPrefixes(final boolean aIsEncoder) { + final Set prefixes = new HashSet<>(); + final String[] mimeTypes = {H264_MIME_TYPE, VP8_MIME_TYPE, VP9_MIME_TYPE}; + for (final String mt : mimeTypes) { + prefixes.addAll(Arrays.asList(getSupportedHWCodecPrefixes(mt, aIsEncoder))); + } + return prefixes.toArray(new String[0]); + } + + @WrapForJNI + public static boolean hasHWVP8(final boolean aIsEncoder) { + return getHWCodecCapability(VP8_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI + public static boolean hasHWVP9(final boolean aIsEncoder) { + return getHWCodecCapability(VP9_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI + public static boolean hasHWH264(final boolean aIsEncoder) { + return getHWCodecCapability(H264_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI(calledFrom = "gecko") + public static boolean hasHWH264() { + return getHWCodecCapability(H264_MIME_TYPE, true) + && getHWCodecCapability(H264_MIME_TYPE, false); + } + + @WrapForJNI + @SuppressLint("NewApi") + public static boolean decodes10Bit(final String aMimeType) { + if (Build.VERSION.SDK_INT < 24) { + // Be conservative when we cannot get supported profile. + return false; + } + + final MediaCodecList codecs = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + for (final MediaCodecInfo info : codecs.getCodecInfos()) { + if (info.isEncoder()) { + continue; + } + try { + for (final MediaCodecInfo.CodecProfileLevel pl : + info.getCapabilitiesForType(aMimeType).profileLevels) { + if ((aMimeType.equals(H264_MIME_TYPE) + && pl.profile == MediaCodecInfo.CodecProfileLevel.AVCProfileHigh10) + || (aMimeType.equals(VP9_MIME_TYPE) && is10BitVP9Profile(pl.profile))) { + return true; + } + } + } catch (final IllegalArgumentException e) { + // Type not supported. + continue; + } + } + + return false; + } + + @SuppressLint("NewApi") + private static boolean is10BitVP9Profile(final int profile) { + if (Build.VERSION.SDK_INT < 24) { + // Be conservative when we cannot get supported profile. + return false; + } + + if ((profile == MediaCodecInfo.CodecProfileLevel.VP9Profile2) + || (profile == MediaCodecInfo.CodecProfileLevel.VP9Profile3) + || (profile == MediaCodecInfo.CodecProfileLevel.VP9Profile2HDR) + || (profile == MediaCodecInfo.CodecProfileLevel.VP9Profile3HDR)) { + return true; + } + + if (Build.VERSION.SDK_INT >= 29 + && ((profile == MediaCodecInfo.CodecProfileLevel.VP9Profile2HDR10Plus) + || (profile == MediaCodecInfo.CodecProfileLevel.VP9Profile3HDR10Plus))) { + return true; + } + + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java new file mode 100644 index 0000000000..bab64b92d4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java @@ -0,0 +1,46 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.content.Context; +import android.content.res.Configuration; + +public final class HardwareUtils { + private static final String LOGTAG = "GeckoHardwareUtils"; + + private static volatile boolean sInited; + + // These are all set once, during init. + private static volatile boolean sIsLargeTablet; + private static volatile boolean sIsSmallTablet; + + private HardwareUtils() {} + + public static synchronized void init(final Context context) { + if (sInited) { + return; + } + + // Pre-populate common flags from the context. + final int screenLayoutSize = + context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK; + if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_XLARGE) { + sIsLargeTablet = true; + } else if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_LARGE) { + sIsSmallTablet = true; + } + + sInited = true; + } + + public static boolean isTablet(final Context context) { + if (!sInited) { + init(context); + } + return sIsLargeTablet || sIsSmallTablet; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java new file mode 100644 index 0000000000..96e5c7b311 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java @@ -0,0 +1,12 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import java.util.concurrent.Executor; + +public interface IXPCOMEventTarget extends Executor { + public boolean isOnCurrentThread(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java new file mode 100644 index 0000000000..4ab330f182 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.graphics.Bitmap; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.geckoview.GeckoResult; + +/** Provides access to Gecko's Image processing library. */ +@AnyThread +public class ImageDecoder { + private static ImageDecoder instance; + + private ImageDecoder() {} + + public static ImageDecoder instance() { + if (instance == null) { + instance = new ImageDecoder(); + } + + return instance; + } + + @WrapForJNI(dispatchTo = "gecko", stubName = "Decode") + private static native void nativeDecode( + final String uri, final int desiredLength, GeckoResult 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 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 decode(final @NonNull String uri, final int desiredLength) { + if (uri == null) { + throw new IllegalArgumentException("Uri cannot be null"); + } + + final GeckoResult result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeDecode(uri, desiredLength, result); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + this, + "nativeDecode", + String.class, + uri, + int.class, + desiredLength, + GeckoResult.class, + result); + } + + return result; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java new file mode 100644 index 0000000000..d57147f363 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java @@ -0,0 +1,334 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.graphics.Bitmap; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.mozilla.geckoview.GeckoResult; + +/** + * Represents an Web API image resource as used in web app manifests and media session metadata. + * + * @see Image Resource + */ +@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 Attribute + * spec for sizes + */ + 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 sizes = new ArrayList(); + + 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 size. 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 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 mImages; + + // A sorted size-index list. The list is sorted based on the supported + // sizes of the images in ascending order. + private final List mSizeIndex; + + /* package */ Collection() { + mImages = new ArrayList<>(); + mSizeIndex = new ArrayList<>(); + } + + /** Builder class for the construction of a {@link Collection}. */ + public static class Builder { + final Collection mCollection; + + public Builder() { + mCollection = new Collection(); + } + + /** + * Add an image resource to the collection. + * + * @param image The {@link ImageResource} to be added. + * @return This builder instance. + */ + public @NonNull Builder add(final ImageResource image) { + final int index = mCollection.mImages.size(); + + if (image.sizes == null) { + // Null-sizes are handled the same as `any`. + mCollection.mSizeIndex.add(new SizeIndexPair(0, index)); + } else { + for (final Size size : image.sizes) { + mCollection.mSizeIndex.add(new SizeIndexPair(size.width, index)); + } + } + mCollection.mImages.add(image); + return this; + } + + /** + * Finalize the collection. + * + * @return The final collection. + */ + public @NonNull Collection build() { + Collections.sort(mCollection.mSizeIndex, (a, b) -> Integer.compare(a.width, b.width)); + return mCollection; + } + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("ImageResource.Collection {"); + builder.append("images=["); + + for (final ImageResource image : mImages) { + builder.append(image).append(", "); + } + builder.append("]}"); + return builder.toString(); + } + + /** + * Returns the best suited {@link ImageResource} for the given size. This is usually determined + * based on the minimal difference between the given size and one of the supported widths of an + * image resource. + * + * @param size The target size for the image in pixels. + * @return The best {@link ImageResource} for the given size from this collection. + */ + public @Nullable ImageResource getBest(final int size) { + if (mSizeIndex.isEmpty()) { + return null; + } + int bestMatchIdx = mSizeIndex.get(0).idx; + int lastDiff = size; + for (final SizeIndexPair sizeIndex : mSizeIndex) { + final int diff = Math.abs(sizeIndex.width - size); + if (lastDiff <= diff) { + // With increasing widths, the difference can only grow now. + // 0-width means "any", so we're finished at the first + // entry. + break; + } + lastDiff = diff; + bestMatchIdx = sizeIndex.idx; + } + return mImages.get(bestMatchIdx); + } + + /** + * Get the best version of this image for size size. 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 getBitmap(final int size) { + final ImageResource image = getBest(size); + if (image == null) { + return GeckoResult.fromValue(null); + } + return image.getBitmap(size); + } + + public static Collection fromSizeSrcBundle(final GeckoBundle bundle) { + final Builder builder = new Builder(); + + for (final String key : bundle.keys()) { + final Integer intKey = Integer.valueOf(key); + if (intKey == null) { + Log.e(LOGTAG, "Non-integer image key: " + intKey); + + if (DEBUG) { + throw new RuntimeException("Non-integer image key: " + key); + } + continue; + } + + final String src = getImageValue(bundle.get(key)); + if (src != null) { + // Given the bundle structure, we don't have insight on + // individual image resources so we have to create an + // instance for each size entry. + final ImageResource image = + new ImageResource(src, null, new Size[] {new Size(intKey, intKey)}); + builder.add(image); + } + } + return builder.build(); + } + + private static String getImageValue(final Object value) { + // The image value can either be an object containing images for + // each theme... + if (value instanceof GeckoBundle) { + // We don't support theme_images yet, so let's just return the + // default value. + final GeckoBundle themeImages = (GeckoBundle) value; + final Object defaultImages = themeImages.get("default"); + + if (!(defaultImages instanceof String)) { + if (DEBUG) { + throw new RuntimeException("Unexpected themed_icon value."); + } + Log.e(LOGTAG, "Unexpected themed_icon value."); + return null; + } + + return (String) defaultImages; + } + + // ... or just a URL. + if (value instanceof String) { + return (String) value; + } + + // We never expect it to be something else, so let's error out here. + if (DEBUG) { + throw new RuntimeException("Unexpected image value: " + value); + } + + Log.e(LOGTAG, "Unexpected image value."); + return null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java new file mode 100644 index 0000000000..e0a0d924a9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java @@ -0,0 +1,20 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.view.InputDevice; + +public class InputDeviceUtils { + public static boolean isPointerTypeDevice(final InputDevice inputDevice) { + final int sources = inputDevice.getSources(); + return (sources + & (InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL)) + != 0; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java new file mode 100644 index 0000000000..20a7b95f4d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.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.util; + +import android.annotation.TargetApi; +import android.content.Intent; +import android.net.Uri; +import java.net.URISyntaxException; +import java.util.Locale; + +/** Utilities for Intents. */ +public class IntentUtils { + private IntentUtils() {} + + /** + * Return a Uri instance which is equivalent to uri, but with a guaranteed-lowercase scheme as if + * the API level 16 method Uri.normalizeScheme had been called. + * + * @param uri The URI string to normalize. + * @return The corresponding normalized Uri. + */ + private static Uri normalizeUriScheme(final Uri uri) { + final String scheme = uri.getScheme(); + if (scheme == null) { + return uri; + } + final String lower = scheme.toLowerCase(Locale.ROOT); + if (lower.equals(scheme)) { + return uri; + } + + // Otherwise, return a new URI with a normalized scheme. + return uri.buildUpon().scheme(lower).build(); + } + + /** + * Return a normalized Uri instance that corresponds to the given URI string with cross-API-level + * compatibility. + * + * @param aUri The URI string to normalize. + * @return The corresponding normalized Uri. + */ + public static Uri normalizeUri(final String aUri) { + final Uri normUri = + normalizeUriScheme( + aUri.indexOf(':') >= 0 ? Uri.parse(aUri) : new Uri.Builder().scheme(aUri).build()); + return normUri; + } + + public static boolean isUriSafeForScheme(final String aUri) { + return isUriSafeForScheme(normalizeUri(aUri)); + } + + /** + * Verify whether the given URI is considered safe to load in respect to its scheme. Unsafe URIs + * should be blocked from further handling. + * + * @param aUri The URI instance to test. + * @return Whether the provided URI is considered safe in respect to its scheme. + */ + public static boolean isUriSafeForScheme(final Uri aUri) { + final String scheme = aUri.getScheme(); + if ("tel".equals(scheme) || "sms".equals(scheme)) { + // Bug 794034 - We don't want to pass MWI or USSD codes to the + // dialer, and ensure the Uri class doesn't parse a URI + // containing a fragment ('#') + final String number = aUri.getSchemeSpecificPart(); + if (number.contains("#") || number.contains("*") || aUri.getFragment() != null) { + return false; + } + } + + if (("intent".equals(scheme) || "android-app".equals(scheme))) { + // Bug 1356893 - Rject intents with file data schemes. + return getSafeIntent(aUri) != null; + } + + return true; + } + + /** + * Create a safe intent for the given URI. Intents with file data schemes are considered unsafe. + * + * @param aUri The URI for the intent. + * @return A safe intent for the given URI or null if URI is considered unsafe. + */ + public static Intent getSafeIntent(final Uri aUri) { + final Intent intent; + try { + intent = Intent.parseUri(aUri.toString(), 0); + } catch (final URISyntaxException e) { + return null; + } + + final Uri data = intent.getData(); + if (data != null && "file".equals(normalizeUriScheme(data).getScheme())) { + return null; + } + + // Only open applications which can accept arbitrary data from a browser. + intent.addCategory(Intent.CATEGORY_BROWSABLE); + + // Prevent site from explicitly opening our internal activities, + // which can leak data. + intent.setComponent(null); + nullIntentSelector(intent); + + return intent; + } + + // We create a separate method to better encapsulate the @TargetApi use. + @TargetApi(15) + private static void nullIntentSelector(final Intent intent) { + intent.setSelector(null); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java new file mode 100644 index 0000000000..b8f15c04e3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java @@ -0,0 +1,168 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.telephony.TelephonyManager; + +public class NetworkUtils { + /* + * Keep the below constants in sync with + * http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public enum ConnectionSubType { + CELL_2G("2g"), + CELL_3G("3g"), + CELL_4G("4g"), + ETHERNET("ethernet"), + WIFI("wifi"), + WIMAX("wimax"), + UNKNOWN("unknown"); + + public final String value; + + ConnectionSubType(final String value) { + this.value = value; + } + } + + /* + * Keep the below constants in sync with + * http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public enum NetworkStatus { + UP("up"), + DOWN("down"), + UNKNOWN("unknown"); + + public final String value; + + NetworkStatus(final String value) { + this.value = value; + } + } + + // Connection Type defined in Network Information API v3. + // See Bug 1270401 - current W3C Spec (Editor's Draft) is different, it also contains wimax, + // mixed, unknown. + // W3C spec: http://w3c.github.io/netinfo/#the-connectiontype-enum + public enum ConnectionType { + CELLULAR(0), + BLUETOOTH(1), + ETHERNET(2), + WIFI(3), + OTHER(4), + NONE(5); + + public final int value; + + ConnectionType(final int value) { + this.value = value; + } + } + + public static boolean isConnected(final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return false; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); + } + + /** For mobile connections, maps particular connection subtype to a general 2G, 3G, 4G bucket. */ + public static ConnectionSubType getConnectionSubType( + final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return ConnectionSubType.UNKNOWN; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + + if (networkInfo == null) { + return ConnectionSubType.UNKNOWN; + } + + switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_ETHERNET: + return ConnectionSubType.ETHERNET; + case ConnectivityManager.TYPE_MOBILE: + return getGenericMobileSubtype(networkInfo.getSubtype()); + case ConnectivityManager.TYPE_WIMAX: + return ConnectionSubType.WIMAX; + case ConnectivityManager.TYPE_WIFI: + return ConnectionSubType.WIFI; + default: + return ConnectionSubType.UNKNOWN; + } + } + + public static ConnectionType getConnectionType(final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return ConnectionType.NONE; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + if (networkInfo == null) { + return ConnectionType.NONE; + } + + switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_BLUETOOTH: + return ConnectionType.BLUETOOTH; + case ConnectivityManager.TYPE_ETHERNET: + return ConnectionType.ETHERNET; + // Fallthrough, MOBILE and WIMAX both map to CELLULAR. + case ConnectivityManager.TYPE_MOBILE: + case ConnectivityManager.TYPE_WIMAX: + return ConnectionType.CELLULAR; + case ConnectivityManager.TYPE_WIFI: + return ConnectionType.WIFI; + default: + return ConnectionType.OTHER; + } + } + + public static NetworkStatus getNetworkStatus(final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return NetworkStatus.UNKNOWN; + } + + if (isConnected(connectivityManager)) { + return NetworkStatus.UP; + } + return NetworkStatus.DOWN; + } + + private static ConnectionSubType getGenericMobileSubtype(final int subtype) { + switch (subtype) { + // 2G types: fallthrough 5x + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_IDEN: + return ConnectionSubType.CELL_2G; + // 3G types: fallthrough 9x + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + case TelephonyManager.NETWORK_TYPE_EHRPD: + case TelephonyManager.NETWORK_TYPE_HSPAP: + return ConnectionSubType.CELL_3G; + // 4G - just one type! + case TelephonyManager.NETWORK_TYPE_LTE: + return ConnectionSubType.CELL_4G; + default: + return ConnectionSubType.UNKNOWN; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java new file mode 100644 index 0000000000..2fb4015f41 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java @@ -0,0 +1,149 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This code is based on AOSP /libcore/luni/src/main/java/java/net/ProxySelectorImpl.java + +package org.mozilla.gecko.util; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.URLConnection; +import java.util.List; + +public class ProxySelector { + public static URLConnection openConnectionWithProxy(final URI uri) throws IOException { + final java.net.ProxySelector ps = java.net.ProxySelector.getDefault(); + Proxy proxy = Proxy.NO_PROXY; + if (ps != null) { + final List proxies = ps.select(uri); + if (proxies != null && !proxies.isEmpty()) { + proxy = proxies.get(0); + } + } + + return uri.toURL().openConnection(proxy); + } + + public ProxySelector() {} + + public Proxy select(final String scheme, final String host) { + int port = -1; + Proxy proxy = null; + String nonProxyHostsKey = null; + boolean httpProxyOkay = true; + if ("http".equalsIgnoreCase(scheme)) { + port = 80; + nonProxyHostsKey = "http.nonProxyHosts"; + proxy = lookupProxy("http.proxyHost", "http.proxyPort", Proxy.Type.HTTP, port); + } else if ("https".equalsIgnoreCase(scheme)) { + port = 443; + nonProxyHostsKey = "https.nonProxyHosts"; // RI doesn't support this + proxy = lookupProxy("https.proxyHost", "https.proxyPort", Proxy.Type.HTTP, port); + } else if ("ftp".equalsIgnoreCase(scheme)) { + port = 80; // not 21 as you might guess + nonProxyHostsKey = "ftp.nonProxyHosts"; + proxy = lookupProxy("ftp.proxyHost", "ftp.proxyPort", Proxy.Type.HTTP, port); + } else if ("socket".equalsIgnoreCase(scheme)) { + httpProxyOkay = false; + } else { + return Proxy.NO_PROXY; + } + + if (nonProxyHostsKey != null && isNonProxyHost(host, System.getProperty(nonProxyHostsKey))) { + return Proxy.NO_PROXY; + } + + if (proxy != null) { + return proxy; + } + + if (httpProxyOkay) { + proxy = lookupProxy("proxyHost", "proxyPort", Proxy.Type.HTTP, port); + if (proxy != null) { + return proxy; + } + } + + proxy = lookupProxy("socksProxyHost", "socksProxyPort", Proxy.Type.SOCKS, 1080); + if (proxy != null) { + return proxy; + } + + return Proxy.NO_PROXY; + } + + /** Returns the proxy identified by the {@code hostKey} system property, or null. */ + @Nullable + private Proxy lookupProxy( + final String hostKey, final String portKey, final Proxy.Type type, final int defaultPort) { + final String host = System.getProperty(hostKey); + if (TextUtils.isEmpty(host)) { + return null; + } + + final int port = getSystemPropertyInt(portKey, defaultPort); + if (port == -1) { + // Port can be -1. See bug 1270529. + return null; + } + + return new Proxy(type, InetSocketAddress.createUnresolved(host, port)); + } + + private int getSystemPropertyInt(final String key, final int defaultValue) { + final String string = System.getProperty(key); + if (string != null) { + try { + return Integer.parseInt(string); + } catch (final NumberFormatException ignored) { + } + } + return defaultValue; + } + + /** + * Returns true if the {@code nonProxyHosts} system property pattern exists and matches {@code + * host}. + */ + private boolean isNonProxyHost(final String host, final String nonProxyHosts) { + if (host == null || nonProxyHosts == null) { + return false; + } + + // construct pattern + final StringBuilder patternBuilder = new StringBuilder(); + for (int i = 0; i < nonProxyHosts.length(); i++) { + final char c = nonProxyHosts.charAt(i); + switch (c) { + case '.': + patternBuilder.append("\\."); + break; + case '*': + patternBuilder.append(".*"); + break; + default: + patternBuilder.append(c); + } + } + // check whether the host is the nonProxyHosts. + final String pattern = patternBuilder.toString(); + return host.matches(pattern); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java new file mode 100644 index 0000000000..00625800c9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java @@ -0,0 +1,145 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import org.mozilla.gecko.annotation.RobocopTarget; + +public final class ThreadUtils { + private static final String LOGTAG = "ThreadUtils"; + + /** + * Controls the action taken when a method like {@link + * ThreadUtils#assertOnUiThread(AssertBehavior)} detects a problem. + */ + public enum AssertBehavior { + NONE, + THROW, + } + + private static final Thread sUiThread = Looper.getMainLooper().getThread(); + private static final Handler sUiHandler = new Handler(Looper.getMainLooper()); + + // Referenced directly from GeckoAppShell in highly performance-sensitive code (The extra + // function call of the getter was harming performance. (Bug 897123)) + // Once Bug 709230 is resolved we should reconsider this as ProGuard should be able to optimise + // this out at compile time. + public static Handler sGeckoHandler; + public static volatile Thread sGeckoThread; + + public static Thread getUiThread() { + return sUiThread; + } + + public static Handler getUiHandler() { + return sUiHandler; + } + + /** + * Runs the provided runnable on the UI thread. If this method is called on the UI thread the + * runnable will be executed synchronously. + * + * @param runnable the runnable to be executed. + */ + public static void runOnUiThread(final Runnable runnable) { + // We're on the UI thread already, let's just run this + if (isOnUiThread()) { + runnable.run(); + return; + } + + postToUiThread(runnable); + } + + public static void postToUiThread(final Runnable runnable) { + sUiHandler.post(runnable); + } + + public static void postToUiThreadDelayed(final Runnable runnable, final long delayMillis) { + sUiHandler.postDelayed(runnable, delayMillis); + } + + public static void removeUiThreadCallbacks(final Runnable runnable) { + sUiHandler.removeCallbacks(runnable); + } + + public static Handler getBackgroundHandler() { + return GeckoBackgroundThread.getHandler(); + } + + public static void postToBackgroundThread(final Runnable runnable) { + GeckoBackgroundThread.post(runnable); + } + + public static void assertOnUiThread(final AssertBehavior assertBehavior) { + assertOnThread(getUiThread(), assertBehavior); + } + + public static void assertOnUiThread() { + assertOnThread(getUiThread(), AssertBehavior.THROW); + } + + @RobocopTarget + public static void assertOnGeckoThread() { + assertOnThread(sGeckoThread, AssertBehavior.THROW); + } + + public static void assertOnThread(final Thread expectedThread, final AssertBehavior behavior) { + assertOnThreadComparison(expectedThread, behavior, true); + } + + private static void assertOnThreadComparison( + final Thread expectedThread, final AssertBehavior behavior, final boolean expected) { + final Thread currentThread = Thread.currentThread(); + final long currentThreadId = currentThread.getId(); + final long expectedThreadId = expectedThread.getId(); + + if ((currentThreadId == expectedThreadId) == expected) { + return; + } + + final String message; + if (expected) { + message = + "Expected thread " + + expectedThreadId + + " (\"" + + expectedThread.getName() + + "\"), but running on thread " + + currentThreadId + + " (\"" + + currentThread.getName() + + "\")"; + } else { + message = + "Expected anything but " + + expectedThreadId + + " (\"" + + expectedThread.getName() + + "\"), but running there."; + } + + final IllegalThreadStateException e = new IllegalThreadStateException(message); + + switch (behavior) { + case THROW: + throw e; + default: + Log.e(LOGTAG, "Method called on wrong thread!", e); + } + } + + public static boolean isOnUiThread() { + return isOnThread(getUiThread()); + } + + @RobocopTarget + public static boolean isOnThread(final Thread thread) { + return (Thread.currentThread().getId() == thread.getId()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja new file mode 100644 index 0000000000..f704bbc775 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 2; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +public final class XPCOMError { + /** Check if the error code corresponds to a failure */ + public static boolean failed(long err) { + return (err & 0x80000000L) != 0; + } + + /** Check if the error code corresponds to a failure */ + public static boolean succeeded(long err) { + return !failed(err); + } + + /** Extract the error code part of the error message */ + public static int getErrorCode(long err) { + return (int)(err & 0xffffL); + } + + /** Extract the error module part of the error message */ + public static int getErrorModule(long err) { + return (int)(((err >> 16) - NS_ERROR_MODULE_BASE_OFFSET) & 0x1fffL); + } + + public static final int NS_ERROR_MODULE_BASE_OFFSET = {{ MODULE_BASE_OFFSET }}; + +{% for mod, val in modules %} + public static final int NS_ERROR_MODULE_{{ mod }} = {{ val }}; +{% endfor %} + +{% for error, val in errors %} + public static final long {{ error }} = 0x{{ "%X" % val }}L; +{% endfor %} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java new file mode 100644 index 0000000000..31eac71a66 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java @@ -0,0 +1,170 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.util; + +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +/** + * Wrapper for nsIEventTarget, enabling seamless dispatch of java runnables to Gecko event queues. + */ +@WrapForJNI +public final class XPCOMEventTarget extends JNIObject implements IXPCOMEventTarget { + @Override + public void execute(final Runnable runnable) { + dispatchNative(new JNIRunnable(runnable)); + } + + public static synchronized IXPCOMEventTarget mainThread() { + if (mMainThread == null) { + mMainThread = new AsyncProxy("main"); + } + return mMainThread; + } + + private static IXPCOMEventTarget mMainThread = null; + + public static synchronized IXPCOMEventTarget launcherThread() { + if (mLauncherThread == null) { + mLauncherThread = new AsyncProxy("launcher"); + } + return mLauncherThread; + } + + private static IXPCOMEventTarget mLauncherThread = null; + + /** + * Runs the provided runnable on the launcher thread. If this method is called from the launcher + * thread itself, the runnable will be executed immediately and synchronously. + */ + public static void runOnLauncherThread(@NonNull final Runnable runnable) { + final IXPCOMEventTarget launcherThread = launcherThread(); + if (launcherThread.isOnCurrentThread()) { + // We're already on the launcher thread, just execute the runnable + runnable.run(); + return; + } + + launcherThread.execute(runnable); + } + + public static void assertOnLauncherThread() { + if (BuildConfig.DEBUG_BUILD && !launcherThread().isOnCurrentThread()) { + throw new AssertionError("Expected to be running on XPCOM launcher thread"); + } + } + + public static void assertNotOnLauncherThread() { + if (BuildConfig.DEBUG_BUILD && launcherThread().isOnCurrentThread()) { + throw new AssertionError("Expected to not be running on XPCOM launcher thread"); + } + } + + private static synchronized IXPCOMEventTarget getTarget(final String name) { + if (name.equals("launcher")) { + return mLauncherThread; + } else if (name.equals("main")) { + return mMainThread; + } else { + throw new RuntimeException("Attempt to assign to unknown thread named " + name); + } + } + + @WrapForJNI + private static synchronized void setTarget(final String name, final XPCOMEventTarget target) { + if (name.equals("main")) { + mMainThread = target; + } else if (name.equals("launcher")) { + mLauncherThread = target; + } else { + throw new RuntimeException("Attempt to assign to unknown thread named " + name); + } + + // Ensure that we see the right name in the Java debugger. We don't do this for mMainThread + // because its name was already set (in this context, "main" is the GeckoThread). + if (mMainThread != target) { + target.execute( + () -> { + Thread.currentThread().setName(name); + }); + } + } + + @Override + public native boolean isOnCurrentThread(); + + private native void dispatchNative(final JNIRunnable runnable); + + @WrapForJNI + private static synchronized void resolveAndDispatch(final String name, final Runnable runnable) { + getTarget(name).execute(runnable); + } + + private static native void resolveAndDispatchNative(final String name, final Runnable runnable); + + @Override + protected native void disposeNative(); + + @WrapForJNI + private static final class JNIRunnable { + JNIRunnable(final Runnable inner) { + mInner = inner; + } + + @WrapForJNI + void run() { + mInner.run(); + } + + private Runnable mInner; + } + + private static final class AsyncProxy implements IXPCOMEventTarget { + private String mTargetName; + + public AsyncProxy(final String targetName) { + mTargetName = targetName; + } + + @Override + public void execute(final Runnable runnable) { + final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName); + + if (target != null && target instanceof XPCOMEventTarget) { + target.execute(runnable); + return; + } + + GeckoThread.queueNativeCallUntil( + GeckoThread.State.JNI_READY, + XPCOMEventTarget.class, + "resolveAndDispatchNative", + String.class, + mTargetName, + Runnable.class, + runnable); + } + + @Override + public boolean isOnCurrentThread() { + final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName); + + // If target is not yet a XPCOMEventTarget then JNI is not + // initialized yet. If JNI is not initialized yet, then we cannot + // possibly be running on a target with an XPCOMEventTarget. + if (target == null || !(target instanceof XPCOMEventTarget)) { + return false; + } + + // Otherwise we have a real XPCOMEventTarget, so we can delegate + // this call to it. + return target.isOnCurrentThread(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java new file mode 100644 index 0000000000..f8342cbfa7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java @@ -0,0 +1,16 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; + +/** This represents a decision to allow or deny a request. */ +@AnyThread +public enum AllowOrDeny { + ALLOW, + DENY; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java new file mode 100644 index 0000000000..e8a004df17 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java @@ -0,0 +1,1445 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion. + * + *

    The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates. + * + *

    The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and + * storing of login entries. + * + *

    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. + * + *

    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. + * + *

    Examples

    + * + *

    Autocomplete/Fetch API

    + * + *

    GeckoView loads https://example.com which contains (for the purpose of this + * example) elements resembling a login form, e.g., + * + *

    
    + *   <form>
    + *     <input type="text" placeholder="username">
    + *     <input type="password" placeholder="password">
    + *     <input type="submit" value="submit">
    + *   </form>
    + * 
    + * + *

    With the document parsed and the login input fields identified, GeckoView dispatches a + * StorageDelegate.onLoginFetch("example.com") request to fetch logins for the + * given domain. + * + *

    Based on the provided login entries, GeckoView will attempt to autofill the login input + * fields, if there is only one suitable login entry option. + * + *

    In the case of multiple valid login entry options, GeckoView dispatches a + * GeckoSession.PromptDelegate.onLoginSelect request, which allows for user-choice + * delegation. + * + *

    Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login + * input fields. + * + *

    Update API

    + * + *

    When the user submits some login input fields, GeckoView dispatches another + * StorageDelegate.onLoginFetch("example.com") request to check whether the + * submitted login exists or whether it's a new or updated login entry. + * + *

    If the submitted login is already contained as-is in the collection returned by + * onLoginFetch, then GeckoView dispatches StorageDelegate.onLoginUsed with the + * submitted login entry. + * + *

    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. + * + *

    Save API

    + * + *

    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 GeckoSession.PromptDelegate.onLoginSave(session, request) + * with the provided credentials. + * + *

    The app may dismiss the prompt request via + * return GeckoResult.fromValue(prompt.dismiss()) which terminates this saving request, or + * confirm it via return GeckoResult.fromValue(prompt.confirm(login)) where login + * either holds the credentials originally provided by the prompt request ( + * prompt.logins[0]) or a new or modified login entry. + * + *

    The login entry returned in a confirmed save prompt is used to request for saving in the + * runtime delegate via StorageDelegate.onLoginSave(login). If the app has already + * stored the entry during the prompt request handling, it may ignore this storage saving request. + *
    + * + * @see GeckoRuntime#setAutocompleteStorageDelegate
    + * @see GeckoSession#setPromptDelegate
    + * @see GeckoSession.PromptDelegate#onLoginSave
    + * @see GeckoSession.PromptDelegate#onLoginSelect + */ +public class Autocomplete { + private static final String LOGTAG = "Autocomplete"; + private static final boolean DEBUG = false; + + protected Autocomplete() {} + + /** Holds credit card information for a specific entry. */ + public static class CreditCard { + private static final String GUID_KEY = "guid"; + private static final String NAME_KEY = "name"; + private static final String NUMBER_KEY = "number"; + private static final String EXP_MONTH_KEY = "expMonth"; + private static final String EXP_YEAR_KEY = "expYear"; + + /** The unique identifier for this login entry. */ + public final @Nullable String guid; + + /** The full name as it appears on the credit card. */ + public final @NonNull String name; + + /** The credit card number. */ + public final @NonNull String number; + + /** The expiration month. */ + public final @NonNull String expirationMonth; + + /** The expiration year. */ + public final @NonNull String expirationYear; + + // For tests only. + @AnyThread + protected CreditCard() { + guid = null; + name = ""; + number = ""; + expirationMonth = ""; + expirationYear = ""; + } + + @AnyThread + /* package */ CreditCard(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + name = bundle.getString(NAME_KEY, ""); + number = bundle.getString(NUMBER_KEY, ""); + expirationMonth = bundle.getString(EXP_MONTH_KEY, ""); + expirationYear = bundle.getString(EXP_YEAR_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("CreditCard {"); + builder + .append("guid=") + .append(guid) + .append(", name=") + .append(name) + .append(", number=") + .append(number) + .append(", expirationMonth=") + .append(expirationMonth) + .append(", expirationYear=") + .append(expirationYear) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(7); + bundle.putString(GUID_KEY, guid); + bundle.putString(NAME_KEY, name); + bundle.putString(NUMBER_KEY, number); + if (expirationMonth != null) { + bundle.putString(EXP_MONTH_KEY, expirationMonth); + } + if (expirationYear != null) { + bundle.putString(EXP_YEAR_KEY, expirationYear); + } + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(7); + } + + /** + * Finalize the {@link CreditCard} instance. + * + * @return The {@link CreditCard} instance. + */ + @AnyThread + public @NonNull CreditCard build() { + return new CreditCard(mBundle); + } + + /** + * Set the unique identifier for this credit card entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the name for this credit card entry. + * + * @param name The full name as it appears on the credit card. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder name(final @Nullable String name) { + mBundle.putString(NAME_KEY, name); + return this; + } + + /** + * Set the number for this credit card entry. + * + * @param number The credit card number string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder number(final @Nullable String number) { + mBundle.putString(NUMBER_KEY, number); + return this; + } + + /** + * Set the expiration month for this credit card entry. + * + * @param expMonth The expiration month string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder expirationMonth(final @Nullable String expMonth) { + mBundle.putString(EXP_MONTH_KEY, expMonth); + return this; + } + + /** + * Set the expiration year for this credit card entry. + * + * @param expYear The expiration year string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder expirationYear(final @Nullable String expYear) { + mBundle.putString(EXP_YEAR_KEY, expYear); + return this; + } + } + } + + /** Holds address information for a specific entry. */ + public static class Address { + private static final String GUID_KEY = "guid"; + private static final String NAME_KEY = "name"; + private static final String GIVEN_NAME_KEY = "givenName"; + private static final String ADDITIONAL_NAME_KEY = "additionalName"; + private static final String FAMILY_NAME_KEY = "familyName"; + private static final String ORGANIZATION_KEY = "organization"; + private static final String STREET_ADDRESS_KEY = "streetAddress"; + private static final String ADDRESS_LEVEL1_KEY = "addressLevel1"; + private static final String ADDRESS_LEVEL2_KEY = "addressLevel2"; + private static final String ADDRESS_LEVEL3_KEY = "addressLevel3"; + private static final String POSTAL_CODE_KEY = "postalCode"; + private static final String COUNTRY_KEY = "country"; + private static final String TEL_KEY = "tel"; + private static final String EMAIL_KEY = "email"; + private static final byte bundleCapacity = 14; + + /** The unique identifier for this address entry. */ + public final @Nullable String guid; + + /** The full name. */ + public final @NonNull String name; + + /** The given (first) name. */ + public final @NonNull String givenName; + + /** An additional name, if available. */ + public final @NonNull String additionalName; + + /** The family name. */ + public final @NonNull String familyName; + + /** The name of the company, if applicable. */ + public final @NonNull String organization; + + /** The (multiline) street address. */ + public final @NonNull String streetAddress; + + /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */ + public final @NonNull String addressLevel1; + + /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */ + public final @NonNull String addressLevel2; + + /** + * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided. + */ + public final @NonNull String addressLevel3; + + /** The postal code. */ + public final @NonNull String postalCode; + + /** The country string in ISO 3166. */ + public final @NonNull String country; + + /** The telephone number string. */ + public final @NonNull String tel; + + /** The email address. */ + public final @NonNull String email; + + // For tests only. + @AnyThread + protected Address() { + guid = null; + name = ""; + givenName = ""; + additionalName = ""; + familyName = ""; + organization = ""; + streetAddress = ""; + addressLevel1 = ""; + addressLevel2 = ""; + addressLevel3 = ""; + postalCode = ""; + country = ""; + tel = ""; + email = ""; + } + + @AnyThread + /* package */ Address(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + name = bundle.getString(NAME_KEY, ""); + givenName = bundle.getString(GIVEN_NAME_KEY, ""); + additionalName = bundle.getString(ADDITIONAL_NAME_KEY, ""); + familyName = bundle.getString(FAMILY_NAME_KEY, ""); + organization = bundle.getString(ORGANIZATION_KEY, ""); + streetAddress = bundle.getString(STREET_ADDRESS_KEY, ""); + addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, ""); + addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, ""); + addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, ""); + postalCode = bundle.getString(POSTAL_CODE_KEY, ""); + country = bundle.getString(COUNTRY_KEY, ""); + tel = bundle.getString(TEL_KEY, ""); + email = bundle.getString(EMAIL_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("Address {"); + builder + .append("guid=") + .append(guid) + .append(", givenName=") + .append(givenName) + .append(", additionalName=") + .append(additionalName) + .append(", familyName=") + .append(familyName) + .append(", organization=") + .append(organization) + .append(", streetAddress=") + .append(streetAddress) + .append(", addressLevel1=") + .append(addressLevel1) + .append(", addressLevel2=") + .append(addressLevel2) + .append(", addressLevel3=") + .append(addressLevel3) + .append(", postalCode=") + .append(postalCode) + .append(", country=") + .append(country) + .append(", tel=") + .append(tel) + .append(", email=") + .append(email) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(bundleCapacity); + bundle.putString(GUID_KEY, guid); + bundle.putString(NAME_KEY, name); + bundle.putString(GIVEN_NAME_KEY, givenName); + bundle.putString(ADDITIONAL_NAME_KEY, additionalName); + bundle.putString(FAMILY_NAME_KEY, familyName); + bundle.putString(ORGANIZATION_KEY, organization); + bundle.putString(STREET_ADDRESS_KEY, streetAddress); + bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1); + bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2); + bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3); + bundle.putString(POSTAL_CODE_KEY, postalCode); + bundle.putString(COUNTRY_KEY, country); + bundle.putString(TEL_KEY, tel); + bundle.putString(EMAIL_KEY, email); + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(bundleCapacity); + } + + /** + * Finalize the {@link Address} instance. + * + * @return The {@link Address} instance. + */ + @AnyThread + public @NonNull Address build() { + return new Address(mBundle); + } + + /** + * Set the unique identifier for this address entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the full name for this address entry. + * + * @param name The full name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder name(final @Nullable String name) { + mBundle.putString(NAME_KEY, name); + return this; + } + + /** + * Set the given name for this address entry. + * + * @param givenName The given name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder givenName(final @Nullable String givenName) { + mBundle.putString(GIVEN_NAME_KEY, givenName); + return this; + } + + /** + * Set the additional name for this address entry. + * + * @param additionalName The additional name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder additionalName(final @Nullable String additionalName) { + mBundle.putString(ADDITIONAL_NAME_KEY, additionalName); + return this; + } + + /** + * Set the family name for this address entry. + * + * @param familyName The family name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder familyName(final @Nullable String familyName) { + mBundle.putString(FAMILY_NAME_KEY, familyName); + return this; + } + + /** + * Set the company name for this address entry. + * + * @param organization The company name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder organization(final @Nullable String organization) { + mBundle.putString(ORGANIZATION_KEY, organization); + return this; + } + + /** + * Set the street address for this address entry. + * + * @param streetAddress The street address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder streetAddress(final @Nullable String streetAddress) { + mBundle.putString(STREET_ADDRESS_KEY, streetAddress); + return this; + } + + /** + * Set the level 1 address for this address entry. + * + * @param addressLevel1 The level 1 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) { + mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1); + return this; + } + + /** + * Set the level 2 address for this address entry. + * + * @param addressLevel2 The level 2 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) { + mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2); + return this; + } + + /** + * Set the level 3 address for this address entry. + * + * @param addressLevel3 The level 3 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) { + mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3); + return this; + } + + /** + * Set the postal code for this address entry. + * + * @param postalCode The postal code string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder postalCode(final @Nullable String postalCode) { + mBundle.putString(POSTAL_CODE_KEY, postalCode); + return this; + } + + /** + * Set the country code for this address entry. + * + * @param country The country string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder country(final @Nullable String country) { + mBundle.putString(COUNTRY_KEY, country); + return this; + } + + /** + * Set the telephone number for this address entry. + * + * @param tel The telephone number string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder tel(final @Nullable String tel) { + mBundle.putString(TEL_KEY, tel); + return this; + } + + /** + * Set the email address for this address entry. + * + * @param email The email address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder email(final @Nullable String email) { + mBundle.putString(EMAIL_KEY, email); + return this; + } + } + } + + /** Holds login information for a specific entry. */ + public static class LoginEntry { + private static final String GUID_KEY = "guid"; + private static final String ORIGIN_KEY = "origin"; + private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin"; + private static final String HTTP_REALM_KEY = "httpRealm"; + private static final String USERNAME_KEY = "username"; + private static final String PASSWORD_KEY = "password"; + + /** The unique identifier for this login entry. */ + public final @Nullable String guid; + + /** The origin this login entry applies to. */ + public final @NonNull String origin; + + /** + * The origin this login entry was submitted to. This only applies to form-based login entries. + * It's derived from the action attribute set on the form element. + */ + public final @Nullable String formActionOrigin; + + /** + * The HTTP realm this login entry was requested for. This only applies to non-form-based login + * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see + * RFC2617 for details. + */ + public final @Nullable String httpRealm; + + /** The username for this login entry. */ + public final @NonNull String username; + + /** The password for this login entry. */ + public final @NonNull String password; + + // For tests only. + @AnyThread + protected LoginEntry() { + guid = null; + origin = ""; + formActionOrigin = null; + httpRealm = null; + username = ""; + password = ""; + } + + @AnyThread + /* package */ LoginEntry(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + origin = bundle.getString(ORIGIN_KEY, ""); + formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY); + httpRealm = bundle.getString(HTTP_REALM_KEY); + username = bundle.getString(USERNAME_KEY, ""); + password = bundle.getString(PASSWORD_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("LoginEntry {"); + builder + .append("guid=") + .append(guid) + .append(", origin=") + .append(origin) + .append(", formActionOrigin=") + .append(formActionOrigin) + .append(", httpRealm=") + .append(httpRealm) + .append(", username=") + .append(username) + .append(", password=") + .append(password) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(6); + bundle.putString(GUID_KEY, guid); + bundle.putString(ORIGIN_KEY, origin); + bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin); + bundle.putString(HTTP_REALM_KEY, httpRealm); + bundle.putString(USERNAME_KEY, username); + bundle.putString(PASSWORD_KEY, password); + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(6); + } + + /** + * Finalize the {@link LoginEntry} instance. + * + * @return The {@link LoginEntry} instance. + */ + @AnyThread + public @NonNull LoginEntry build() { + return new LoginEntry(mBundle); + } + + /** + * Set the unique identifier for this login entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the origin this login entry applies to. + * + * @param origin The origin string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder origin(final @NonNull String origin) { + mBundle.putString(ORIGIN_KEY, origin); + return this; + } + + /** + * Set the origin this login entry was submitted to. + * + * @param formActionOrigin The form action origin string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) { + mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin); + return this; + } + + /** + * Set the HTTP realm this login entry was requested for. + * + * @param httpRealm The HTTP realm string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder httpRealm(final @Nullable String httpRealm) { + mBundle.putString(HTTP_REALM_KEY, httpRealm); + return this; + } + + /** + * Set the username for this login entry. + * + * @param username The username string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder username(final @NonNull String username) { + mBundle.putString(USERNAME_KEY, username); + return this; + } + + /** + * Set the password for this login entry. + * + * @param password The password string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder password(final @NonNull String password) { + mBundle.putString(PASSWORD_KEY, password); + return this; + } + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {UsedField.PASSWORD}) + public @interface LSUsedField {} + + // Sync with UsedField in GeckoViewAutocomplete.jsm. + /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */ + public static class UsedField { + /** The password field of a login entry. */ + public static final int PASSWORD = 1; + + protected UsedField() {} + } + + /** + * Implement this interface to handle runtime login storage requests. Login storage events include + * login entry requests for autofill and autocompletion of login input fields. This delegate is + * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}. + */ + public interface StorageDelegate { + /** + * Request login entries for a given domain. While processing the web document, we have + * identified elements resembling login input fields suitable for autofill. We will attempt to + * match the provided login information to the identified input fields. + * + * @param domain The domain string for the requested logins. + * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing + * the existing logins for the given domain. + */ + @UiThread + default @Nullable GeckoResult onLoginFetch(@NonNull final String domain) { + return null; + } + + /** + * Request login entries for all domains. + * + * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing + * the existing logins. + */ + @UiThread + default @Nullable GeckoResult onLoginFetch() { + return null; + } + + /** + * Request credit card entries. While processing the web document, we have identified elements + * resembling credit card input fields suitable for autofill. We will attempt to match the + * provided credit card information to the identified input fields. + * + * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing + * the existing credit cards. + */ + @UiThread + default @Nullable GeckoResult onCreditCardFetch() { + return null; + } + + /** + * Request address entries. While processing the web document, we have identified elements + * resembling address input fields suitable for autofill. We will attempt to match the provided + * address information to the identified input fields. + * + * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the + * existing addresses. + */ + @UiThread + default @Nullable GeckoResult onAddressFetch() { + return null; + } + + /** + * Request saving or updating of the given login entry. This is triggered by confirming a {@link + * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request. + * + * @param login The {@link LoginEntry} as confirmed by the prompt request. + */ + @UiThread + default void onLoginSave(@NonNull final LoginEntry login) {} + + /** + * Request saving or updating of the given credit card entry. This is triggered by confirming a + * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request. + * + * @param creditCard The {@link CreditCard} as confirmed by the prompt request. + */ + @UiThread + default void onCreditCardSave(@NonNull CreditCard creditCard) {} + + /** + * Request saving or updating of the given address entry. This is triggered by confirming a + * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request. + * + * @param address The {@link Address} as confirmed by the prompt request. + */ + @UiThread + default void onAddressSave(@NonNull Address address) {} + + /** + * Notify that the given login was used to autofill login input fields. This is triggered by + * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}. + * + * @param login The {@link LoginEntry} that was used for the autofilling. + * @param usedFields The login entry fields used for autofilling. A combination of {@link + * UsedField}. + */ + @UiThread + default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {} + } + + /** + * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and + * {@link Autocomplete.SelectOption}. + */ + public abstract static class Option { + /* 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 extends Option { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE}) + public @interface SaveOptionHint {} + + /** Hint types for login saving requests. */ + public static class Hint { + public static final int NONE = 0; + + /** Auto-generated password. Notify but do not prompt the user for saving. */ + public static final int GENERATED = 1 << 0; + + /** + * Potentially non-login data. The form data entered may be not login credentials but other + * forms of input like credit card numbers. Note that this could be valid login data in same + * cases, e.g., some banks may expect credit card numbers in the username field. + */ + public static final int LOW_CONFIDENCE = 1 << 1; + + protected Hint() {} + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) { + super(value, hint); + } + } + + /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */ + public abstract static class SelectOption extends Option { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Hint.NONE, + Hint.GENERATED, + Hint.INSECURE_FORM, + Hint.DUPLICATE_USERNAME, + Hint.MATCHING_ORIGIN + }) + public @interface SelectOptionHint {} + + /** Hint types for selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Auto-generated password. A new password-only login entry containing a secure generated + * password. + */ + public static final int GENERATED = 1 << 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + + /** + * The username is shared with another login entry. There are multiple login entries in the + * options that share the same username. You may have to disambiguate the login entry, e.g., + * using the last date of modification and its origin. + */ + public static final int DUPLICATE_USERNAME = 1 << 2; + + /** + * The login entry's origin matches the login form origin. The login was saved from the same + * origin it is being requested for, rather than for a subdomain. + */ + public static final int MATCHING_ORIGIN = 1 << 3; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) { + super(value, hint); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("SelectOption {"); + builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}"); + return builder.toString(); + } + } + + /** Holds information required to process login saving requests. */ + public static class LoginSaveOption extends SaveOption { + /** + * Construct a login save option. + * + * @param value The {@link LoginEntry} login entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a login save option. + * + * @param value The {@link LoginEntry} login entry to be saved. + */ + public LoginSaveOption(final @NonNull LoginEntry value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process address saving requests. */ + public static class AddressSaveOption extends SaveOption

    { + /** + * Construct a address save option. + * + * @param value The {@link Address} address entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct an address save option. + * + * @param value The {@link Address} address entry to be saved. + */ + public AddressSaveOption(final @NonNull Address value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process credit card saving requests. */ + public static class CreditCardSaveOption extends SaveOption { + /** + * Construct a credit card save option. + * + * @param value The {@link CreditCard} credit card entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ CreditCardSaveOption( + final @NonNull CreditCard value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a credit card save option. + * + * @param value The {@link CreditCard} credit card entry to be saved. + */ + public CreditCardSaveOption(final @NonNull CreditCard value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process login selection requests. */ + public static class LoginSelectOption extends SelectOption { + /** + * Construct a login select option. + * + * @param value The {@link LoginEntry} login entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ LoginSelectOption( + final @NonNull LoginEntry value, final @SelectOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a login select option. + * + * @param value The {@link LoginEntry} login entry selection option. + */ + public LoginSelectOption(final @NonNull LoginEntry value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final LoginEntry value = new LoginEntry(bundle.getBundle("value")); + + return new LoginSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process credit card selection requests. */ + public static class CreditCardSelectOption extends SelectOption { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.INSECURE_FORM}) + public @interface CreditCardSelectHint {} + + /** Hint types for credit card selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + } + + /** + * Construct a credit card select option. + * + * @param value The {@link LoginEntry} credit card entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ CreditCardSelectOption( + final @NonNull CreditCard value, final @CreditCardSelectHint int hint) { + super(value, hint); + } + + /** + * Construct a credit card select option. + * + * @param value The {@link CreditCard} credit card entry selection option. + */ + public CreditCardSelectOption(final @NonNull CreditCard value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull CreditCardSelectOption fromBundle( + final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final CreditCard value = new CreditCard(bundle.getBundle("value")); + + return new CreditCardSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process address selection requests. */ + public static class AddressSelectOption extends SelectOption
    { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.INSECURE_FORM}) + public @interface AddressSelectHint {} + + /** Hint types for credit card selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + } + + /** + * Construct a credit card select option. + * + * @param value The {@link LoginEntry} credit card entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ AddressSelectOption( + final @NonNull Address value, final @AddressSelectHint int hint) { + super(value, hint); + } + + /** + * Construct a address select option. + * + * @param value The {@link Address} address entry selection option. + */ + public AddressSelectOption(final @NonNull Address value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull AddressSelectOption fromBundle( + final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final Address value = new Address(bundle.getBundle("value")); + + return new AddressSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /* package */ static final class StorageProxy implements BundleEventListener { + private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login"; + private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard"; + private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address"; + private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login"; + private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard"; + private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address"; + private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login"; + + private @Nullable StorageDelegate mDelegate; + + public StorageProxy() {} + + private void registerListener() { + EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null); + EventDispatcher.getInstance() + .registerUiThreadListener( + this, + FETCH_LOGIN_EVENT, + FETCH_CREDIT_CARD_EVENT, + FETCH_ADDRESS_EVENT, + SAVE_LOGIN_EVENT, + SAVE_CREDIT_CARD_EVENT, + SAVE_ADDRESS_EVENT, + USED_LOGIN_EVENT); + } + + private void unregisterListener() { + EventDispatcher.getInstance() + .unregisterUiThreadListener( + this, + FETCH_LOGIN_EVENT, + FETCH_CREDIT_CARD_EVENT, + FETCH_ADDRESS_EVENT, + SAVE_LOGIN_EVENT, + SAVE_CREDIT_CARD_EVENT, + SAVE_ADDRESS_EVENT, + USED_LOGIN_EVENT); + } + + public synchronized void setDelegate(final @Nullable StorageDelegate delegate) { + if (mDelegate == delegate) { + return; + } + if (mDelegate != null) { + unregisterListener(); + } + + mDelegate = delegate; + + if (mDelegate != null) { + registerListener(); + } + } + + public synchronized @Nullable StorageDelegate getDelegate() { + return mDelegate; + } + + @Override // BundleEventListener + public synchronized void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, "handleMessage " + event); + } + + if (mDelegate == null) { + if (callback != null) { + callback.sendError("No StorageDelegate attached"); + } + return; + } + + if (FETCH_LOGIN_EVENT.equals(event)) { + final String domain = message.getString("domain"); + final GeckoResult result = + domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + logins -> { + if (logins == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] loginBundles = new GeckoBundle[logins.length]; + for (int i = 0; i < logins.length; ++i) { + loginBundles[i] = logins[i].toBundle(); + } + + return loginBundles; + })); + } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) { + final GeckoResult result = mDelegate.onCreditCardFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + creditCards -> { + if (creditCards == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length]; + for (int i = 0; i < creditCards.length; ++i) { + creditCardBundles[i] = creditCards[i].toBundle(); + } + + return creditCardBundles; + })); + } else if (FETCH_ADDRESS_EVENT.equals(event)) { + final GeckoResult result = mDelegate.onAddressFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + addresses -> { + if (addresses == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length]; + for (int i = 0; i < addresses.length; ++i) { + addressBundles[i] = addresses[i].toBundle(); + } + + return addressBundles; + })); + } else if (SAVE_LOGIN_EVENT.equals(event)) { + final GeckoBundle loginBundle = message.getBundle("login"); + final LoginEntry login = new LoginEntry(loginBundle); + + mDelegate.onLoginSave(login); + } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) { + final GeckoBundle creditCardBundle = message.getBundle("creditCard"); + final CreditCard creditCard = new CreditCard(creditCardBundle); + + mDelegate.onCreditCardSave(creditCard); + } else if (SAVE_ADDRESS_EVENT.equals(event)) { + final GeckoBundle addressBundle = message.getBundle("address"); + final Address address = new Address(addressBundle); + + mDelegate.onAddressSave(address); + } else if (USED_LOGIN_EVENT.equals(event)) { + final GeckoBundle loginBundle = message.getBundle("login"); + final LoginEntry login = new LoginEntry(loginBundle); + final int fields = message.getInt("usedFields"); + + mDelegate.onLoginUsed(login, fields); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java new file mode 100644 index 0000000000..5a4488f4fa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java @@ -0,0 +1,1234 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.util.Log; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewStructure; +import android.view.autofill.AutofillValue; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.collection.ArrayMap; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public class Autofill { + private static final boolean DEBUG = false; + + public @interface AutofillNotify {} + + public static final class Hint { + private Hint() {} + + /** Hint indicating that no special handling is required. */ + public static final int NONE = -1; + + /** Hint indicating that a node represents an email address. */ + public static final int EMAIL_ADDRESS = 0; + + /** Hint indicating that a node represents a password. */ + public static final int PASSWORD = 1; + + /** Hint indicating that a node represents an URI. */ + public static final int URI = 2; + + /** Hint indicating that a node represents a username. */ + public static final int USERNAME = 3; + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public static @Nullable String toString(final @AutofillHint int hint) { + final int idx = hint + 1; + final String[] map = new String[] {"NONE", "EMAIL", "PASSWORD", "URI", "USERNAME"}; + + if (idx < 0 || idx >= map.length) { + return null; + } + return map[idx]; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Hint.NONE, Hint.EMAIL_ADDRESS, Hint.PASSWORD, Hint.URI, Hint.USERNAME}) + public @interface AutofillHint {} + + public static final class InputType { + private InputType() {} + + /** Indicates that a node is not a known input type. */ + public static final int NONE = -1; + + /** Indicates that a node is a text input type. Example: {@code } */ + public static final int TEXT = 0; + + /** Indicates that a node is a number input type. Example: {@code } */ + public static final int NUMBER = 1; + + /** Indicates that a node is a phone input type. Example: {@code } */ + public static final int PHONE = 2; + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public static @Nullable String toString(final @AutofillInputType int type) { + final int idx = type + 1; + final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"}; + + if (idx < 0 || idx >= map.length) { + return null; + } + return map[idx]; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE}) + public @interface AutofillInputType {} + + /** Represents autofill data associated to a {@link Node}. */ + public static class NodeData { + /** Autofill id for this node. */ + final int id; + + String value; + Node node; + EventCallback callback; + + NodeData(final int id, final Node node) { + this.id = id; + this.node = node; + } + + /** + * Gets the value for this node. + * + * @return a String representing the value for this node. + */ + @AnyThread + public @Nullable String getValue() { + return value; + } + + /** + * Returns the autofill id for this node. + * + * @return an int representing the id for this node. + */ + @AnyThread + public int getId() { + return id; + } + } + + /** Represents an autofill session. A session holds the autofill nodes and state of a page. */ + public static final class Session { + private static final String LOGTAG = "AutofillSession"; + + private @NonNull final GeckoSession mGeckoSession; + private Node mRoot; + private HashMap mUuidToNodeData; + private SparseArray mIdToNode; + private int mCurrentIndex = 0; + private String mId = null; + + // We can't store the Node directly because it might be updated by subsequent NodeAdd calls. + private String mFocusedUuid = null; + + /* package */ Session(@NonNull final GeckoSession geckoSession) { + mGeckoSession = geckoSession; + // Dummy session until a real one gets created + clear(UUID.randomUUID().toString()); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull Rect getDefaultDimensions() { + final Rect rect = new Rect(); + mGeckoSession.getSurfaceBounds(rect); + return rect; + } + + /* package */ void clear(final String newSessionId) { + mId = newSessionId; + mFocusedUuid = null; + mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId); + mIdToNode = new SparseArray<>(); + mUuidToNodeData = new HashMap<>(); + addNode(mRoot); + } + + /* package */ boolean isEmpty() { + // Root data is always there + return mUuidToNodeData.size() == 1; + } + + /** + * Get data for the given node. + * + * @param node the {@link Node} get data for. + * @return the {@link NodeData} for the given node. + */ + @UiThread + public @NonNull NodeData dataFor(final @NonNull Node node) { + final NodeData data = mUuidToNodeData.get(node.getUuid()); + Objects.requireNonNull(data); + return data; + } + + /** + * Perform auto-fill using the specified values. + * + * @param values Map of auto-fill IDs to values. + */ + @UiThread + public void autofill(@NonNull final SparseArray values) { + ThreadUtils.assertOnUiThread(); + + if (isEmpty()) { + return; + } + + final HashMap valueBundles = new HashMap<>(); + + for (int i = 0; i < values.size(); i++) { + final int id = values.keyAt(i); + final Node node = getNode(id); + if (node == null) { + Log.w(LOGTAG, "Could not find node id=" + id); + continue; + } + + final CharSequence value = values.valueAt(i); + + if (DEBUG) { + Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value); + } + + if (node == getRoot()) { + // We cannot autofill the session root as it does not correspond to a + // real element on the page. + Log.w(LOGTAG, "Ignoring autofill on session root."); + continue; + } + + final Node root = node.getRoot(); + if (!valueBundles.containsKey(root)) { + valueBundles.put(root, new GeckoBundle()); + } + valueBundles.get(root).putString(node.getUuid(), String.valueOf(value)); + } + + for (final Node root : valueBundles.keySet()) { + final NodeData data = dataFor(root); + Objects.requireNonNull(data); + final EventCallback callback = data.callback; + callback.sendSuccess(valueBundles.get(root)); + } + } + + /* package */ void addRoot(@NonNull final Node node, final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, "addRoot: " + node); + } + + mRoot.addChild(node); + addNode(node); + dataFor(node).callback = callback; + } + + /* package */ void addNode(@NonNull final Node node) { + if (DEBUG) { + Log.d(LOGTAG, "addNode: " + node); + } + + NodeData data = mUuidToNodeData.get(node.getUuid()); + if (data == null) { + final int nodeId = mCurrentIndex++; + data = new NodeData(nodeId, node); + mUuidToNodeData.put(node.getUuid(), data); + } else { + data.node = node; + } + + mIdToNode.put(data.id, node); + for (final Node child : node.getChildren()) { + addNode(child); + } + } + + /** + * Returns true if the node is currently visible in the page. + * + * @param node the {@link Node} instance + * @return true if the node is visible, false otherwise. + */ + @UiThread + public boolean isVisible(final @NonNull Node node) { + if (!Objects.equals(node.mSessionId, mId)) { + Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId); + return false; + } + if (mRoot == node) { + // The root is always visible + return true; + } + final Node focused = getFocused(); + if (focused == null) { + return false; + } + final Node focusedRoot = focused.getRoot(); + final Node focusedParent = focused.getParent(); + + final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null; + final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null; + + return (focusedParent != null && focusedParent.getUuid().equals(parentUuid)) + || (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid)); + } + + /** + * Returns the currently focused node. + * + * @return a reference to the {@link Node} that is currently focused or null if no node is + * currently focused. + */ + @UiThread + public @Nullable Node getFocused() { + return getNode(mFocusedUuid); + } + + /* package */ void setFocus(final Node node) { + mFocusedUuid = node != null ? node.getUuid() : null; + } + + /** + * Returns the currently focused node data. + * + * @return a refernce to {@link NodeData} or null if no node is focused. + */ + @UiThread + public @Nullable NodeData getFocusedData() { + final Node focused = getFocused(); + return focused != null ? dataFor(focused) : null; + } + + /* package */ @Nullable + Node getNode(final String uuid) { + if (uuid == null) { + return null; + } + final NodeData nodeData = mUuidToNodeData.get(uuid); + if (nodeData == null) { + return null; + } + return nodeData.node; + } + + /* package */ Node getNode(final int id) { + return mIdToNode.get(id); + } + + /** + * Get the root node of the session tree. Each session is managed in a tree with a virtual root + * node for the document. + * + * @return The root {@link Node} for this session. + */ + @AnyThread + public @NonNull Node getRoot() { + return mRoot; + } + + /* package */ String getId() { + return mId; + } + + @Override + @UiThread + public String toString() { + final StringBuilder builder = new StringBuilder("Session {"); + final Node focused = getFocused(); + builder + .append("id=") + .append(mId) + .append(", focused=") + .append(mFocusedUuid) + .append(", focusedRoot=") + .append( + (focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null) + .append(", root=") + .append(getRoot()) + .append("}"); + return builder.toString(); + } + + @TargetApi(23) + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public void fillViewStructure( + @NonNull final View view, @NonNull final ViewStructure structure, final int flags) { + ThreadUtils.assertOnUiThread(); + fillViewStructure(getRoot(), view, structure, flags); + } + + @TargetApi(23) + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public void fillViewStructure( + final @NonNull Node node, + @NonNull final View view, + @NonNull final ViewStructure structure, + final int flags) { + ThreadUtils.assertOnUiThread(); + + if (DEBUG) { + Log.d(LOGTAG, "fillViewStructure"); + } + + final NodeData data = dataFor(node); + if (data == null) { + return; + } + + if (Build.VERSION.SDK_INT >= 26) { + structure.setAutofillId(view.getAutofillId(), data.id); + structure.setWebDomain(node.getDomain()); + structure.setAutofillValue(AutofillValue.forText(data.value)); + } + + structure.setId(data.id, null, null, null); + // This dimensions doesn't seem to used for autofill service. + structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height()); + + if (Build.VERSION.SDK_INT >= 26) { + final ViewStructure.HtmlInfo.Builder htmlBuilder = + structure.newHtmlInfoBuilder(node.getTag()); + for (final String key : node.getAttributes().keySet()) { + htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key))); + } + + structure.setHtmlInfo(htmlBuilder.build()); + } + + structure.setChildCount(node.getChildren().size()); + int childCount = 0; + + for (final Node child : node.getChildren()) { + final ViewStructure childStructure = structure.newChild(childCount); + fillViewStructure(child, view, childStructure, flags); + childCount++; + } + + switch (node.getTag()) { + case "input": + case "textarea": + structure.setClassName("android.widget.EditText"); + structure.setEnabled(node.getEnabled()); + structure.setFocusable(node.getFocusable()); + structure.setFocused(node.equals(getFocused())); + structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE); + + if (Build.VERSION.SDK_INT >= 26) { + structure.setAutofillType(View.AUTOFILL_TYPE_TEXT); + } + break; + default: + if (childCount > 0) { + structure.setClassName("android.view.ViewGroup"); + } else { + structure.setClassName("android.view.View"); + } + break; + } + + if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) { + return; + } + // LastPass will fill password to the field where setAutofillHints + // is unset and setInputType is set. + switch (node.getHint()) { + case Hint.EMAIL_ADDRESS: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + break; + } + case Hint.PASSWORD: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD); + break; + } + case Hint.URI: + { + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_URI); + break; + } + case Hint.USERNAME: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT); + break; + } + case Hint.NONE: + { + // Nothing to do. + break; + } + } + + switch (node.getInputType()) { + case InputType.NUMBER: + { + structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER); + break; + } + case InputType.PHONE: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE}); + structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE); + break; + } + case InputType.TEXT: + case InputType.NONE: + // Nothing to do. + break; + } + } + } + + /** + * Represents an autofill node. A node is an input element and may contain child nodes forming a + * tree. + */ + public static final class Node { + private final String mUuid; + private final Node mRoot; + private final Node mParent; + private final @NonNull Rect mDimens; + private final @NonNull Rect mScreenRect; + private final @NonNull Map mChildren; + private final @NonNull Map mAttributes; + private final boolean mEnabled; + private final boolean mFocusable; + private final @AutofillHint int mHint; + private final @AutofillInputType int mInputType; + private final @NonNull String mTag; + private final @NonNull String mDomain; + private final String mSessionId; + + /* package */ + @NonNull + String getUuid() { + return mUuid; + } + + /* package */ + @Nullable + Node getRoot() { + return mRoot; + } + + /* package */ + @Nullable + Node getParent() { + return mParent; + } + + /** + * Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their + * proper dimensions. + * + * @return The dimensions of this node. + */ + @AnyThread + /* package */ @NonNull + Rect getDimensions() { + return mDimens; + } + + /** + * Get the dimensions of this node in screen coordinates. This is valid when this node has an + * focus. + * + * @return The dimensions of this node. + */ + @AnyThread + public @NonNull Rect getScreenRect() { + return mScreenRect; + } + + /** + * Set the dimensions of this node in screen coordinates. + * + * @param screenRect The dimensions of this node. + */ + /* package */ void setScreenRect(final @NonNull RectF screenRectF) { + screenRectF.roundOut(mScreenRect); + } + + /** + * Get the child nodes for this node. + * + * @return The collection of child nodes for this node. + */ + @AnyThread + public @NonNull Collection getChildren() { + return mChildren.values(); + } + + /* package */ + @NonNull + Node addChild(@NonNull final Node child) { + mChildren.put(child.getUuid(), child); + return this; + } + + /** + * Get HTML attributes for this node. + * + * @return The HTML attributes for this node. + */ + @AnyThread + public @NonNull Map getAttributes() { + return mAttributes; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable String getAttribute(@NonNull final String key) { + return mAttributes.get(key); + } + + /** + * Get whether or not this node is enabled. + * + * @return True if the node is enabled, false otherwise. + */ + @AnyThread + public boolean getEnabled() { + return mEnabled; + } + + /** + * Get whether or not this node is focusable. + * + * @return True if the node is focusable, false otherwise. + */ + @AnyThread + public boolean getFocusable() { + return mFocusable; + } + + /** + * Get the hint for the type of data contained in this node. + * + * @return The input data hint for this node, one of {@link Hint}. + */ + @AnyThread + public @AutofillHint int getHint() { + return mHint; + } + + /** + * Get the input type of this node. + * + * @return The input type of this node, one of {@link InputType}. + */ + @AnyThread + public @AutofillInputType int getInputType() { + return mInputType; + } + + /** + * Get the HTML tag of this node. + * + * @return The HTML tag of this node. + */ + @AnyThread + public @NonNull String getTag() { + return mTag; + } + + /** + * Get web domain of this node. + * + * @return The domain of this node. + */ + @AnyThread + public @NonNull String getDomain() { + return mDomain; + } + + /* package */ + static Node newDummyRoot(final Rect dimensions, final String sessionId) { + return new Node(dimensions, sessionId); + } + + /* package */ Node(final Rect dimensions, final String sessionId) { + mRoot = null; + mParent = null; + mUuid = UUID.randomUUID().toString(); + mDimens = dimensions; + mScreenRect = new Rect(); + mSessionId = sessionId; + mAttributes = new ArrayMap<>(); + mEnabled = false; + mFocusable = false; + mHint = Hint.NONE; + mInputType = InputType.NONE; + mTag = ""; + mDomain = ""; + mChildren = new HashMap<>(); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("Node {"); + builder + .append("uuid=") + .append(mUuid) + .append(", sessionId=") + .append(mSessionId) + .append(", parent=") + .append(mParent != null ? mParent.getUuid() : null) + .append(", root=") + .append(mRoot != null ? mRoot.getUuid() : null) + .append(", dims=") + .append(getDimensions().toShortString()) + .append(", screenRect=") + .append(getScreenRect().toShortString()) + .append(", children=["); + + for (final Node child : mChildren.values()) { + builder.append(child.getUuid()).append(", "); + } + + builder + .append("]") + .append(", attrs=") + .append(mAttributes) + .append(", enabled=") + .append(mEnabled) + .append(", focusable=") + .append(mFocusable) + .append(", hint=") + .append(Hint.toString(mHint)) + .append(", type=") + .append(InputType.toString(mInputType)) + .append(", tag=") + .append(mTag) + .append(", domain=") + .append(mDomain) + .append("}"); + + return builder.toString(); + } + + /* package */ Node( + @NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) { + this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId); + } + + /* package */ Node( + @NonNull final GeckoBundle bundle, + final Node root, + final Node parent, + final Rect defaultDimensions, + final String sessionId) { + final GeckoBundle bounds = bundle.getBundle("bounds"); + + mSessionId = sessionId; + mUuid = bundle.getString("uuid"); + mDomain = bundle.getString("origin", ""); + final Rect dimens = + new Rect( + bounds.getInt("left"), + bounds.getInt("top"), + bounds.getInt("right"), + bounds.getInt("bottom")); + if (dimens.isEmpty()) { + // Some nodes like will have null-dimensions, + // we need to set them to the virtual documents dimensions. + mDimens = defaultDimensions; + } else { + mDimens = dimens; + } + mScreenRect = new Rect(); + + mParent = parent; + // If the root is null, then this object is the root itself + mRoot = root != null ? root : this; + + final GeckoBundle[] children = bundle.getBundleArray("children"); + final Map childrenMap = new HashMap<>(children != null ? children.length : 0); + + if (children != null) { + for (final GeckoBundle childBundle : children) { + final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId); + childrenMap.put(child.getUuid(), child); + } + } + + mChildren = childrenMap; + + mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT); + + final GeckoBundle attrs = bundle.getBundle("attributes"); + final Map attributes = new HashMap<>(); + + for (final String key : attrs.keys()) { + attributes.put(key, String.valueOf(attrs.get(key))); + } + + mAttributes = attributes; + + mEnabled = + enabledFromBundle( + mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false)); + mFocusable = mEnabled; + + final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT); + final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT); + mInputType = typeFromBundle(type, hint); + mHint = hintFromBundle(type, hint); + } + + private boolean enabledFromBundle( + final String tag, final boolean editable, final boolean disabled) { + switch (tag) { + case "input": + { + if (!editable) { + // Don't process non-editable inputs (e.g., type="button"). + return false; + } + return !disabled; + } + case "textarea": + return !disabled; + default: + return false; + } + } + + private @AutofillHint int hintFromBundle(final String type, final String hint) { + switch (type) { + case "email": + return Hint.EMAIL_ADDRESS; + case "password": + return Hint.PASSWORD; + case "url": + return Hint.URI; + case "text": + { + if (hint.equals("username")) { + return Hint.USERNAME; + } + break; + } + } + + return Hint.NONE; + } + + private @AutofillInputType int typeFromBundle(final String type, final String hint) { + switch (type) { + case "password": + case "url": + case "email": + return InputType.TEXT; + case "number": + return InputType.NUMBER; + case "tel": + return InputType.PHONE; + case "text": + { + if (hint.equals("username")) { + return InputType.TEXT; + } + break; + } + } + + return InputType.NONE; + } + } + + public interface Delegate { + + /** + * An autofill session has started. Usually triggered by page load. + * + * @param session The {@link GeckoSession} instance. + */ + @UiThread + default void onSessionStart(@NonNull final GeckoSession session) {} + + /** + * An autofill session has been committed. Triggered by form submission or navigation. + * + * @param session The {@link GeckoSession} instance. + * @param node the node that is being committed. + * @param data the node data associated to the node being committed. + */ + @UiThread + default void onSessionCommit( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + + /** + * An autofill session has been canceled. Triggered by page unload. + * + * @param session The {@link GeckoSession} instance. + */ + @UiThread + default void onSessionCancel(@NonNull final GeckoSession session) {} + + /** + * A node within the autofill session has been added. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was added. + * @param data The {@link NodeData} associated to the note that was added. + */ + @UiThread + default void onNodeAdd( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + + /** + * A node within the autofill session has been removed. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was removed. + * @param data The {@link NodeData} associated to the note that was removed. + */ + @UiThread + default void onNodeRemove( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + + /** + * A node within the autofill session has been updated. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was updated. + * @param data The {@link NodeData} associated to the note that was updated. + */ + @UiThread + default void onNodeUpdate( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + + /** + * A node within the autofill session has gained focus. + * + * @param session The {@link GeckoSession} instance. + * @param focused The {@link Node} that is now focused. + * @param data The {@link NodeData} associated to the note that is now focused. + */ + @UiThread + default void onNodeFocus( + @NonNull final GeckoSession session, + @NonNull final Node focused, + @NonNull final NodeData data) {} + + /** + * A node within the autofill session has lost focus. + * + * @param session The {@link GeckoSession} instance. + * @param prev The {@link Node} that lost focus. + * @param data The {@link NodeData} associated to the note that lost focus. + */ + @UiThread + default void onNodeBlur( + @NonNull final GeckoSession session, + @NonNull final Node prev, + @NonNull final NodeData data) {} + } + + /* package */ static final class Support implements BundleEventListener { + private static final String LOGTAG = "AutofillSupport"; + + private @NonNull final GeckoSession mGeckoSession; + private @NonNull final Session mAutofillSession; + private Delegate mDelegate; + + public Support(@NonNull final GeckoSession geckoSession) { + mGeckoSession = geckoSession; + mAutofillSession = new Session(mGeckoSession); + } + + public void registerListeners() { + mGeckoSession + .getEventDispatcher() + .registerUiThreadListener( + this, + "GeckoView:StartAutofill", + "GeckoView:AddAutofill", + "GeckoView:ClearAutofill", + "GeckoView:CommitAutofill", + "GeckoView:OnAutofillFocus", + "GeckoView:UpdateAutofill"); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event); + if ("GeckoView:AddAutofill".equals(event)) { + addNode(message.getBundle("node"), callback); + } else if ("GeckoView:StartAutofill".equals(event)) { + start(message.getString("sessionId")); + } else if ("GeckoView:ClearAutofill".equals(event)) { + clear(); + } else if ("GeckoView:OnAutofillFocus".equals(event)) { + onFocusChanged(message.getBundle("node")); + } else if ("GeckoView:CommitAutofill".equals(event)) { + commit(message.getBundle("node")); + } else if ("GeckoView:UpdateAutofill".equals(event)) { + update(message.getBundle("node")); + } + } + + @UiThread + public void setDelegate(final @Nullable Delegate delegate) { + ThreadUtils.assertOnUiThread(); + + mDelegate = delegate; + } + + @UiThread + public @Nullable Delegate getDelegate() { + ThreadUtils.assertOnUiThread(); + + return mDelegate; + } + + @UiThread + public @NonNull Session getAutofillSession() { + ThreadUtils.assertOnUiThread(); + + return mAutofillSession; + } + + /* package */ void addNode( + @NonNull final GeckoBundle message, @NonNull final EventCallback callback) { + final Session session = getAutofillSession(); + final Node node = new Node(message, session.getDefaultDimensions(), session.getId()); + + session.addRoot(node, callback); + addValues(message); + + if (mDelegate != null) { + mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + private void addValues(final GeckoBundle message) { + final String uuid = message.getString("uuid"); + if (uuid == null) { + return; + } + + final String value = message.getString("value"); + final Node node = getAutofillSession().getNode(uuid); + if (node == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + Objects.requireNonNull(node); + final NodeData data = getAutofillSession().dataFor(node); + Objects.requireNonNull(data); + data.value = value; + + final GeckoBundle[] children = message.getBundleArray("children"); + if (children != null) { + for (final GeckoBundle child : children) { + addValues(child); + } + } + } + + /* package */ void start(@Nullable final String sessionId) { + // Make sure we start with a clean session + getAutofillSession().clear(sessionId); + if (mDelegate != null) { + mDelegate.onSessionStart(mGeckoSession); + } + } + + /* package */ void commit(@Nullable final GeckoBundle message) { + if (getAutofillSession().isEmpty() || message == null) { + return; + } + + final String uuid = message.getString("uuid"); + final Node node = getAutofillSession().getNode(uuid); + if (node == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "commit(" + uuid + ")"); + } + + if (mDelegate != null) { + mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + /* package */ void update(@Nullable final GeckoBundle message) { + if (getAutofillSession().isEmpty() || message == null) { + return; + } + + final String uuid = message.getString("uuid"); + + if (DEBUG) { + Log.d(LOGTAG, "update(" + uuid + ")"); + } + + final Node node = getAutofillSession().getNode(uuid); + final String value = message.getString("value", ""); + + if (node == null) { + Log.d(LOGTAG, "could not find node " + uuid); + return; + } + + if (DEBUG) { + final NodeData data = getAutofillSession().dataFor(node); + Log.d( + LOGTAG, + "updating node " + uuid + " value from " + data != null + ? data.value + : null + " to " + value); + } + + getAutofillSession().dataFor(node).value = value; + + if (mDelegate != null) { + mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + /* package */ void clear() { + if (getAutofillSession().isEmpty()) { + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "clear()"); + } + + getAutofillSession().clear(null); + if (mDelegate != null) { + mDelegate.onSessionCancel(mGeckoSession); + } + } + + /* package */ void onFocusChanged(@Nullable final GeckoBundle message) { + final Session session = getAutofillSession(); + if (session.isEmpty()) { + return; + } + + final Node prev = getAutofillSession().getFocused(); + final String prevUuid = prev != null ? prev.getUuid() : null; + final String uuid = message != null ? message.getString("uuid") : null; + + final Node focused; + if (uuid == null) { + focused = null; + } else { + focused = session.getNode(uuid); + if (focused == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + if (message != null) { + final RectF screenRectF = message.getRectF("screenRect"); + focused.setScreenRect(screenRectF); + } + } + + if (DEBUG) { + Log.d( + LOGTAG, + "onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')'); + } + + if (Objects.equals(uuid, prevUuid)) { + // Nothing changed, nothing to do. + return; + } + + session.setFocus(focused); + + if (mDelegate != null) { + if (prev != null) { + mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev)); + } + if (uuid != null) { + mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } + } + } + + @UiThread + public void onActiveChanged(final boolean active) { + ThreadUtils.assertOnUiThread(); + + final Node focused = getAutofillSession().getFocused(); + + if (focused == null) { + return; + } + + if (mDelegate != null) { + if (active) { + mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } else { + mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java new file mode 100644 index 0000000000..d135194afa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * This class exposes the Base64 URL encode/decode functions from Gecko. They are different from + * android.util.Base64 in that they always use URL encoding, no padding, and are constant time. The + * last bit is important when dealing with values that might be secret as we do with Web Push. + */ +/* package */ class Base64Utils { + @WrapForJNI + public static native byte[] decode(final String data); + + @WrapForJNI + public static native String encode(final byte[] data); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java new file mode 100644 index 0000000000..f2e10e50a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java @@ -0,0 +1,685 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.os.TransactionTooLargeException; +import android.text.TextUtils; +import android.util.Log; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default + * if the consumer does not explicitly set a SelectionActionDelegate. + * + *

    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_PASTE_AS_PLAIN_TEXT, + ACTION_PROCESS_TEXT + }; + private static final String[] FIXED_TOOLBAR_ACTIONS = + new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE}; + + // This is limitation of intent text. + private static final int MAX_INTENT_TEXT_LENGTH = 100000; + + protected final @NonNull Activity mActivity; + protected final boolean mUseFloatingToolbar; + + private boolean mExternalActionsEnabled; + + protected @Nullable ActionMode mActionMode; + protected @Nullable GeckoSession mSession; + protected @Nullable Selection mSelection; + protected boolean mRepopulatedMenu; + + private @Nullable ActionMode mActionModeForClipboardPermission; + + @TargetApi(Build.VERSION_CODES.M) + private class Callback2Wrapper extends ActionMode.Callback2 { + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu); + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode); + } + + @Override + public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { + super.onGetContentRect(mode, view, outRect); + BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect); + } + } + + @SuppressWarnings("checkstyle:javadocmethod") + public BasicSelectionActionDelegate(final @NonNull Activity activity) { + this(activity, Build.VERSION.SDK_INT >= 23); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public BasicSelectionActionDelegate( + final @NonNull Activity activity, final boolean useFloatingToolbar) { + mActivity = activity; + mUseFloatingToolbar = useFloatingToolbar; + mExternalActionsEnabled = true; + } + + /** + * Set whether to include text actions from other apps in the floating toolbar. + * + * @param enable True if external actions should be enabled. + */ + public void enableExternalActions(final boolean enable) { + ThreadUtils.assertOnUiThread(); + mExternalActionsEnabled = enable; + + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + /** + * Get whether text actions from other apps are enabled. + * + * @return True if external actions are enabled. + */ + public boolean areExternalActionsEnabled() { + return mExternalActionsEnabled; + } + + /** + * Return list of all actions in proper order, regardless of their availability at present. + * Override to add to or remove from the default set. + * + * @return Array of action IDs in proper order. + */ + protected @NonNull String[] getAllActions() { + return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS; + } + + /** + * Return whether an action is presently available. Override to indicate availability for custom + * actions. + * + * @param id Action ID. + * @return True if the action is presently available. + */ + protected boolean isActionAvailable(final @NonNull String id) { + if (mSelection == null) { + return false; + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) { + return false; + } + + if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) { + return !getProcessTextExportedActivities().isEmpty(); + } + + return mSelection.isActionAvailable(id); + } + + /** + * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text + * is selected. + * + * @return list of exported activities + */ + private @NonNull List getProcessTextExportedActivities() { + final PackageManager pm = mActivity.getPackageManager(); + final List resolvedList = + pm.queryIntentActivityOptions( + null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY); + final ArrayList exportedList = new ArrayList<>(); + for (final ResolveInfo info : resolvedList) { + if (info.activityInfo.exported) { + exportedList.add(info); + } + } + + return exportedList; + } + + /** + * Provides access to whether there are text selection actions available. Override to indicate + * availability for custom actions. + * + * @return True if there are text selection actions available. + */ + public boolean isActionAvailable() { + if (mSelection == null) { + return false; + } + + return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty(); + } + + /** + * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom + * action. + * + * @param id Action ID. + * @param item New menu item to prepare. + */ + protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) { + switch (id) { + case ACTION_CUT: + item.setTitle(android.R.string.cut); + break; + case ACTION_COPY: + item.setTitle(android.R.string.copy); + break; + case ACTION_PASTE: + item.setTitle(android.R.string.paste); + break; + case ACTION_PASTE_AS_PLAIN_TEXT: + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + throw new IllegalStateException("Unexpected version for action"); + } + item.setTitle(android.R.string.paste_as_plain_text); + break; + case ACTION_SELECT_ALL: + item.setTitle(android.R.string.selectAll); + break; + case ACTION_PROCESS_TEXT: + throw new IllegalStateException("Unexpected action"); + } + } + + /** + * Perform the specified action. Override to perform custom actions. + * + * @param id Action ID. + * @param item Nenu item for the action. + * @return True if the action was performed. + */ + protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) { + if (ACTION_PROCESS_TEXT.equals(id)) { + try { + mActivity.startActivity(item.getIntent()); + } catch (final ActivityNotFoundException e) { + Log.e(LOGTAG, "Cannot perform action", e); + return false; + } + return true; + } + + if (mSelection == null) { + return false; + } + mSelection.execute(id); + + // Android behavior is to clear selection on copy. + if (ACTION_COPY.equals(id)) { + if (mUseFloatingToolbar) { + clearSelection(); + } else { + mActionMode.finish(); + } + } + return true; + } + + /** + * Get the current selection object. This object should not be stored as it does not update when + * the selection becomes invalid. Stale actions are ignored. + * + * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current + * action menu. null if no action menu is active. + */ + public @Nullable Selection getSelection() { + return mSelection; + } + + /** Clear the current selection, if possible. */ + public void clearSelection() { + if (mSelection == null) { + return; + } + + if (isActionAvailable(ACTION_COLLAPSE_TO_END)) { + mSelection.collapseToEnd(); + } else if (isActionAvailable(ACTION_UNSELECT)) { + mSelection.unselect(); + } else { + mSelection.hide(); + } + } + + private String getSelectedText(final int maxLength) { + if (mSelection == null) { + return ""; + } + + if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) { + return mSelection.text; + } + + return mSelection.text.substring(0, maxLength); + } + + private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) { + final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT); + if (resolveInfo != null) { + intent.setComponent( + new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name)); + } + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setType("text/plain"); + // If using large text, anything intent may throw RemoteException. + intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH)); + // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137). + intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true); + return intent; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + ThreadUtils.assertOnUiThread(); + final String[] allActions = getAllActions(); + for (final String actionId : allActions) { + if (isActionAvailable(actionId)) { + if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) { + // Android bug where onPrepareActionMode is not called initially. + onPrepareActionMode(actionMode, menu); + } + return true; + } + } + return false; + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + ThreadUtils.assertOnUiThread(); + final String[] allActions = getAllActions(); + boolean changed = false; + + // Whether we are repopulating an existing menu. + mRepopulatedMenu = menu.size() != 0; + + // For each action, see if it's available at present, and if necessary, + // add to or remove from menu. + for (int i = 0; i < allActions.length; i++) { + final String actionId = allActions[i]; + final int menuId = i + Menu.FIRST; + + if (ACTION_PROCESS_TEXT.equals(actionId)) { + if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) { + final List exportedPackageInfo = getProcessTextExportedActivities(); + if (!exportedPackageInfo.isEmpty()) { + for (final ResolveInfo info : exportedPackageInfo) { + final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info); + if (isMenuItemAdded) { + changed = true; + } + } + } + } else if (menu.findItem(menuId) != null) { + menu.removeGroup(menuId); + changed = true; + } + continue; + } + + if (isActionAvailable(actionId)) { + if (menu.findItem(menuId) == null) { + prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ "")); + changed = true; + } + } else if (menu.findItem(menuId) != null) { + menu.removeItem(menuId); + changed = true; + } + } + return changed; + } + + private boolean addProcessTextMenuItem( + final Menu menu, final int menuId, final ResolveInfo info) { + boolean isMenuItemAdded = false; + try { + menu.addIntentOptions( + menuId, + menuId, + menuId, + mActivity.getComponentName(), + /* specifiec */ null, + getProcessTextIntent(info), + /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */ + null); + isMenuItemAdded = true; + } catch (final RuntimeException e) { + if (e.getCause() instanceof TransactionTooLargeException) { + // Binder size error. MAX_INTENT_TEXT_LENGTH is still large? + Log.e(LOGTAG, "Cannot add intent option", e); + } else { + throw e; + } + } + return isMenuItemAdded; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + ThreadUtils.assertOnUiThread(); + MenuItem realMenuItem = null; + if (mRepopulatedMenu) { + // When we repopulate an existing menu, Android can sometimes give us an old, + // deleted MenuItem. Find the current MenuItem that corresponds to the old one. + final Menu menu = actionMode.getMenu(); + final int size = menu.size(); + for (int i = 0; i < size; i++) { + final MenuItem item = menu.getItem(i); + if (item == menuItem + || (item.getItemId() == menuItem.getItemId() + && item.getTitle().equals(menuItem.getTitle()))) { + realMenuItem = item; + break; + } + } + } else { + realMenuItem = menuItem; + } + + if (realMenuItem == null) { + return false; + } + final String[] allActions = getAllActions(); + return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + ThreadUtils.assertOnUiThread(); + if (!mUseFloatingToolbar) { + clearSelection(); + } + mSession = null; + mSelection = null; + mActionMode = null; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public void onGetContentRect( + final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) { + ThreadUtils.assertOnUiThread(); + if (mSelection == null || mSelection.screenRect == null) { + return; + } + + // outRect has to convert to current window coordinate. + final Matrix matrix = new Matrix(); + mSession.getScreenToWindowManagerOffsetMatrix(matrix); + final RectF transformedRect = new RectF(); + matrix.mapRect(transformedRect, mSelection.screenRect); + transformedRect.roundOut(outRect); + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onShowActionRequest(final GeckoSession session, final Selection selection) { + ThreadUtils.assertOnUiThread(); + mSession = session; + mSelection = selection; + + if (mActionMode != null) { + if (isActionAvailable()) { + mActionMode.invalidate(); + } else { + mActionMode.finish(); + } + return; + } + + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + return; + } + + if (mUseFloatingToolbar) { + mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING); + } else { + mActionMode = mActivity.startActionMode(this); + } + } + + @Override + public void onHideAction(final GeckoSession session, final int reason) { + ThreadUtils.assertOnUiThread(); + if (mActionMode == null) { + return; + } + + switch (reason) { + case HIDE_REASON_ACTIVE_SCROLL: + case HIDE_REASON_ACTIVE_SELECTION: + case HIDE_REASON_INVISIBLE_SELECTION: + if (mUseFloatingToolbar) { + // Hide the floating toolbar when scrolling/selecting. + mActionMode.finish(); + } + break; + + case HIDE_REASON_NO_SELECTION: + mActionMode.finish(); + break; + } + } + + /** Callback class of clipboard permission. This is used on pre-M only */ + private class ClipboardPermissionCallback implements ActionMode.Callback { + private GeckoResult mResult; + + public ClipboardPermissionCallback(final GeckoResult result) { + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + } + + /** Callback class of clipboard permission for Android M+ */ + @TargetApi(Build.VERSION_CODES.M) + private class ClipboardPermissionCallbackM extends ActionMode.Callback2 { + private @Nullable GeckoResult mResult; + private final @NonNull GeckoSession mSession; + private final @Nullable Point mPoint; + + public ClipboardPermissionCallbackM( + final @NonNull GeckoSession session, + final @Nullable Point screenPoint, + final @NonNull GeckoResult result) { + mSession = session; + mPoint = screenPoint; + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + + @Override + public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { + super.onGetContentRect(mode, view, outRect); + + if (mPoint == null) { + return; + } + + outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1); + } + } + + /** + * Show action mode bar to request clipboard permission + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @TargetApi(Build.VERSION_CODES.M) + @Override + public GeckoResult onShowClipboardPermissionRequest( + final GeckoSession session, final ClipboardPermission permission) { + ThreadUtils.assertOnUiThread(); + + final GeckoResult result = new GeckoResult<>(); + + if (mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + } + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + + if (mUseFloatingToolbar) { + mActionModeForClipboardPermission = + mActivity.startActionMode( + new ClipboardPermissionCallbackM(session, permission.screenPoint, result), + ActionMode.TYPE_FLOATING); + } else { + mActionModeForClipboardPermission = + mActivity.startActionMode(new ClipboardPermissionCallback(result)); + } + + return result; + } + + /** + * Dismiss action mode for requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @Override + public void onDismissClipboardPermissionRequest(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + } + + /* package */ boolean onCreateActionModeForClipboardPermission( + final ActionMode actionMode, final Menu menu) { + final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ ""); + item.setTitle(android.R.string.paste); + return true; + } + + /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) { + mActionModeForClipboardPermission = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java new file mode 100644 index 0000000000..9162566666 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import org.mozilla.gecko.util.EventCallback; + +/* package */ abstract class CallbackResult extends GeckoResult implements EventCallback { + @Override + public void sendError(final Object response) { + completeExceptionally( + response != null ? new Exception(response.toString()) : new UnknownError()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java new file mode 100644 index 0000000000..77bca329c4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java @@ -0,0 +1,133 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.graphics.Color; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public final class CompositorController { + private final GeckoSession.Compositor mCompositor; + + private List 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(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..7d284611b4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java @@ -0,0 +1,1689 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.mozilla.gecko.util.GeckoBundle; + +/** Content Blocking API to hold and control anti-tracking, cookie and Safe Browsing settings. */ +@AnyThread +public class ContentBlocking { + /** {@link SafeBrowsingProvider} configuration for Google's legacy SafeBrowsing server. */ + public static final SafeBrowsingProvider GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER = + SafeBrowsingProvider.withName("google") + .version("2.2") + .lists( + "goog-badbinurl-shavar", + "goog-downloadwhite-digest256", + "goog-phish-shavar", + "googpub-phish-shavar", + "goog-malware-shavar", + "goog-unwanted-shavar") + .updateUrl( + "https://safebrowsing.google.com/safebrowsing/downloads?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2&key=%GOOGLE_SAFEBROWSING_API_KEY%") + .getHashUrl( + "https://safebrowsing.google.com/safebrowsing/gethash?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2") + .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=") + .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=") + .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=") + .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory") + .advisoryName("Google Safe Browsing") + .build(); + + /** {@link SafeBrowsingProvider} configuration for Google's SafeBrowsing server. */ + public static final SafeBrowsingProvider GOOGLE_SAFE_BROWSING_PROVIDER = + SafeBrowsingProvider.withName("google4") + .version("4") + .lists( + "goog-badbinurl-proto", + "goog-downloadwhite-proto", + "goog-phish-proto", + "googpub-phish-proto", + "goog-malware-proto", + "goog-unwanted-proto", + "goog-harmful-proto", + "goog-passwordwhite-proto") + .updateUrl( + "https://safebrowsing.googleapis.com/v4/threatListUpdates:fetch?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .getHashUrl( + "https://safebrowsing.googleapis.com/v4/fullHashes:find?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=") + .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=") + .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=") + .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory") + .advisoryName("Google Safe Browsing") + .dataSharingUrl( + "https://safebrowsing.googleapis.com/v4/threatHits?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .dataSharingEnabled(false) + .build(); + + // This class shouldn't be instantiated + protected ContentBlocking() {} + + @AnyThread + public static class Settings extends RuntimeSettings { + private final Map mSafeBrowsingProviders = new HashMap<>(); + + private static final SafeBrowsingProvider[] DEFAULT_PROVIDERS = { + ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER, + ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER + }; + + @AnyThread + public static class Builder extends RuntimeSettings.Builder { + @Override + protected @NonNull Settings newSettings(final @Nullable Settings settings) { + return new Settings(settings); + } + + /** + * Set custom safe browsing providers. + * + * @param providers one or more custom providers. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingProviders( + final @NonNull SafeBrowsingProvider... providers) { + getSettings().setSafeBrowsingProviders(providers); + return this; + } + + /** + * Set the safe browsing table for phishing threats. + * + * @param safeBrowsingPhishingTable one or more lists for safe browsing phishing. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingPhishingTable( + final @NonNull String[] safeBrowsingPhishingTable) { + getSettings().setSafeBrowsingPhishingTable(safeBrowsingPhishingTable); + return this; + } + + /** + * Set the safe browsing table for malware threats. + * + * @param safeBrowsingMalwareTable one or more lists for safe browsing malware. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingMalwareTable( + final @NonNull String[] safeBrowsingMalwareTable) { + getSettings().setSafeBrowsingMalwareTable(safeBrowsingMalwareTable); + return this; + } + + /** + * Set anti-tracking categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the + * {@link ContentBlocking.AntiTracking} flags. + * @return This Builder instance. + */ + public @NonNull Builder antiTracking(final @CBAntiTracking int cat) { + getSettings().setAntiTracking(cat); + return this; + } + + /** + * Set safe browsing categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the + * {@link ContentBlocking.SafeBrowsing} flags. + * @return This Builder instance. + */ + public @NonNull Builder safeBrowsing(final @CBSafeBrowsing int cat) { + getSettings().setSafeBrowsing(cat); + return this; + } + + /** + * Set cookie storage behavior. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return The Builder instance. + */ + public @NonNull Builder cookieBehavior(final @CBCookieBehavior int behavior) { + getSettings().setCookieBehavior(behavior); + return this; + } + + /** + * Set cookie storage behavior in private browsing mode. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return The Builder instance. + */ + public @NonNull Builder cookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) { + getSettings().setCookieBehaviorPrivateMode(behavior); + return this; + } + + /** + * Set the ETP behavior level. + * + * @param level The level of ETP blocking to use. Only takes effect if cookie behavior is set + * to {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * @return The Builder instance. + */ + public @NonNull Builder enhancedTrackingProtectionLevel(final @CBEtpLevel int level) { + getSettings().setEnhancedTrackingProtectionLevel(level); + return this; + } + + /** + * Set whether or not strict social tracking protection is enabled. This will block resources + * from loading if they are on the social tracking protection list, rather than just blocking + * cookies as with normal social tracking protection. + * + * @param enabled A boolean indicating whether or not strict social tracking protection should + * be enabled. + * @return The builder instance. + */ + public @NonNull Builder strictSocialTrackingProtection(final boolean enabled) { + getSettings().setStrictSocialTrackingProtection(enabled); + return this; + } + + /** + * Set whether or not to automatically purge tracking cookies. This will purge cookies from + * tracking sites that do not have recent user interaction provided that the cookie behavior + * is set to either {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * + * @param enabled A boolean indicating whether or not cookie purging should be enabled. + * @return The builder instance. + */ + public @NonNull Builder cookiePurging(final boolean enabled) { + getSettings().setCookiePurging(enabled); + return this; + } + + /** + * Set the Cookie Banner Handling Mode. + * + * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerHandlingMode(final @CBCookieBannerMode int mode) { + getSettings().setCookieBannerMode(mode); + return this; + } + + /** + * Set the Cookie Banner Handling Mode for private browsing. + * + * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerHandlingModePrivateBrowsing( + final @CBCookieBannerMode int mode) { + getSettings().setCookieBannerModePrivateBrowsing(mode); + return this; + } + + /** + * When set to true, cookie banners are detected and detection events are dispatched, but they + * will not be handled. + * + * @param enabled A boolean indicating whether to enable cookie banner detect only mode. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerHandlingDetectOnlyMode(final boolean enabled) { + getSettings().setCookieBannerDetectOnlyMode(enabled); + return this; + } + } + + /* package */ final Pref mAt = + new Pref( + "urlclassifier.trackingTable", ContentBlocking.catToAtPref(AntiTracking.DEFAULT)); + /* package */ final Pref mCm = + new Pref("privacy.trackingprotection.cryptomining.enabled", false); + /* package */ final Pref mCmList = + new Pref( + "urlclassifier.features.cryptomining.blacklistTables", + ContentBlocking.catToCmListPref(AntiTracking.NONE)); + /* package */ final Pref mFp = + new Pref("privacy.trackingprotection.fingerprinting.enabled", false); + /* package */ final Pref mFpList = + new Pref( + "urlclassifier.features.fingerprinting.blacklistTables", + ContentBlocking.catToFpListPref(AntiTracking.NONE)); + /* package */ final Pref mSt = + new Pref("privacy.socialtracking.block_cookies.enabled", false); + /* package */ final Pref mStStrict = + new Pref("privacy.trackingprotection.socialtracking.enabled", false); + /* package */ final Pref mStList = + new Pref( + "urlclassifier.features.socialtracking.annotate.blacklistTables", + ContentBlocking.catToStListPref(AntiTracking.NONE)); + + /* package */ final Pref mSbMalware = + new Pref("browser.safebrowsing.malware.enabled", true); + /* package */ final Pref mSbPhishing = + new Pref("browser.safebrowsing.phishing.enabled", true); + /* package */ final Pref mCookieBehavior = + new Pref("network.cookie.cookieBehavior", CookieBehavior.ACCEPT_NON_TRACKERS); + /* package */ final Pref mCookieBehaviorPrivateMode = + new Pref( + "network.cookie.cookieBehavior.pbmode", CookieBehavior.ACCEPT_NON_TRACKERS); + /* package */ final Pref mCookiePurging = + new Pref("privacy.purge_trackers.enabled", false); + + /* package */ final Pref mEtpEnabled = + new Pref("privacy.trackingprotection.annotate_channels", false); + /* package */ final Pref mEtpStrict = + new Pref("privacy.annotate_channels.strict_list.enabled", false); + + /* package */ final Pref mCbhMode = + new Pref( + "cookiebanners.service.mode", CookieBannerMode.COOKIE_BANNER_MODE_DISABLED); + /* package */ final Pref mCbhModePrivateBrowsing = + new Pref( + "cookiebanners.service.mode.privateBrowsing", + CookieBannerMode.COOKIE_BANNER_MODE_REJECT); + + /* package */ final Pref mChbDetectOnlyMode = + new Pref("cookiebanners.service.detectOnly", false); + + /* package */ final Pref 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 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 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 @CBSafeBrowsing int getSafeBrowsingCategories() { + return ContentBlocking.sbMalwareToSbCat(mSbMalware.get()) + | ContentBlocking.sbPhishingToSbCat(mSbPhishing.get()); + } + + /** + * Get the assigned cookie storage behavior. + * + * @return The assigned behavior, as one of {@link CookieBehavior} flags. + */ + @SuppressLint("WrongConstant") + public @CBCookieBehavior int getCookieBehavior() { + return mCookieBehavior.get(); + } + + /** + * Set cookie storage behavior. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBehavior(final @CBCookieBehavior int behavior) { + mCookieBehavior.commit(behavior); + return this; + } + + /** + * Get the assigned private mode cookie storage behavior. + * + * @return The assigned behavior, as one of {@link CookieBehavior} flags. + */ + @SuppressLint("WrongConstant") + public @CBCookieBehavior int getCookieBehaviorPrivateMode() { + return mCookieBehaviorPrivateMode.get(); + } + + /** + * Set cookie storage behavior for private browsing mode. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) { + mCookieBehaviorPrivateMode.commit(behavior); + return this; + } + + /** + * Get whether or not cookie purging is enabled. + * + * @return A boolean indicating whether or not cookie purging is enabled. + */ + public boolean getCookiePurging() { + return mCookiePurging.get(); + } + + /** + * Enable or disable cookie purging. This will automatically purge cookies from tracking sites + * that have no recent user interaction, provided the cookie behavior is set to {@link + * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * + * @param enabled A boolean indicating whether to enable cookie purging. + * @return This Settings instance. + */ + public @NonNull Settings setCookiePurging(final boolean enabled) { + mCookiePurging.commit(enabled); + return this; + } + + /** + * Set the Cookie Banner Handling Mode to the new provided {@link CBCookieBannerMode} value. + * + * @param mode Integer indicating the new mode. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerMode(final @CBCookieBannerMode int mode) { + mCbhMode.commit(mode); + return this; + } + + /** + * When set to true, cookie banners are detected and detection events are dispatched, but they + * will not be handled. Requires the service to be enabled for the desired mode via + * setCookieBannerMode. + * + * @param enabled A boolean indicating whether to enable cookie banners. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerDetectOnlyMode(final boolean enabled) { + mChbDetectOnlyMode.commit(enabled); + return this; + } + + /** + * Indicates if cookie banner handling detect only mode is enabled. + * + * @return boolean indicating if the cookie banner handling detect only mode setting is enabled. + */ + public boolean getCookieBannerDetectOnlyMode() { + return mChbDetectOnlyMode.get(); + } + + /** + * Gets the current cookie banner handling mode. + * + * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}. + */ + @SuppressLint("WrongConstant") + public @CBCookieBannerMode int getCookieBannerMode() { + return mCbhMode.get(); + } + + /** + * Set the Cookie Banner Handling Mode for private browsing to the new provided {@link + * CBCookieBannerMode} value. + * + * @param mode Integer indicating the new mode. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerModePrivateBrowsing( + final @CBCookieBannerMode int mode) { + mCbhModePrivateBrowsing.commit(mode); + return this; + } + + /** + * Gets the current cookie banner handling mode for private browsing. + * + * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}. + */ + @SuppressLint("WrongConstant") + public @CBCookieBannerMode int getCookieBannerModePrivateBrowsing() { + return mCbhModePrivateBrowsing.get(); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @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.
    + *
    + * This class can be used to modify existing configuration for SafeBrowsing providers or to add a + * custom SafeBrowsing provider to the app.
    + *
    + * 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}.
    + *
    + * This class is immutable, once constructed its values cannot be changed.
    + *
    + * 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:
    + * + *

    
    +   *     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);
    +   * 
    + * + * This will override the configuration.
    + *
    + * You can also add a custom SafeBrowsing provider using the {@link #withName} method. For + * example, to add a custom provider that provides the list testprovider-phish-digest256 + * do the following:
    + * + *
    
    +   *     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();
    +   * 
    + * + * And then add the custom provider (adding optionally existing providers):
    + * + *
    
    +   *     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);
    +   * 
    + * + * And set the list in the phishing configuration
    + * + *
    
    +   *     runtime.getContentBlocking().setSafeBrowsingPhishingTable(
    +   *          "testprovider-phish-digest256",
    +   *          // Existing configuration
    +   *          "goog-phish-proto");
    +   * 
    + * + * 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 safe-browsing/v4. + */ + @AnyThread + public static class SafeBrowsingProvider extends RuntimeSettings { + private static final String ROOT = "browser.safebrowsing.provider."; + + private final String mName; + + /* package */ final Pref mVersion; + /* package */ final Pref mLists; + /* package */ final Pref mUpdateUrl; + /* package */ final Pref mGetHashUrl; + /* package */ final Pref mReportUrl; + /* package */ final Pref mReportPhishingMistakeUrl; + /* package */ final Pref mReportMalwareMistakeUrl; + /* package */ final Pref mAdvisoryUrl; + /* package */ final Pref mAdvisoryName; + /* package */ final Pref mDataSharingUrl; + /* package */ final Pref mDataSharingEnabled; + + /** + * Creates a {@link SafeBrowsingProvider.Builder} for a provider with the given name. + * + *

    Note: the mozilla 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 name="mozilla" + */ + @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 + * v4/threadListUpdates/fetch . + * + * @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 + * v4/fullHashes/find . + * + * @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 true 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 + * v4/threadListUpdates/fetch . + * + * @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 + * v4/fullHashes/find . + * + * @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 true if the browser should whare threat data with the provider, + * false 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 CREATOR = + new Parcelable.Creator() { + @Override + public SafeBrowsingProvider createFromParcel(final Parcel source) { + final String name = (String) source.readValue(getClass().getClassLoader()); + final SafeBrowsingProvider settings = new SafeBrowsingProvider(name); + settings.readFromParcel(source); + return settings; + } + + @Override + public SafeBrowsingProvider[] newArray(final int size) { + return new SafeBrowsingProvider[size]; + } + }; + } + + private static String listsToPref(final String... lists) { + final StringBuilder prefBuilder = new StringBuilder(); + + for (final String list : lists) { + if (list.contains(",")) { + // We use ',' as the separator, so the list name cannot contain it. + // Should never happen. + throw new IllegalArgumentException("List name cannot contain ',' character."); + } + + prefBuilder.append(list); + prefBuilder.append(","); + } + + // Remove trailing "," + if (lists.length > 0) { + prefBuilder.setLength(prefBuilder.length() - 1); + } + + return prefBuilder.toString(); + } + + private static String[] prefToLists(final String pref) { + return pref != null ? pref.split(",") : new String[] {}; + } + + public static class AntiTracking { + public static final int NONE = 0; + + /** Block advertisement trackers. */ + public static final int AD = 1 << 1; + + /** Block analytics trackers. */ + public static final int ANALYTIC = 1 << 2; + + /** + * Block social trackers. Note: This is not the same as "Social Tracking Protection", which is + * controlled by {@link #STP}. + */ + public static final int SOCIAL = 1 << 3; + + /** Block content trackers. May cause issues with some web sites. */ + public static final int CONTENT = 1 << 4; + + /** Block Gecko test trackers (used for tests). */ + public static final int TEST = 1 << 5; + + /** Block cryptocurrency miners. */ + public static final int CRYPTOMINING = 1 << 6; + + /** Block fingerprinting trackers. */ + public static final int FINGERPRINTING = 1 << 7; + + /** Block trackers on the Social Tracking Protection list. */ + public static final int STP = 1 << 8; + + /** Block ad, analytic, social and test trackers. */ + public static final int DEFAULT = AD | ANALYTIC | SOCIAL | TEST; + + /** Block all known trackers. May cause issues with some web sites. */ + public static final int STRICT = DEFAULT | CONTENT | CRYPTOMINING | FINGERPRINTING; + + protected AntiTracking() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + AntiTracking.AD, + AntiTracking.ANALYTIC, + AntiTracking.SOCIAL, + AntiTracking.CONTENT, + AntiTracking.TEST, + AntiTracking.CRYPTOMINING, + AntiTracking.FINGERPRINTING, + AntiTracking.DEFAULT, + AntiTracking.STRICT, + AntiTracking.STP, + AntiTracking.NONE + }) + public @interface CBAntiTracking {} + + public static class SafeBrowsing { + public static final int NONE = 0; + + /** Block malware sites. */ + public static final int MALWARE = 1 << 10; + + /** Block unwanted sites. */ + public static final int UNWANTED = 1 << 11; + + /** Block harmful sites. */ + public static final int HARMFUL = 1 << 12; + + /** Block phishing sites. */ + public static final int PHISHING = 1 << 13; + + /** Block all unsafe sites. */ + public static final int DEFAULT = MALWARE | UNWANTED | HARMFUL | PHISHING; + + protected SafeBrowsing() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SafeBrowsing.MALWARE, SafeBrowsing.UNWANTED, + SafeBrowsing.HARMFUL, SafeBrowsing.PHISHING, + SafeBrowsing.DEFAULT, SafeBrowsing.NONE + }) + public @interface CBSafeBrowsing {} + + // Sync values with nsICookieService.idl. + public static class CookieBehavior { + /** Accept first-party and third-party cookies and site data. */ + public static final int ACCEPT_ALL = 0; + + /** + * Accept only first-party cookies and site data to block cookies which are not associated with + * the domain of the visited site. + */ + public static final int ACCEPT_FIRST_PARTY = 1; + + /** Do not store any cookies and site data. */ + public static final int ACCEPT_NONE = 2; + + /** + * Accept first-party and third-party cookies and site data only from sites previously visited + * in a first-party context. + */ + public static final int ACCEPT_VISITED = 3; + + /** + * Accept only first-party and non-tracking third-party cookies and site data to block cookies + * which are not associated with the domain of the visited site set by known trackers. + */ + public static final int ACCEPT_NON_TRACKERS = 4; + + /** + * Enable dynamic first party isolation (dFPI); this will block third-party tracking cookies in + * accordance with the ETP level and isolate non-tracking third-party cookies. + */ + public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5; + + protected CookieBehavior() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CookieBehavior.ACCEPT_ALL, CookieBehavior.ACCEPT_FIRST_PARTY, + CookieBehavior.ACCEPT_NONE, CookieBehavior.ACCEPT_VISITED, + CookieBehavior.ACCEPT_NON_TRACKERS + }) + public @interface CBCookieBehavior {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT}) + public @interface CBEtpLevel {} + + /** Possible settings for ETP. */ + public static class EtpLevel { + /** Do not enable ETP at all. */ + public static final int NONE = 0; + + /** Enable ETP for ads, analytic, and social tracking lists. */ + public static final int DEFAULT = 1; + + /** + * Enable ETP for all of the default lists as well as the content list. May break many sites! + */ + public static final int STRICT = 2; + } + + /** Holds content block event details. */ + public static class BlockEvent { + /** The URI of the blocked resource. */ + public final @NonNull String uri; + + private final @CBAntiTracking int mAntiTrackingCat; + private final @CBSafeBrowsing int mSafeBrowsingCat; + private final @CBCookieBehavior int mCookieBehaviorCat; + private final boolean mIsBlocking; + + @SuppressWarnings("checkstyle:javadocmethod") + public BlockEvent( + @NonNull final String uri, + final @CBAntiTracking int atCat, + final @CBSafeBrowsing int sbCat, + final @CBCookieBehavior int cbCat, + final boolean isBlocking) { + this.uri = uri; + this.mAntiTrackingCat = atCat; + this.mSafeBrowsingCat = sbCat; + this.mCookieBehaviorCat = cbCat; + this.mIsBlocking = isBlocking; + } + + /** + * The anti-tracking category types of the blocked resource. + * + * @return One or more of the {@link AntiTracking} flags. + */ + @UiThread + public @CBAntiTracking int getAntiTrackingCategory() { + return mAntiTrackingCat; + } + + /** + * The safe browsing category types of the blocked resource. + * + * @return One or more of the {@link SafeBrowsing} flags. + */ + @UiThread + public @CBSafeBrowsing int getSafeBrowsingCategory() { + return mSafeBrowsingCat; + } + + /** + * The cookie types of the blocked resource. + * + * @return One or more of the {@link CookieBehavior} flags. + */ + @UiThread + public @CBCookieBehavior int getCookieBehaviorCategory() { + return mCookieBehaviorCat; + } + + /* package */ static BlockEvent fromBundle(@NonNull final GeckoBundle bundle) { + final String uri = bundle.getString("uri"); + final String blockedList = bundle.getString("blockedList"); + final String loadedList = TextUtils.join(",", bundle.getStringArray("loadedLists")); + final long error = bundle.getLong("error", 0L); + final long category = bundle.getLong("category", 0L); + + final String matchedList = blockedList != null ? blockedList : loadedList; + + // Note: Even if loadedList is non-empty it does not necessarily + // mean that the event is not a blocking event. + final boolean blocking = + (blockedList != null || error != 0L || ContentBlocking.isBlockingGeckoCbCat(category)); + + return new BlockEvent( + uri, + ContentBlocking.atListToAtCat(matchedList) + | ContentBlocking.cmListToAtCat(matchedList) + | ContentBlocking.fpListToAtCat(matchedList) + | ContentBlocking.stListToAtCat(matchedList), + ContentBlocking.errorToSbCat(error), + ContentBlocking.geckoCatToCbCat(category), + blocking); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public boolean isBlocking() { + return mIsBlocking; + } + } + + /** GeckoSession applications implement this interface to handle content blocking events. */ + public interface Delegate { + /** + * A content element has been blocked from loading. Set blocked element categories via {@link + * GeckoRuntimeSettings} and enable content blocking via {@link GeckoSessionSettings}. + * + * @param session The GeckoSession that initiated the callback. + * @param event The {@link BlockEvent} details. + */ + @UiThread + default void onContentBlocked( + @NonNull final GeckoSession session, @NonNull final BlockEvent event) {} + + /** + * A content element that could be blocked has been loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param event The {@link BlockEvent} details. + */ + @UiThread + default void onContentLoaded( + @NonNull final GeckoSession session, @NonNull final BlockEvent event) {} + } + + private static final String TEST = "moztest-track-simple"; + private static final String AD = "ads-track-digest256"; + private static final String ANALYTIC = "analytics-track-digest256"; + private static final String SOCIAL = "social-track-digest256"; + private static final String CONTENT = "content-track-digest256"; + private static final String CRYPTOMINING = "base-cryptomining-track-digest256"; + private static final String FINGERPRINTING = "base-fingerprinting-track-digest256"; + private static final String STP = + "social-tracking-protection-facebook-digest256,social-tracking-protection-linkedin-digest256,social-tracking-protection-twitter-digest256"; + + /* package */ static @CBSafeBrowsing int sbMalwareToSbCat(final boolean enabled) { + return enabled + ? (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL) + : SafeBrowsing.NONE; + } + + /* package */ static @CBSafeBrowsing int sbPhishingToSbCat(final boolean enabled) { + return enabled ? SafeBrowsing.PHISHING : SafeBrowsing.NONE; + } + + /* package */ static boolean catToSbMalware(@CBAntiTracking final int cat) { + return (cat & (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)) != 0; + } + + /* package */ static boolean catToSbPhishing(@CBAntiTracking final int cat) { + return (cat & SafeBrowsing.PHISHING) != 0; + } + + /* package */ static String catToAtPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.TEST) != 0) { + builder.append(TEST).append(','); + } + if ((cat & AntiTracking.AD) != 0) { + builder.append(AD).append(','); + } + if ((cat & AntiTracking.ANALYTIC) != 0) { + builder.append(ANALYTIC).append(','); + } + if ((cat & AntiTracking.SOCIAL) != 0) { + builder.append(SOCIAL).append(','); + } + if ((cat & AntiTracking.CONTENT) != 0) { + builder.append(CONTENT).append(','); + } + if (builder.length() == 0) { + return ""; + } + // Trim final ','. + return builder.substring(0, builder.length() - 1); + } + + /* package */ static boolean catToCmPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.CRYPTOMINING) != 0; + } + + /* package */ static String catToCmListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.CRYPTOMINING) != 0) { + builder.append(CRYPTOMINING); + } + return builder.toString(); + } + + /* package */ static boolean catToFpPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.FINGERPRINTING) != 0; + } + + /* package */ static String catToFpListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.FINGERPRINTING) != 0) { + builder.append(FINGERPRINTING); + } + return builder.toString(); + } + + /* package */ static @CBAntiTracking int fpListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(FINGERPRINTING) != -1) { + cat |= AntiTracking.FINGERPRINTING; + } + return cat; + } + + /* package */ static boolean catToStPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.STP) != 0; + } + + /* package */ static String catToStListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.STP) != 0) { + builder.append(STP).append(","); + } + if (builder.length() == 0) { + return ""; + } + // Trim final ','. + return builder.substring(0, builder.length() - 1); + } + + /* package */ static @CBAntiTracking int atListToAtCat(final String list) { + int cat = AntiTracking.NONE; + + if (list == null) { + return cat; + } + if (list.indexOf(TEST) != -1) { + cat |= AntiTracking.TEST; + } + if (list.indexOf(AD) != -1) { + cat |= AntiTracking.AD; + } + if (list.indexOf(ANALYTIC) != -1) { + cat |= AntiTracking.ANALYTIC; + } + if (list.indexOf(SOCIAL) != -1) { + cat |= AntiTracking.SOCIAL; + } + if (list.indexOf(CONTENT) != -1) { + cat |= AntiTracking.CONTENT; + } + return cat; + } + + /* package */ static @CBAntiTracking int cmListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(CRYPTOMINING) != -1) { + cat |= AntiTracking.CRYPTOMINING; + } + return cat; + } + + /* package */ static @CBAntiTracking int stListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(STP) != -1) { + cat |= AntiTracking.STP; + } + return cat; + } + + /* package */ static @CBSafeBrowsing int errorToSbCat(final long error) { + // Match flags with XPCOM ErrorList.h. + if (error == 0x805D001FL) { + return SafeBrowsing.PHISHING; + } + if (error == 0x805D001EL) { + return SafeBrowsing.MALWARE; + } + if (error == 0x805D0023L) { + return SafeBrowsing.UNWANTED; + } + if (error == 0x805D0026L) { + return SafeBrowsing.HARMFUL; + } + return SafeBrowsing.NONE; + } + + // Match flags with nsIWebProgressListener.idl. + private static final long STATE_COOKIES_LOADED = 0x8000L; + private static final long STATE_COOKIES_LOADED_TRACKER = 0x40000L; + private static final long STATE_COOKIES_LOADED_SOCIALTRACKER = 0x80000L; + private static final long STATE_COOKIES_BLOCKED_TRACKER = 0x20000000L; + private static final long STATE_COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000L; + private static final long STATE_COOKIES_BLOCKED_ALL = 0x40000000L; + private static final long STATE_COOKIES_BLOCKED_FOREIGN = 0x80L; + + /* package */ static boolean isBlockingGeckoCbCat(final long geckoCat) { + return (geckoCat + & (STATE_COOKIES_BLOCKED_TRACKER + | STATE_COOKIES_BLOCKED_SOCIALTRACKER + | STATE_COOKIES_BLOCKED_ALL + | STATE_COOKIES_BLOCKED_FOREIGN)) + != 0; + } + + /* package */ static @CBCookieBehavior int geckoCatToCbCat(final long geckoCat) { + if ((geckoCat & STATE_COOKIES_LOADED) != 0) { + // We don't know which setting would actually block this cookie, so + // we return the most strict value. + return CookieBehavior.ACCEPT_NONE; + } + if ((geckoCat & STATE_COOKIES_BLOCKED_FOREIGN) != 0) { + return CookieBehavior.ACCEPT_FIRST_PARTY; + } + // If we receive STATE_COOKIES_LOADED_{SOCIAL,}TRACKER we know that this + // setting would block this cookie. + if ((geckoCat + & (STATE_COOKIES_BLOCKED_TRACKER + | STATE_COOKIES_BLOCKED_SOCIALTRACKER + | STATE_COOKIES_LOADED_TRACKER + | STATE_COOKIES_LOADED_SOCIALTRACKER)) + != 0) { + return CookieBehavior.ACCEPT_NON_TRACKERS; + } + if ((geckoCat & STATE_COOKIES_BLOCKED_ALL) != 0) { + return CookieBehavior.ACCEPT_NONE; + } + // TODO: There are more reasons why cookies may be blocked. + return CookieBehavior.ACCEPT_ALL; + } + + // Cookie Banner Handling feature. + + public static class CookieBannerMode { + /** Do not enable handling cookie banners. */ + public static final int COOKIE_BANNER_MODE_DISABLED = 0; + + /** Only handle banners where selecting "reject all" is possible. */ + public static final int COOKIE_BANNER_MODE_REJECT = 1; + + /** Reject cookies when possible otherwise accept the cookies. */ + public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2; + + protected CookieBannerMode() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CookieBannerMode.COOKIE_BANNER_MODE_DISABLED, + CookieBannerMode.COOKIE_BANNER_MODE_REJECT, + CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, + }) + public @interface CBCookieBannerMode {} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java new file mode 100644 index 0000000000..73238b7eac --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java @@ -0,0 +1,203 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * ContentBlockingController is used to manage and modify the content blocking exception list. This + * list is shared across all sessions. + */ +@AnyThread +public class ContentBlockingController { + private static final String LOGTAG = "GeckoContentBlocking"; + + public static class Event { + // These values must be kept in sync with the corresponding values in + // nsIWebProgressListener.idl. + /** Tracking content has been blocked from loading. */ + public static final int BLOCKED_TRACKING_CONTENT = 0x00001000; + + /** Level 1 tracking content has been loaded. */ + public static final int LOADED_LEVEL_1_TRACKING_CONTENT = 0x00002000; + + /** Level 2 tracking content has been loaded. */ + public static final int LOADED_LEVEL_2_TRACKING_CONTENT = 0x00100000; + + /** Fingerprinting content has been blocked from loading. */ + public static final int BLOCKED_FINGERPRINTING_CONTENT = 0x00000040; + + /** Fingerprinting content has been loaded. */ + public static final int LOADED_FINGERPRINTING_CONTENT = 0x00000400; + + /** Cryptomining content has been blocked from loading. */ + public static final int BLOCKED_CRYPTOMINING_CONTENT = 0x00000800; + + /** Cryptomining content has been loaded. */ + public static final int LOADED_CRYPTOMINING_CONTENT = 0x00200000; + + /** Content which appears on the SafeBrowsing list has been blocked from loading. */ + public static final int BLOCKED_UNSAFE_CONTENT = 0x00004000; + + /** + * Performed a storage access check, which usually means something like a cookie or a storage + * item was loaded/stored on the current tab. Alternatively this could indicate that something + * in the current tab attempted to communicate with its same-origin counterparts in other tabs. + */ + public static final int COOKIES_LOADED = 0x00008000; + + /** + * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a + * third-party tracker when the active cookie policy imposes restrictions on such content. + */ + public static final int COOKIES_LOADED_TRACKER = 0x00040000; + + /** + * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a + * third-party social tracker when the active cookie policy imposes restrictions on such + * content. + */ + public static final int COOKIES_LOADED_SOCIALTRACKER = 0x00080000; + + /** Rejected for custom site permission. */ + public static final int COOKIES_BLOCKED_BY_PERMISSION = 0x10000000; + + /** Rejected because the resource is a tracker and cookie policy doesn't allow its loading. */ + public static final int COOKIES_BLOCKED_TRACKER = 0x20000000; + + /** + * Rejected because the resource is a tracker from a social origin and cookie policy doesn't + * allow its loading. + */ + public static final int COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000; + + /** Rejected because cookie policy blocks all cookies. */ + public static final int COOKIES_BLOCKED_ALL = 0x40000000; + + /** + * Rejected because the resource is a third-party and cookie policy forces third-party resources + * to be partitioned. + */ + public static final int COOKIES_PARTITIONED_FOREIGN = 0x80000000; + + /** Rejected because cookie policy blocks 3rd party cookies. */ + public static final int COOKIES_BLOCKED_FOREIGN = 0x00000080; + + /** SocialTracking content has been blocked from loading. */ + public static final int BLOCKED_SOCIALTRACKING_CONTENT = 0x00010000; + + /** SocialTracking content has been loaded. */ + public static final int LOADED_SOCIALTRACKING_CONTENT = 0x00020000; + + /** + * Indicates that content that would have been blocked has instead been replaced with a shim. + */ + public static final int REPLACED_TRACKING_CONTENT = 0x00000010; + + /** Indicates that content that would have been blocked has instead been allowed by a shim. */ + public static final int ALLOWED_TRACKING_CONTENT = 0x00000020; + + protected Event() {} + } + + /** An entry in the content blocking log for a site. */ + @AnyThread + public static class LogEntry { + /** Data about why a given entry was blocked. */ + public static class BlockingData { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Event.BLOCKED_TRACKING_CONTENT, Event.LOADED_LEVEL_1_TRACKING_CONTENT, + Event.LOADED_LEVEL_2_TRACKING_CONTENT, Event.BLOCKED_FINGERPRINTING_CONTENT, + Event.LOADED_FINGERPRINTING_CONTENT, Event.BLOCKED_CRYPTOMINING_CONTENT, + Event.LOADED_CRYPTOMINING_CONTENT, Event.BLOCKED_UNSAFE_CONTENT, + Event.COOKIES_LOADED, Event.COOKIES_LOADED_TRACKER, + Event.COOKIES_LOADED_SOCIALTRACKER, Event.COOKIES_BLOCKED_BY_PERMISSION, + Event.COOKIES_BLOCKED_TRACKER, Event.COOKIES_BLOCKED_SOCIALTRACKER, + Event.COOKIES_BLOCKED_ALL, Event.COOKIES_PARTITIONED_FOREIGN, + Event.COOKIES_BLOCKED_FOREIGN, Event.BLOCKED_SOCIALTRACKING_CONTENT, + Event.LOADED_SOCIALTRACKING_CONTENT, Event.REPLACED_TRACKING_CONTENT + }) + public @interface LogEvent {} + + /** A category the entry falls under. */ + public final @LogEvent int category; + + /** Indicates whether or not blocking occured for this category, where applicable. */ + public final boolean blocked; + + /** The count of consecutive repeated appearances. */ + public final int count; + + /* package */ BlockingData(final @NonNull GeckoBundle bundle) { + category = bundle.getInt("category"); + blocked = bundle.getBoolean("blocked"); + count = bundle.getInt("count"); + } + + protected BlockingData() { + category = Event.BLOCKED_TRACKING_CONTENT; + blocked = false; + count = 0; + } + } + + /** The origin of this log entry. */ + public final @NonNull String origin; + + /** The blocking data for this origin, sorted chronologically. */ + public final @NonNull List blockingData; + + /* package */ LogEntry(final @NonNull GeckoBundle bundle) { + origin = bundle.getString("origin"); + final GeckoBundle[] data = bundle.getBundleArray("blockData"); + final ArrayList dataArray = new ArrayList(data.length); + for (final GeckoBundle b : data) { + dataArray.add(new BlockingData(b)); + } + blockingData = Collections.unmodifiableList(dataArray); + } + + protected LogEntry() { + origin = null; + blockingData = null; + } + } + + private List logFromBundle(final GeckoBundle value) { + final GeckoBundle[] bundles = value.getBundleArray("log"); + final ArrayList logArray = new ArrayList<>(bundles.length); + for (final GeckoBundle b : bundles) { + logArray.add(new LogEntry(b)); + } + return Collections.unmodifiableList(logArray); + } + + /** + * Get a log of all content blocking information for the site currently loaded by the supplied + * {@link GeckoSession}. + * + * @param session A {@link GeckoSession} for which you want the content blocking log. + * @return A {@link GeckoResult} that resolves to the list of content blocking log entries. + */ + @UiThread + public @NonNull GeckoResult> getLog(final @NonNull GeckoSession session) { + return session + .getEventDispatcher() + .queryBundle("ContentBlocking:RequestLog") + .map(this::logFromBundle); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java new file mode 100644 index 0000000000..691686e230 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java @@ -0,0 +1,385 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.zip.GZIPOutputStream; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.ProxySelector; + +/** + * Sends a crash report to the Mozilla Socorro 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 IGNORE_KEYS = + Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY); + + /** + * Sends a crash report to the Mozilla Socorro + * crash report server.
    + * The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug 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 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 Socorro + * crash report server.
    + * The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug 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 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 Socorro + * crash report server.
    + * The {@code appName} needs to be whitelisted for the server to accept the crash. File a bug 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 sendCrashReport( + @NonNull final Context context, + @NonNull final File minidumpFile, + @NonNull final File extrasFile, + @NonNull final String appName) + throws IOException, URISyntaxException { + final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName); + + final String url = annotations.optString(SERVER_URL_KEY, null); + if (url == null) { + return GeckoResult.fromException(new Exception("No server url present")); + } + + for (final String key : IGNORE_KEYS) { + annotations.remove(key); + } + + return sendCrashReport(url, minidumpFile, annotations); + } + + /** + * Sends a crash report to the Mozilla Socorro + * 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 sendCrashReport( + @NonNull final String serverURL, + @NonNull final File minidumpFile, + @NonNull final JSONObject extras) + throws IOException, URISyntaxException { + Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath()); + + HttpURLConnection conn = null; + try { + final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8")); + final URI uri = + new URI( + url.getProtocol(), + url.getUserInfo(), + url.getHost(), + url.getPort(), + url.getPath(), + url.getQuery(), + url.getRef()); + conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri); + conn.setRequestMethod("POST"); + final String boundary = generateBoundary(); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + conn.setRequestProperty("Content-Encoding", "gzip"); + + final OutputStream os = new GZIPOutputStream(conn.getOutputStream()); + sendAnnotations(os, boundary, extras); + sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile); + os.write(("\r\n--" + boundary + "--\r\n").getBytes()); + os.flush(); + os.close(); + + BufferedReader br = null; + try { + br = new BufferedReader(new InputStreamReader(conn.getInputStream())); + final HashMap responseMap = readStringsFromReader(br); + + if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) { + final String crashid = responseMap.get("CrashID"); + if (crashid != null) { + Log.i(LOGTAG, "Successfully sent crash report: " + crashid); + return GeckoResult.fromValue(crashid); + } else { + Log.i(LOGTAG, "Server rejected crash report"); + } + } else { + Log.w( + LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode()); + } + } catch (final Exception e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } finally { + try { + if (br != null) { + br.close(); + } + } catch (final IOException e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } + } + } catch (final Exception e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return GeckoResult.fromException(new Exception("Failed to submit crash report")); + } + + private static String computeMinidumpHash(@NonNull final File minidump) throws IOException { + MessageDigest md = null; + final FileInputStream stream = new FileInputStream(minidump); + try { + md = MessageDigest.getInstance("SHA-256"); + + final byte[] buffer = new byte[4096]; + int readBytes; + + while ((readBytes = stream.read(buffer)) != -1) { + md.update(buffer, 0, readBytes); + } + } catch (final NoSuchAlgorithmException e) { + throw new IOException(e); + } finally { + stream.close(); + } + + final byte[] digest = md.digest(); + final StringBuilder hash = new StringBuilder(64); + + for (int i = 0; i < digest.length; i++) { + hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4)); + hash.append(Integer.toHexString(digest[i] & 0x0f)); + } + + return hash.toString(); + } + + private static HashMap readStringsFromReader(final BufferedReader reader) + throws IOException { + String line; + final HashMap map = new HashMap<>(); + while ((line = reader.readLine()) != null) { + int equalsPos = -1; + if ((equalsPos = line.indexOf('=')) != -1) { + final String key = line.substring(0, equalsPos); + final String val = unescape(line.substring(equalsPos + 1)); + map.put(key, val); + } + } + return map; + } + + private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException { + final byte[] buffer = new byte[4096]; + final FileInputStream inputStream = new FileInputStream(filePath); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + int bytesRead = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + final String contents = new String(outputStream.toByteArray(), "UTF-8"); + return new JSONObject(contents); + } + + private static JSONObject getCrashAnnotations( + @NonNull final Context context, + @NonNull final File minidump, + @NonNull final File extra, + @NonNull final String appName) + throws IOException { + try { + final JSONObject annotations = readExtraFile(extra.getPath()); + + // Compute the minidump hash and generate the stack traces + try { + final String hash = computeMinidumpHash(minidump); + annotations.put(MINIDUMP_SHA256_HASH_KEY, hash); + } catch (final Exception e) { + Log.e(LOGTAG, "exception while computing the minidump hash: ", e); + } + + annotations.put(PRODUCT_NAME_KEY, appName); + annotations.put(PRODUCT_ID_KEY, PRODUCT_ID); + annotations.put("Android_Manufacturer", Build.MANUFACTURER); + annotations.put("Android_Model", Build.MODEL); + annotations.put("Android_Board", Build.BOARD); + annotations.put("Android_Brand", Build.BRAND); + annotations.put("Android_Device", Build.DEVICE); + annotations.put("Android_Display", Build.DISPLAY); + annotations.put("Android_Fingerprint", Build.FINGERPRINT); + annotations.put("Android_CPU_ABI", Build.CPU_ABI); + annotations.put("Android_PackageName", context.getPackageName()); + try { + annotations.put("Android_CPU_ABI2", Build.CPU_ABI2); + annotations.put("Android_Hardware", Build.HARDWARE); + } catch (final Exception ex) { + Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex); + } + annotations.put( + "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")"); + + return annotations; + } catch (final JSONException e) { + throw new IOException(e); + } + } + + private static String generateBoundary() { + // Generate some random numbers to fill out the boundary + final int r0 = (int) (Integer.MAX_VALUE * Math.random()); + final int r1 = (int) (Integer.MAX_VALUE * Math.random()); + return String.format("---------------------------%08X%08X", r0, r1); + } + + private static void sendAnnotations( + final OutputStream os, final String boundary, final JSONObject extras) throws IOException { + os.write( + ("--" + + boundary + + "\r\n" + + "Content-Disposition: form-data; name=\"extra\"; " + + "filename=\"extra.json\"\r\n" + + "Content-Type: application/json\r\n" + + "\r\n") + .getBytes()); + os.write(extras.toString().getBytes("UTF-8")); + os.write('\n'); + } + + private static void sendFile( + final OutputStream os, final String boundary, final String name, final File file) + throws IOException { + os.write( + ("--" + + boundary + + "\r\n" + + "Content-Disposition: form-data; name=\"" + + name + + "\"; " + + "filename=\"" + + file.getName() + + "\"\r\n" + + "Content-Type: application/octet-stream\r\n" + + "\r\n") + .getBytes()); + final FileChannel fc = new FileInputStream(file).getChannel(); + fc.transferTo(0, fc.size(), Channels.newChannel(os)); + fc.close(); + } + + private static String unescape(final String string) { + return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t"); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java new file mode 100644 index 0000000000..fe6b723983 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.LOCAL_VARIABLE; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Additional metadata about a deprecation notice. */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE}) +public @interface DeprecationSchedule { + /** + * @return Major version when we expect to remove the deprecated member attached to this + * annotation. + */ + int version(); + + /** + * @return Identifier for a deprecation notice. All notices with the same identifier will be + * removed at the same time. + */ + String id(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java new file mode 100644 index 0000000000..1fc34cb8bb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java @@ -0,0 +1,528 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.view.Surface; +import android.view.SurfaceControl; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Applications use a GeckoDisplay instance to provide {@link GeckoSession} with a {@link Surface} + * for displaying content. To ensure drawing only happens on a valid {@link Surface}, {@link + * GeckoSession} will only use the provided {@link Surface} after {@link + * #surfaceChanged(SurfaceInfo)} is called and before {@link #surfaceDestroyed()} returns. + */ +public class GeckoDisplay { + private final GeckoSession mSession; + + protected GeckoDisplay(final GeckoSession session) { + mSession = session; + } + + /** + * Interface that allows Gecko the request a new Surface from the application. An implementation + * of this should be set on the {@link GeckoDisplay.SurfaceInfo} object passed to {@link + * GeckoDisplay#surfaceChanged(SurfaceInfo)}, by using {@link + * GeckoDisplay.SurfaceInfo.Builder#newSurfaceProvider(NewSurfaceProvider)}. + */ + public interface NewSurfaceProvider { + /** + * Called by Gecko to request a new Surface from the application. + * + *

    Occasionally the Surface provided to Gecko via {@link #surfaceChanged(SurfaceInfo)} is + * invalid and Gecko is unable to render in to it. This function will be called in such + * circumstances. It is the implementation's responsibility to ensure that {@link + * #surfaceChanged(SurfaceInfo)} gets called soon afterwards with a new Surface, allowing Gecko + * to resume rendering. + * + *

    Failure to implement this function may result in Gecko either crashing or not rendering + * correctly should it encounter an invalid Surface. + */ + @UiThread + void requestNewSurface(); + } + + /** + * Wrapper class containing a Surface and associated information that the compositor should render + * in to. Should be constructed using {@link SurfaceInfo.Builder}. + */ + public static class SurfaceInfo { + /* package */ final @NonNull Surface mSurface; + /* package */ final @Nullable SurfaceControl mSurfaceControl; + /* package */ final @Nullable NewSurfaceProvider mNewSurfaceProvider; + /* package */ final int mLeft; + /* package */ final int mTop; + /* package */ final int mWidth; + /* package */ final int mHeight; + + private SurfaceInfo(final @NonNull Builder builder) { + mSurface = builder.mSurface; + mSurfaceControl = builder.mSurfaceControl; + mNewSurfaceProvider = builder.mNewSurfaceProvider; + mLeft = builder.mLeft; + mTop = builder.mTop; + mWidth = builder.mWidth; + mHeight = builder.mHeight; + } + + /** Helper class for constructing a {@link SurfaceInfo} object. */ + public static class Builder { + private Surface mSurface; + private SurfaceControl mSurfaceControl; + private NewSurfaceProvider mNewSurfaceProvider; + private int mLeft; + private int mTop; + private int mWidth; + private int mHeight; + + /** + * Creates a new Builder and sets the new Surface. + * + * @param surface The new Surface. + */ + public Builder(final @NonNull Surface surface) { + mSurface = surface; + } + + /** + * Sets the SurfaceControl associated with the new Surface's SurfaceView. + * + *

    This must be called when rendering in to a {@link android.view.SurfaceView} on SDK level + * 29 or above. On earlier SDK levels, or when rendering in to something other than a + * SurfaceView, this call can be omitted or the value can be null. + * + * @param surfaceControl The SurfaceControl associated with the new Surface's SurfaceView, or + * null. + * @return The builder object + */ + @UiThread + public @NonNull Builder surfaceControl(final @Nullable SurfaceControl surfaceControl) { + mSurfaceControl = surfaceControl; + return this; + } + + /** + * Sets a NewSurfaceProvider from which Gecko can request a new Surface. + * + *

    This allows Gecko to recover from situations where the current Surface is for whatever + * reason invalid and Gecko is unable to render in to it. Failure to set this field correctly + * may result in Gecko either crashing or not rendering correctly should it encounter an + * invalid Surface. + * + * @param newSurfaceProvider A NewSurfaceProvider from which Gecko can request a new Surface. + * @return The builder object + */ + @UiThread + public @NonNull Builder newSurfaceProvider( + final @Nullable NewSurfaceProvider newSurfaceProvider) { + mNewSurfaceProvider = newSurfaceProvider; + return this; + } + + /** + * Sets the new compositor origin offset. + * + * @param left The compositor origin offset in the X axis. Can not be negative. + * @param top The compositor origin offset in the Y axis. Can not be negative. + * @return The builder object + */ + @UiThread + public @NonNull Builder offset(final int left, final int top) { + mLeft = left; + mTop = top; + return this; + } + + /** + * Sets the new surface size. + * + * @param width New width of the Surface. Can not be negative. + * @param height New height of the Surface. Can not be negative. + * @return The builder object + */ + @UiThread + public @NonNull Builder size(final int width, final int height) { + mWidth = width; + mHeight = height; + return this; + } + + /** + * Builds the {@link SurfaceInfo} object with the specified properties. + * + * @return The SurfaceInfo object + */ + @UiThread + public @NonNull SurfaceInfo build() { + if ((mLeft < 0) || (mTop < 0)) { + throw new IllegalArgumentException("Left and Top offsets can not be negative."); + } + + return new SurfaceInfo(this); + } + } + } + + /** + * Sets a surface for the compositor render a surface. + * + *

    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. + * + *

    If rendering in to a {@link android.view.SurfaceView} on SDK level 29 or above, please + * ensure that the SurfaceControl field of the {@link SurfaceInfo} object is set. + * + * @param surfaceInfo Information about the new Surface. + */ + @UiThread + public void surfaceChanged(@NonNull final SurfaceInfo surfaceInfo) { + ThreadUtils.assertOnUiThread(); + + if (mSession.getDisplay() == this) { + mSession.onSurfaceChanged(surfaceInfo); + } + } + + /** + * Removes the current surface registered with the compositor. + * + *

    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 capturePixels() { + return screenshot().capture(); + } + + /** Builder to construct screenshot requests. */ + public static final class ScreenshotBuilder { + private static final int NONE = 0; + private static final int SCALE = 1; + private static final int ASPECT = 2; + private static final int FULL = 3; + private static final int RECYCLE = 4; + + private final GeckoSession mSession; + private int mOffsetX; + private int mOffsetY; + private int mSrcWidth; + private int mSrcHeight; + private int mOutWidth; + private int mOutHeight; + private int mAspectPreservingWidth; + private float mScale; + private Bitmap mRecycle; + private int mSizeType; + + /* package */ ScreenshotBuilder(final GeckoSession session) { + this.mSizeType = NONE; + this.mSession = session; + } + + /** + * The screenshot will be of a region instead of the entire screen + * + * @param x Left most pixel of the source region. + * @param y Top most pixel of the source region. + * @param width Width of the source region in screen pixels + * @param height Height of the source region in screen pixels + * @return The builder + */ + @AnyThread + public @NonNull ScreenshotBuilder source( + final int x, final int y, final int width, final int height) { + mOffsetX = x; + mOffsetY = y; + mSrcWidth = width; + mSrcHeight = height; + return this; + } + + /** + * The screenshot will be of a region instead of the entire screen + * + * @param source Region of the screen to capture in screen pixels + * @return The builder + */ + @AnyThread + public @NonNull ScreenshotBuilder source(final @NonNull Rect source) { + mOffsetX = source.left; + mOffsetY = source.top; + mSrcWidth = source.width(); + mSrcHeight = source.height(); + return this; + } + + private void checkAndSetSizeType(final int sizeType) { + if (mSizeType != NONE) { + throw new IllegalStateException("Size has already been set."); + } + mSizeType = sizeType; + } + + /** + * The width of the bitmap to create when taking the screenshot. The height will be calculated + * to match the aspect ratio of the source as closely as possible. The source screenshot will be + * scaled into the resulting Bitmap. + * + * @param width of the result Bitmap in screen pixels. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder aspectPreservingSize(final int width) { + checkAndSetSizeType(ASPECT); + mAspectPreservingWidth = width; + return this; + } + + /** + * The scale of the bitmap relative to the source. The height and width of the output bitmap + * will be within one pixel of this multiple of the source dimensions. The source screenshot + * will be scaled into the resulting Bitmap. + * + * @param scale of the result Bitmap relative to the source. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder scale(final float scale) { + checkAndSetSizeType(SCALE); + mScale = scale; + return this; + } + + /** + * Size of the bitmap to create when taking the screenshot. The source screenshot will be scaled + * into the resulting Bitmap + * + * @param width of the result Bitmap in screen pixels. + * @param height of the result Bitmap in screen pixels. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder size(final int width, final int height) { + checkAndSetSizeType(FULL); + mOutWidth = width; + mOutHeight = height; + return this; + } + + /** + * Instead of creating a new Bitmap for the result, the builder will use the passed Bitmap. + * + * @param bitmap The Bitmap to use in the result. + * @return The builder. + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder bitmap(final @Nullable Bitmap bitmap) { + checkAndSetSizeType(RECYCLE); + mRecycle = bitmap; + return this; + } + + /** + * Request a {@link Bitmap} of the requested portion of the web page currently being rendered + * using any parameters specified with the builder. + * + *

    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 capture() { + ThreadUtils.assertOnUiThread(); + if (!mSession.isCompositorReady()) { + throw new IllegalStateException("Compositor must be ready before pixels can be captured"); + } + + final GeckoResult result = new GeckoResult<>(); + final Bitmap target; + final Rect rect = new Rect(); + + if (mSrcWidth == 0 || mSrcHeight == 0) { + // Source is unset or invalid, use defaults. + mSession.getSurfaceBounds(rect); + mSrcWidth = rect.width(); + mSrcHeight = rect.height(); + } + + switch (mSizeType) { + case NONE: + mOutWidth = mSrcWidth; + mOutHeight = mSrcHeight; + break; + case SCALE: + mSession.getSurfaceBounds(rect); + mOutWidth = (int) (rect.width() * mScale); + mOutHeight = (int) (rect.height() * mScale); + break; + case ASPECT: + mSession.getSurfaceBounds(rect); + mOutWidth = mAspectPreservingWidth; + mOutHeight = (int) (rect.height() * (mAspectPreservingWidth / (double) rect.width())); + break; + case RECYCLE: + mOutWidth = mRecycle.getWidth(); + mOutHeight = mRecycle.getHeight(); + break; + // case FULL does not need to be handled, as width and height are already set. + } + + if (mRecycle == null) { + try { + target = Bitmap.createBitmap(mOutWidth, mOutHeight, Bitmap.Config.ARGB_8888); + } catch (final Throwable e) { + if (e instanceof NullPointerException || e instanceof OutOfMemoryError) { + return GeckoResult.fromException( + new OutOfMemoryError("Not enough memory to allocate for bitmap")); + } + return GeckoResult.fromException(new Throwable("Failed to create bitmap", e)); + } + } else { + target = mRecycle; + } + + mSession.mCompositor.requestScreenPixels( + result, target, mOffsetX, mOffsetY, mSrcWidth, mSrcHeight, mOutWidth, mOutHeight); + + return result; + } + } + + /** + * Creates a new screenshot builder. + * + * @return The new {@link ScreenshotBuilder} + */ + @UiThread + public @NonNull ScreenshotBuilder screenshot() { + return new ScreenshotBuilder(mSession); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java new file mode 100644 index 0000000000..2d24dcbe93 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java @@ -0,0 +1,2616 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.graphics.RectF; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.Selection; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.method.KeyListener; +import android.text.method.TextKeyListener; +import android.text.style.CharacterStyle; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.GeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState; + +/** + * GeckoEditable implements only some functions of Editable The field mText contains the actual + * underlying SpannableStringBuilder/Editable that contains our text. + */ +/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub + implements InvocationHandler, Editable, SessionTextInput.EditableClient { + + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoEditable"; + + // Filters to implement Editable's filtering functionality + private InputFilter[] mFilters; + + /** + * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing + * objects around via JNI seems to confuse the GC into thinking we have a native GC root. + */ + /* package */ final WeakReference mSession; + + private final AsyncText mText; + private final Editable mProxy; + private final ConcurrentLinkedQueue mActions; + private KeyCharacterMap mKeyMap; + + // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables + // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to + // The two can be different when switching from one handler to another + private Handler mIcRunHandler; + private Handler mIcPostHandler; + + // Parent process child used as a default for key events. + /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread. + // Parent or content process child that has the focus. + /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread. + /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread. + /* package */ SessionTextInput.EditableListener mListener; + + /* package */ boolean mInBatchMode; // Used by IC thread + /* package */ boolean mNeedSync; // Used by IC thread + // Gecko side needs an updated composition from Java; + private boolean mNeedUpdateComposition; // Used by IC thread + private boolean mSuppressKeyUp; // Used by IC thread + + @IMEState + private int mIMEState = // Used by IC thread. + SessionTextInput.EditableListener.IME_STATE_DISABLED; + + private String mIMETypeHint = ""; // Used by IC/UI thread. + private String mIMEModeHint = ""; // Used by IC thread. + private String mIMEActionHint = ""; // Used by IC thread. + private String mIMEAutocapitalize = ""; // Used by IC thread. + @IMEContextFlags private int mIMEFlags; // Used by IC thread. + + private boolean mIgnoreSelectionChange; // Used by Gecko thread + // Combined offsets from the previous batch of onTextChange calls; valid + // between the onTextChange calls and the next onSelectionChange call. + private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread + private int mLastTextChangeOldEnd = -1; // Used by Gecko thread + private int mLastTextChangeNewEnd = -1; // Used by Gecko thread + private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread + + // Prevent showSoftInput and hideSoftInput from being called multiple times in a row, + // including reentrant calls on some devices. Used by UI/IC thread. + /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger(); + + private static final int IME_RANGE_CARETPOSITION = 1; + private static final int IME_RANGE_RAWINPUT = 2; + private static final int IME_RANGE_SELECTEDRAWTEXT = 3; + private static final int IME_RANGE_CONVERTEDTEXT = 4; + private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5; + + private static final int IME_RANGE_LINE_NONE = 0; + private static final int IME_RANGE_LINE_SOLID = 1; + private static final int IME_RANGE_LINE_DOTTED = 2; + private static final int IME_RANGE_LINE_DASHED = 3; + private static final int IME_RANGE_LINE_DOUBLE = 4; + private static final int IME_RANGE_LINE_WAVY = 5; + + private static final int IME_RANGE_UNDERLINE = 1; + private static final int IME_RANGE_FORECOLOR = 2; + private static final int IME_RANGE_BACKCOLOR = 4; + private static final int IME_RANGE_LINECOLOR = 8; + + private void onKeyEvent( + final IGeckoEditableChild child, + final KeyEvent event, + final int action, + final int savedMetaState, + final boolean isSynthesizedImeKey) + throws RemoteException { + // Use a separate action argument so we can override the key's original action, + // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate + // a new key event just to change its action field. + // + // Normally we expect event.getMetaState() to reflect the current meta-state; however, + // some software-generated key events may not have event.getMetaState() set, e.g. key + // events from Swype. Therefore, it's necessary to combine the key's meta-states + // with the meta-states that we keep separately in KeyListener + final int metaState = event.getMetaState() | savedMetaState; + final int unmodifiedMetaState = + metaState & ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK); + + final int unicodeChar = event.getUnicodeChar(metaState); + final int unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState); + final int domPrintableKeyValue = + unicodeChar >= ' ' + ? unicodeChar + : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0; + + // If a modifier (e.g. meta key) caused a different character to be entered, we + // drop that modifier from the metastate for the generated keypress event. + final int keyPressMetaState = + (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar) + ? unmodifiedMetaState + : metaState; + + // For synthesized keys, ignore modifier metastates from the synthesized event, + // because the synthesized modifier metastates don't reflect the actual state of + // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is + // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key + // is not actually pressed in this case. + final int keyUpDownMetaState = + isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState; + + child.onKeyEvent( + action, + event.getKeyCode(), + event.getScanCode(), + keyUpDownMetaState, + keyPressMetaState, + event.getEventTime(), + domPrintableKeyValue, + event.getRepeatCount(), + event.getFlags(), + isSynthesizedImeKey, + event); + } + + /** + * Class that encapsulates asynchronous text editing. There are two copies of the text, a current + * copy and a shadow copy. Both can be modified independently through the current*** and shadow*** + * methods, respectively. The current copy can only be modified on the Gecko side and reflects the + * authoritative version of the text. The shadow copy can only be modified on the IC side and + * reflects what we think the current text is. Periodically, the shadow copy can be synced to the + * current copy through syncShadowText, so the shadow copy once again refers to the same text as + * the current copy. + */ + private final class AsyncText { + // The current text is the update-to-date version of the text, and is only updated + // on the Gecko side. + private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder(); + // Track changes on the current side for syncing purposes. + // Start of the changed range in current text since last sync. + private int mCurrentStart = Integer.MAX_VALUE; + // End of the changed range (before the change) in current text since last sync. + private int mCurrentOldEnd; + // End of the changed range (after the change) in current text since last sync. + private int mCurrentNewEnd; + // Track selection changes separately. + private boolean mCurrentSelectionChanged; + + // The shadow text is what we think the current text is on the Java side, and is + // periodically synced with the current text. + private final SpannableStringBuilder mShadowText = new SpannableStringBuilder(); + // Track changes on the shadow side for syncing purposes. + // Start of the changed range in shadow text since last sync. + private int mShadowStart = Integer.MAX_VALUE; + // End of the changed range (before the change) in shadow text since last sync. + private int mShadowOldEnd; + // End of the changed range (after the change) in shadow text since last sync. + private int mShadowNewEnd; + + private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) { + // Merge the new change into any existing change. + mCurrentStart = Math.min(mCurrentStart, start); + mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd); + mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd); + } + + public synchronized void currentReplace( + final int start, final int end, final CharSequence newText) { + // On Gecko or binder thread. + mCurrentText.replace(start, end, newText); + addCurrentChangeLocked(start, end, start + newText.length()); + } + + public synchronized void currentSetSelection(final int start, final int end) { + // On Gecko or binder thread. + Selection.setSelection(mCurrentText, start, end); + mCurrentSelectionChanged = true; + } + + public synchronized void currentSetSpan( + final Object obj, final int start, final int end, final int flags) { + // On Gecko or binder thread. + mCurrentText.setSpan(obj, start, end, flags); + addCurrentChangeLocked(start, end, end); + } + + public synchronized void currentRemoveSpan(final Object obj) { + // On Gecko or binder thread. + if (obj == null) { + mCurrentText.clearSpans(); + addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length()); + return; + } + final int start = mCurrentText.getSpanStart(obj); + final int end = mCurrentText.getSpanEnd(obj); + if (start < 0 || end < 0) { + return; + } + mCurrentText.removeSpan(obj); + addCurrentChangeLocked(start, end, end); + } + + // Return Spanned instead of Editable because the returned object is supposed to + // be read-only. Editing should be done through one of the current*** methods. + public Spanned getCurrentText() { + // On Gecko or binder thread. + return mCurrentText; + } + + private void addShadowChange(final int start, final int oldEnd, final int newEnd) { + // Merge the new change into any existing change. + mShadowStart = Math.min(mShadowStart, start); + mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd); + mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd); + } + + public void shadowReplace(final int start, final int end, final CharSequence newText) { + if (DEBUG) { + assertOnIcThread(); + } + mShadowText.replace(start, end, newText); + addShadowChange(start, end, start + newText.length()); + } + + public void shadowSetSpan(final Object obj, final int start, final int end, final int flags) { + if (DEBUG) { + assertOnIcThread(); + } + mShadowText.setSpan(obj, start, end, flags); + addShadowChange(start, end, end); + } + + public void shadowRemoveSpan(final Object obj) { + if (DEBUG) { + assertOnIcThread(); + } + if (obj == null) { + mShadowText.clearSpans(); + addShadowChange(0, mShadowText.length(), mShadowText.length()); + return; + } + final int start = mShadowText.getSpanStart(obj); + final int end = mShadowText.getSpanEnd(obj); + if (start < 0 || end < 0) { + return; + } + mShadowText.removeSpan(obj); + addShadowChange(start, end, end); + } + + // Return Spanned instead of Editable because the returned object is supposed to + // be read-only. Editing should be done through one of the shadow*** methods. + public Spanned getShadowText() { + if (DEBUG) { + assertOnIcThread(); + } + return mShadowText; + } + + /** + * Check whether we are currently discarding the composition. It means that shadow text has + * composition, but current text has no composition. So syncShadowText will discard composition. + * + * @return true if discarding composition + */ + private boolean isDiscardingComposition() { + if (!isComposing(mShadowText)) { + return false; + } + + return !isComposing(mCurrentText); + } + + public synchronized void syncShadowText(final SessionTextInput.EditableListener listener) { + if (DEBUG) { + assertOnIcThread(); + } + + if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) { + // Still check selection changes. + if (!mCurrentSelectionChanged) { + return; + } + final int start = Selection.getSelectionStart(mCurrentText); + final int end = Selection.getSelectionEnd(mCurrentText); + Selection.setSelection(mShadowText, start, end); + mCurrentSelectionChanged = false; + + if (listener != null) { + listener.onSelectionChange(); + } + return; + } + + if (isDiscardingComposition()) { + if (listener != null) { + listener.onDiscardComposition(); + } + } + + // Copy the portion of the current text that has changed over to the shadow + // text, with consideration for any concurrent changes in the shadow text. + final int start = Math.min(mShadowStart, mCurrentStart); + final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd); + final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd); + + // Remove existing spans that may no longer be in the new text. + Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class); + for (final Object span : spans) { + mShadowText.removeSpan(span); + } + + mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd); + + // The replace() call may not have copied all affected spans, so we re-copy all the + // spans manually just in case. Expand bounds by 1 so we get all the spans. + spans = + mCurrentText.getSpans( + Math.max(start - 1, 0), + Math.min(currentEnd + 1, mCurrentText.length()), + Object.class); + for (final Object span : spans) { + if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) { + continue; + } + mShadowText.setSpan( + span, + mCurrentText.getSpanStart(span), + mCurrentText.getSpanEnd(span), + mCurrentText.getSpanFlags(span)); + } + + // SpannableStringBuilder has some internal logic to fix up selections, but we + // don't want that, so we always fix up the selection a second time. + final int selStart = Selection.getSelectionStart(mCurrentText); + final int selEnd = Selection.getSelectionEnd(mCurrentText); + Selection.setSelection(mShadowText, selStart, selEnd); + + if (DEBUG && !checkEqualText(mShadowText, mCurrentText)) { + // Sanity check. + throw new IllegalStateException( + "Failed to sync: " + + mShadowStart + + '-' + + mShadowOldEnd + + '-' + + mShadowNewEnd + + '/' + + mCurrentStart + + '-' + + mCurrentOldEnd + + '-' + + mCurrentNewEnd); + } + + if (listener != null) { + // Call onTextChange after selection fix-up but before we call + // onSelectionChange. + listener.onTextChange(); + + if (mCurrentSelectionChanged + || (mCurrentOldEnd != mCurrentNewEnd + && (selStart >= mCurrentStart || selEnd >= mCurrentStart))) { + listener.onSelectionChange(); + } + } + + // These values ensure the first change is properly added. + mCurrentStart = mShadowStart = Integer.MAX_VALUE; + mCurrentOldEnd = mShadowOldEnd = 0; + mCurrentNewEnd = mShadowNewEnd = 0; + mCurrentSelectionChanged = false; + } + } + + private static boolean checkEqualText(final Spanned s1, final Spanned s2) { + if (!s1.toString().equals(s2.toString())) { + return false; + } + + final Object[] o1s = s1.getSpans(0, s1.length(), Object.class); + final Object[] o2s = s2.getSpans(0, s2.length(), Object.class); + + if (o1s.length != o2s.length) { + return false; + } + + o1loop: + for (final Object o1 : o1s) { + for (final Object o2 : o2s) { + if (o1 != o2) { + continue; + } + if (s1.getSpanStart(o1) != s2.getSpanStart(o2) + || s1.getSpanEnd(o1) != s2.getSpanEnd(o2) + || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) { + return false; + } + continue o1loop; + } + // o1 not found in o2s. + return false; + } + return true; + } + + /* An action that alters the Editable + + Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko + thread, the action stays on top of mActions queue. After the Gecko event is processed and + replied, the action is removed from the queue + */ + private static final class Action { + // For input events (keypress, etc.); use with onImeSynchronize + static final int TYPE_EVENT = 0; + // For Editable.replace() call; use with onImeReplaceText + static final int TYPE_REPLACE_TEXT = 1; + // For Editable.setSpan() call; use with onImeSynchronize + static final int TYPE_SET_SPAN = 2; + // For Editable.removeSpan() call; use with onImeSynchronize + static final int TYPE_REMOVE_SPAN = 3; + // For switching handler; use with onImeSynchronize + static final int TYPE_SET_HANDLER = 4; + + final int mType; + int mStart; + int mEnd; + CharSequence mSequence; + Object mSpanObject; + int mSpanFlags; + Handler mHandler; + + Action(final int type) { + mType = type; + } + + static Action newReplaceText(final CharSequence text, final int start, final int end) { + if (start < 0 || start > end) { + Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end); + throw new IllegalArgumentException("invalid replace text offsets"); + } + + final Action action = new Action(TYPE_REPLACE_TEXT); + action.mSequence = text; + action.mStart = start; + action.mEnd = end; + return action; + } + + static Action newSetSpan(final Object object, final int start, final int end, final int flags) { + if (start < 0 || start > end) { + Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end); + throw new IllegalArgumentException("invalid span offsets"); + } + final Action action = new Action(TYPE_SET_SPAN); + action.mSpanObject = object; + action.mStart = start; + action.mEnd = end; + action.mSpanFlags = flags; + return action; + } + + static Action newRemoveSpan(final Object object) { + final Action action = new Action(TYPE_REMOVE_SPAN); + action.mSpanObject = object; + return action; + } + + static Action newSetHandler(final Handler handler) { + final Action action = new Action(TYPE_SET_HANDLER); + action.mHandler = handler; + return action; + } + } + + private void icOfferAction(final Action action) { + if (DEBUG) { + assertOnIcThread(); + Log.d(LOGTAG, "offer: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")"); + } + + switch (action.mType) { + case Action.TYPE_EVENT: + case Action.TYPE_SET_HANDLER: + break; + + case Action.TYPE_SET_SPAN: + mText.shadowSetSpan( + action.mSpanObject, action.mStart, + action.mEnd, action.mSpanFlags); + break; + + case Action.TYPE_REMOVE_SPAN: + action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject); + mText.shadowRemoveSpan(action.mSpanObject); + break; + + case Action.TYPE_REPLACE_TEXT: + mText.shadowReplace(action.mStart, action.mEnd, action.mSequence); + break; + + default: + throw new IllegalStateException("Action not processed"); + } + + // Always perform actions on the shadow text side above, so we still act as a + // valid Editable object, but don't send the actions to Gecko below if we haven't + // been focused or initialized, or we've been destroyed. + if (mFocusedChild == null || mListener == null) { + return; + } + + mActions.offer(action); + + try { + icPerformAction(action); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + // Undo the offer. + mActions.remove(action); + } + } + + private void icPerformAction(final Action action) throws RemoteException { + switch (action.mType) { + case Action.TYPE_EVENT: + case Action.TYPE_SET_HANDLER: + mFocusedChild.onImeSynchronize(); + break; + + case Action.TYPE_SET_SPAN: + { + final boolean needUpdate = + (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0 + && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0 + || action.mSpanObject == Selection.SELECTION_START + || action.mSpanObject == Selection.SELECTION_END); + + action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd); + + mNeedUpdateComposition |= needUpdate; + if (needUpdate) { + icMaybeSendComposition( + mText.getShadowText(), + SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT); + } + + mFocusedChild.onImeSynchronize(); + break; + } + case Action.TYPE_REMOVE_SPAN: + { + final boolean needUpdate = + (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0 + && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0; + + mNeedUpdateComposition |= needUpdate; + if (needUpdate) { + icMaybeSendComposition( + mText.getShadowText(), + SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT); + } + + mFocusedChild.onImeSynchronize(); + break; + } + case Action.TYPE_REPLACE_TEXT: + // Always sync text after a replace action, so that if the Gecko + // text is not changed, we will revert the shadow text to before. + mNeedSync = true; + + // Because we get composition styling here essentially for free, + // we don't need to check if we're in batch mode. + if (icMaybeSendComposition(action.mSequence, SEND_COMPOSITION_USE_ENTIRE_TEXT)) { + mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString()); + break; + } + + // Since we don't have a composition, we can try sending key events. + sendCharKeyEvents(action); + + // onImeReplaceText will set the selection range. But we don't + // know whether event state manager is processing text and + // selection. So current shadow may not be synchronized with + // Gecko's text and selection. So we have to avoid unnecessary + // selection update. + final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText()); + final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText()); + int actionStart = action.mStart; + int actionEnd = action.mEnd; + // If action range is collapsed and selection of shadow text is + // collapsed, we may try to dispatch keypress on current caret + // position. Action range is previous range before dispatching + // keypress, and shadow range is new range after dispatching + // it. + if (action.mStart == action.mEnd + && selStartOnShadow == selEndOnShadow + && action.mStart == selStartOnShadow + action.mSequence.toString().length()) { + // Replacing range is same value as current shadow's selection. + // So it is unnecessary to update the selection on Gecko. + actionStart = -1; + actionEnd = -1; + } + mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString()); + break; + + default: + throw new IllegalStateException("Action not processed"); + } + } + + private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) { + try { + if (mKeyMap == null) { + mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + } catch (final Exception e) { + // KeyCharacterMap.UnavailableException is not found on Gingerbread; + // besides, it seems like HC and ICS will throw something other than + // KeyCharacterMap.UnavailableException; so use a generic Exception here + return null; + } + final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray()); + if (keyEvents == null || keyEvents.length == 0) { + return null; + } + return keyEvents; + } + + private void sendCharKeyEvents(final Action action) throws RemoteException { + if (action.mSequence.length() != 1 + || (action.mSequence instanceof Spannable + && ((Spannable) action.mSequence).nextSpanTransition(-1, Integer.MAX_VALUE, null) + < Integer.MAX_VALUE)) { + // Spans are not preserved when we use key events, + // so we need the sequence to not have any spans + return; + } + final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence); + if (keyEvents == null) { + return; + } + for (final KeyEvent event : keyEvents) { + if (KeyEvent.isModifierKey(event.getKeyCode())) { + continue; + } + if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) { + continue; + } + if (DEBUG) { + Log.d(LOGTAG, "sending: " + event); + } + onKeyEvent( + mFocusedChild, + event, + event.getAction(), + /* metaState */ 0, /* isSynthesizedImeKey */ + true); + } + } + + public GeckoEditable(@NonNull final GeckoSession session) { + if (DEBUG) { + // Called by SessionTextInput. + ThreadUtils.assertOnUiThread(); + } + + mSession = new WeakReference<>(session); + mText = new AsyncText(); + mActions = new ConcurrentLinkedQueue(); + + final Class[] PROXY_INTERFACES = {Editable.class}; + mProxy = + (Editable) Proxy.newProxyInstance(Editable.class.getClassLoader(), PROXY_INTERFACES, this); + + mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler(); + } + + @Override // IGeckoEditableParent + public void setDefaultChild(final IGeckoEditableChild child) { + if (DEBUG) { + // On Gecko or binder thread. + Log.d(LOGTAG, "setDefaultEditableChild " + child); + } + mDefaultChild = child; + } + + public void setListener(final SessionTextInput.EditableListener newListener) { + if (DEBUG) { + // Called by SessionTextInput. + ThreadUtils.assertOnUiThread(); + Log.d(LOGTAG, "setListener " + newListener); + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "onViewChange (set listener)"); + } + + mListener = newListener; + } + }); + } + + private boolean onIcThread() { + return mIcRunHandler.getLooper() == Looper.myLooper(); + } + + private void assertOnIcThread() { + ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW); + } + + private Object getField(final Object obj, final String field, final Object def) { + try { + return obj.getClass().getField(field).get(obj); + } catch (final Exception e) { + return def; + } + } + + // Flags for icMaybeSendComposition + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SEND_COMPOSITION_USE_ENTIRE_TEXT, + SEND_COMPOSITION_NOTIFY_GECKO, + SEND_COMPOSITION_KEEP_CURRENT + }) + public @interface CompositionFlags {} + + // If text has composing spans, treat the entire text as a Gecko composition, + // instead of just the spanned part. + private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0; + // Notify Gecko of the new composition ranges; + // otherwise, the caller is responsible for notifying Gecko. + private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1; + // Keep the current composition when updating; + // composition is not updated if there is no current composition. + private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2; + + /** + * Send composition ranges to Gecko if the text has composing spans. + * + * @param sequence Text with possible composing spans + * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition. + * @return Whether there was a composition + */ + private boolean icMaybeSendComposition( + final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException { + final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0; + final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0; + final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0; + final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0; + + if (!keepCurrent) { + // If keepCurrent is true, the composition may not actually be updated; + // so we may still need to update the composition in the future. + mNeedUpdateComposition = false; + } + + int selStart = Selection.getSelectionStart(sequence); + int selEnd = Selection.getSelectionEnd(sequence); + + if (sequence instanceof Spanned) { + final Spanned text = (Spanned) sequence; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + boolean found = false; + int composingStart = useEntireText ? 0 : Integer.MAX_VALUE; + int composingEnd = useEntireText ? text.length() : 0; + + // Find existence and range of any composing spans (spans with the + // SPAN_COMPOSING flag set). + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) { + continue; + } + found = true; + if (useEntireText) { + break; + } + composingStart = Math.min(composingStart, text.getSpanStart(span)); + composingEnd = Math.max(composingEnd, text.getSpanEnd(span)); + } + + if (useEntireText && (selStart < 0 || selEnd < 0)) { + selStart = composingEnd; + selEnd = composingEnd; + } + + if (found) { + if (selStart < composingStart || selEnd > composingEnd) { + // GBoard will set caret position that is out of composing + // range. Unfortunately, Gecko doesn't support this caret + // position. So we shouldn't set composing range data now. + // But this is temporary composing range, then GBoard will + // set valid range soon. + if (DEBUG) { + final StringBuilder sb = + new StringBuilder("icSendComposition(): invalid caret position. "); + sb.append("composing = ") + .append(composingStart) + .append("-") + .append(composingEnd) + .append(", selection = ") + .append(selStart) + .append("-") + .append(selEnd); + Log.d(LOGTAG, sb.toString()); + } + } else { + icSendComposition(text, selStart, selEnd, composingStart, composingEnd); + if (notifyGecko) { + mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags); + } + return true; + } + } + } + + if (notifyGecko) { + // Set the selection by using a composition without ranges. + final Spanned currentText = mText.getCurrentText(); + if (Selection.getSelectionStart(currentText) != selStart + || Selection.getSelectionEnd(currentText) != selEnd) { + // Gecko's selection is different of requested selection, so + // we have to set selection of Gecko side. + // If selection is same, it is unnecessary to update it. + // This may be race with Gecko's updating selection via + // JavaScript or keyboard event. But we don't know whether + // Gecko is during updating selection. + mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags); + } + } + + if (DEBUG) { + Log.d(LOGTAG, "icSendComposition(): no composition"); + } + return false; + } + + private void icSendComposition( + final Spanned text, + final int selStart, + final int selEnd, + final int composingStart, + final int composingEnd) + throws RemoteException { + if (DEBUG) { + assertOnIcThread(); + final StringBuilder sb = new StringBuilder("icSendComposition("); + sb.append("\"") + .append(text) + .append("\"") + .append(", range = ") + .append(composingStart) + .append("-") + .append(composingEnd) + .append(", selection = ") + .append(selStart) + .append("-") + .append(selEnd) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (selEnd >= composingStart && selEnd <= composingEnd) { + mFocusedChild.onImeAddCompositionRange( + selEnd - composingStart, + selEnd - composingStart, + IME_RANGE_CARETPOSITION, + 0, + 0, + false, + 0, + 0, + 0); + } + + int rangeStart = composingStart; + final TextPaint tp = new TextPaint(); + final TextPaint emptyTp = new TextPaint(); + // set initial foreground color to 0, because we check for tp.getColor() == 0 + // below to decide whether to pass a foreground color to Gecko + emptyTp.setColor(0); + do { + final int rangeType; + int rangeStyles = 0; + int rangeLineStyle = IME_RANGE_LINE_NONE; + boolean rangeBoldLine = false; + int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0; + int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class); + + if (selStart > rangeStart && selStart < rangeEnd) { + rangeEnd = selStart; + } else if (selEnd > rangeStart && selEnd < rangeEnd) { + rangeEnd = selEnd; + } + final CharacterStyle[] styleSpans = text.getSpans(rangeStart, rangeEnd, CharacterStyle.class); + + if (DEBUG) { + Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + rangeStart + "-" + rangeEnd); + } + + if (styleSpans.length == 0) { + rangeType = + (selStart == rangeStart && selEnd == rangeEnd) + ? IME_RANGE_SELECTEDRAWTEXT + : IME_RANGE_RAWINPUT; + } else { + rangeType = + (selStart == rangeStart && selEnd == rangeEnd) + ? IME_RANGE_SELECTEDCONVERTEDTEXT + : IME_RANGE_CONVERTEDTEXT; + tp.set(emptyTp); + for (final CharacterStyle span : styleSpans) { + span.updateDrawState(tp); + } + int tpUnderlineColor = 0; + float tpUnderlineThickness = 0.0f; + + // These TextPaint fields only exist on Android ICS+ and are not in the SDK. + tpUnderlineColor = (Integer) getField(tp, "underlineColor", 0); + tpUnderlineThickness = (Float) getField(tp, "underlineThickness", 0.0f); + if (tpUnderlineColor != 0) { + rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR; + rangeLineColor = tpUnderlineColor; + // Approximately translate underline thickness to what Gecko understands + if (tpUnderlineThickness <= 0.5f) { + rangeLineStyle = IME_RANGE_LINE_DOTTED; + } else { + rangeLineStyle = IME_RANGE_LINE_SOLID; + if (tpUnderlineThickness >= 2.0f) { + rangeBoldLine = true; + } + } + } else if (tp.isUnderlineText()) { + rangeStyles |= IME_RANGE_UNDERLINE; + rangeLineStyle = IME_RANGE_LINE_SOLID; + } + if (tp.getColor() != 0) { + rangeStyles |= IME_RANGE_FORECOLOR; + rangeForeColor = tp.getColor(); + } + if (tp.bgColor != 0) { + rangeStyles |= IME_RANGE_BACKCOLOR; + rangeBackColor = tp.bgColor; + } + } + mFocusedChild.onImeAddCompositionRange( + rangeStart - composingStart, + rangeEnd - composingStart, + rangeType, + rangeStyles, + rangeLineStyle, + rangeBoldLine, + rangeForeColor, + rangeBackColor, + rangeLineColor); + rangeStart = rangeEnd; + + if (DEBUG) { + Log.d( + LOGTAG, + " added " + + rangeType + + " : " + + Integer.toHexString(rangeStyles) + + " : " + + Integer.toHexString(rangeForeColor) + + " : " + + Integer.toHexString(rangeBackColor)); + } + } while (rangeStart < composingEnd); + } + + @Override // SessionTextInput.EditableClient + public void sendKeyEvent( + final @Nullable View view, final int action, final @NonNull KeyEvent event) { + final Editable editable = mProxy; + final KeyListener keyListener = TextKeyListener.getInstance(); + final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event); + + // We only let TextKeyListener do UI things on the UI thread. + final View v = ThreadUtils.isOnUiThread() ? view : null; + final int keyCode = translatedEvent.getKeyCode(); + final boolean handled; + + if (shouldSkipKeyListener(keyCode, translatedEvent)) { + handled = false; + } else if (action == KeyEvent.ACTION_DOWN) { + setSuppressKeyUp(true); + handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent); + } else if (action == KeyEvent.ACTION_UP) { + handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent); + } else { + handled = keyListener.onKeyOther(v, editable, translatedEvent); + } + + if (!handled) { + sendKeyEvent(translatedEvent, action, TextKeyListener.getMetaState(editable)); + } + + if (action == KeyEvent.ACTION_DOWN) { + if (!handled) { + // Usually, the down key listener call above adjusts meta states for us. + // However, if the call didn't handle the event, we have to manually + // adjust meta states so the meta states remain consistent. + TextKeyListener.adjustMetaAfterKeypress(editable); + } + setSuppressKeyUp(false); + } + } + + private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final int metaState) { + if (DEBUG) { + assertOnIcThread(); + Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")"); + } + /* + We are actually sending two events to Gecko here, + 1. Event from the event parameter (key event) + 2. Sync event from the icOfferAction call + The first event is a normal event that does not reply back to us, + the second sync event will have a reply, during which we see that there is a pending + event-type action, and update the shadow text accordingly. + */ + try { + if (mFocusedChild == null) { + if (mDefaultChild == null) { + Log.w(LOGTAG, "Discarding key event"); + return; + } + // Not focused; send simple key event to chrome window. + onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false); + return; + } + + // Most IMEs handle arrow key, then set caret position. But GBoard + // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right + // even if having IME composition. + // Since Gecko doesn't dispatch keypress during IME composition due to + // DOM UI events spec, we have to emulate arrow key's behaviour. + boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN; + if (isComposing(mText.getShadowText()) + && action == KeyEvent.ACTION_DOWN + && event.hasNoModifiers()) { + final int selStart = Selection.getSelectionStart(mText.getShadowText()); + final int selEnd = Selection.getSelectionEnd(mText.getShadowText()); + if (selStart == selEnd) { + // If dispatching arrow left/right key into composition, + // we update IME caret. + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_LEFT: + if (getComposingStart(mText.getShadowText()) < selStart) { + Selection.setSelection(getEditable(), selStart - 1, selStart - 1); + mNeedUpdateComposition = true; + commitCompositionBeforeKeyEvent = false; + } else if (selStart == 0) { + // Keep current composition + commitCompositionBeforeKeyEvent = false; + } + break; + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (getComposingEnd(mText.getShadowText()) > selEnd) { + Selection.setSelection(getEditable(), selStart + 1, selStart + 1); + mNeedUpdateComposition = true; + commitCompositionBeforeKeyEvent = false; + } else if (selEnd == mText.getShadowText().length()) { + // Keep current composition + commitCompositionBeforeKeyEvent = false; + } + break; + } + } + } + + // Focused; key event may go to chrome window or to content window. + if (mNeedUpdateComposition) { + icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO); + } + + if (commitCompositionBeforeKeyEvent) { + mFocusedChild.onImeRequestCommit(); + } + onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false); + icOfferAction(new Action(Action.TYPE_EVENT)); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) { + if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + return true; + } + + // Preserve enter and tab keys for the browser + if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_TAB) { + return true; + } + // BaseKeyListener returns false even if it handled these keys for us, + // so we skip the key listener entirely and handle these ourselves + if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) { + return true; + } + return false; + } + + private static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final KeyEvent event) { + // The cross and circle button mappings may be swapped in the different regions so + // determine if they are swapped so the proper key codes can be mapped to the keys + final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped(); + + int translatedKeyCode = keyCode; + // If a Sony Xperia, remap the cross and circle buttons to buttons + // A and B for the gamepad API + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: + translatedKeyCode = + (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B); + break; + + case KeyEvent.KEYCODE_DPAD_CENTER: + translatedKeyCode = + (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A); + break; + + default: + return event; + } + + return new KeyEvent(event.getAction(), translatedKeyCode); + } + + private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611; + + private static boolean isSonyXperiaGamepadKeyEvent(final KeyEvent event) { + return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID + && "Sony Ericsson".equals(Build.MANUFACTURER) + && ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL))); + } + + private static boolean areSonyXperiaGamepadKeysSwapped() { + // The cross and circle buttons on Sony Xperia phones are swapped + // in different regions + // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/ + final char DEFAULT_O_BUTTON_LABEL = 0x25CB; + + boolean swapped = false; + final int[] deviceIds = InputDevice.getDeviceIds(); + + for (int i = 0; deviceIds != null && i < deviceIds.length; i++) { + final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]); + if (keyCharacterMap != null + && DEFAULT_O_BUTTON_LABEL + == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) { + swapped = true; + break; + } + } + return swapped; + } + + private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) { + if (isSonyXperiaGamepadKeyEvent(event)) { + return translateSonyXperiaGamepadKeys(keyCode, event); + } + return event; + } + + @Override // SessionTextInput.EditableClient + public Editable getEditable() { + if (!onIcThread()) { + // Android may be holding an old InputConnection; ignore + if (DEBUG) { + Log.i(LOGTAG, "getEditable() called on non-IC thread"); + } + return null; + } + if (mListener == null) { + // We haven't initialized or we've been destroyed. + return null; + } + return mProxy; + } + + @Override // SessionTextInput.EditableClient + public void setBatchMode(final boolean inBatchMode) { + if (!onIcThread()) { + // Android may be holding an old InputConnection; ignore + if (DEBUG) { + Log.i(LOGTAG, "setBatchMode() called on non-IC thread"); + } + return; + } + + mInBatchMode = inBatchMode; + + if (!inBatchMode && mFocusedChild != null) { + // We may not commit composition on Gecko even if Java side has + // no composition. So we have to sync composition state with Gecko + // when batch edit is done. + // + // i.e. Although finishComposingText removes composing span, we + // don't commit current composition yet. + final Editable editable = getEditable(); + if (editable != null && !isComposing(editable)) { + try { + mFocusedChild.onImeRequestCommit(); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + // Committing composition doesn't change text, so we can sync shadow text. + } + + if (!inBatchMode && mNeedSync) { + icSyncShadowText(); + } + } + + /* package */ void icSyncShadowText() { + if (mListener == null) { + // Not yet attached or already destroyed. + return; + } + + if (mInBatchMode || !mActions.isEmpty()) { + mNeedSync = true; + return; + } + + mNeedSync = false; + mText.syncShadowText(mListener); + } + + private void setSuppressKeyUp(final boolean suppress) { + if (DEBUG) { + assertOnIcThread(); + } + // Suppress key up event generated as a result of + // translating characters to key events + mSuppressKeyUp = suppress; + } + + @Override // SessionTextInput.EditableClient + public Handler setInputConnectionHandler(final Handler handler) { + if (handler == mIcRunHandler) { + return mIcRunHandler; + } + if (DEBUG) { + assertOnIcThread(); + } + + // There are three threads at this point: Gecko thread, old IC thread, and new IC + // thread, and we want to safely switch from old IC thread to new IC thread. + // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that + // the Gecko thread is stopped at a known point. At the same time, the old IC + // thread blocks on the action; this ensures that the old IC thread is stopped at + // a known point. Finally, inside the Gecko thread, we post a Runnable to the old + // IC thread; this Runnable switches from old IC thread to new IC thread. We + // switch IC thread on the old IC thread to ensure any pending Runnables on the + // old IC thread are processed before we switch over. Inside the Gecko thread, we + // also post a Runnable to the new IC thread; this Runnable blocks until the + // switch is complete; this ensures that the new IC thread won't accept + // InputConnection calls until after the switch. + + handler.post( + new Runnable() { // Make the new IC thread wait. + @Override + public void run() { + synchronized (handler) { + while (mIcRunHandler != handler) { + try { + handler.wait(); + } catch (final InterruptedException e) { + } + } + } + } + }); + + icOfferAction(Action.newSetHandler(handler)); + return handler; + } + + @Override // SessionTextInput.EditableClient + public void postToInputConnection(final Runnable runnable) { + mIcPostHandler.post(runnable); + } + + @Override // SessionTextInput.EditableClient + public void requestCursorUpdates(@CursorMonitorMode final int requestMode) { + try { + if (mFocusedChild != null) { + mFocusedChild.onImeRequestCursorUpdates(requestMode); + } + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @Override // SessionTextInput.EditableClient + public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) { + if (mFocusedChild == null) { + return; + } + + try { + mFocusedChild.onImeInsertImage(data, mimeType); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call to insert image failed", e); + } + } + + private void geckoSetIcHandler(final Handler newHandler) { + // On Gecko or binder thread. + mIcPostHandler.post( + new Runnable() { // posting to old IC thread + @Override + public void run() { + synchronized (newHandler) { + mIcRunHandler = newHandler; + newHandler.notify(); + } + } + }); + + // At this point, all future Runnables should be posted to the new IC thread, but + // we don't switch mIcRunHandler yet because there may be pending Runnables on the + // old IC thread still waiting to run. + mIcPostHandler = newHandler; + } + + private void geckoActionReply(final Action action) { + // On Gecko or binder thread. + if (action == null) { + Log.w(LOGTAG, "Mismatched reply"); + return; + } + if (DEBUG) { + Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")"); + } + switch (action.mType) { + case Action.TYPE_REPLACE_TEXT: + { + final Spanned currentText = mText.getCurrentText(); + final int actionNewEnd = action.mStart + action.mSequence.length(); + if (mLastTextChangeStart > mLastTextChangeNewEnd + || mLastTextChangeNewEnd > currentText.length() + || action.mStart < mLastTextChangeStart + || actionNewEnd > mLastTextChangeNewEnd) { + // Replace-text action doesn't match our text change. + break; + } + + int indexInText = + TextUtils.indexOf( + currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd); + if (indexInText < 0 && action.mStart != mLastTextChangeStart) { + final String changedText = + TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd); + indexInText = changedText.lastIndexOf(action.mSequence.toString()); + if (indexInText >= 0) { + indexInText += mLastTextChangeStart; + } + } + if (indexInText < 0) { + // Replace-text action doesn't match our current text. + break; + } + + final int selStart = Selection.getSelectionStart(currentText); + final int selEnd = Selection.getSelectionEnd(currentText); + + // Replace-text action matches our current text; copy the new spans to the + // current text. + mText.currentReplace( + indexInText, indexInText + action.mSequence.length(), action.mSequence); + // Make sure selection is preserved. + mText.currentSetSelection(selStart, selEnd); + + // The text change is caused by the replace-text event. If the text change + // replaced the previous selection, we need to rely on Gecko for an updated + // selection, so don't ignore selection change. However, if the text change + // did not replace the previous selection, we can ignore the Gecko selection + // in favor of the Java selection. + mIgnoreSelectionChange = !mLastTextChangeReplacedSelection; + break; + } + + case Action.TYPE_SET_SPAN: + final int len = mText.getCurrentText().length(); + if (action.mStart > len + || action.mEnd > len + || !TextUtils.substring(mText.getCurrentText(), action.mStart, action.mEnd) + .equals(action.mSequence)) { + if (DEBUG) { + Log.d(LOGTAG, "discarding stale set span call"); + } + break; + } + if ((action.mSpanObject == Selection.SELECTION_START + || action.mSpanObject == Selection.SELECTION_END) + && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart + || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) { + // Use the Java selection if, between text-change notification and replace-text + // processing, we specifically set the selection to outside the replaced range. + mLastTextChangeReplacedSelection = false; + } + mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags); + break; + + case Action.TYPE_REMOVE_SPAN: + mText.currentRemoveSpan(action.mSpanObject); + break; + + case Action.TYPE_SET_HANDLER: + geckoSetIcHandler(action.mHandler); + break; + } + } + + private synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) { + // Verify that we're getting an IME notification from the currently focused child. + if (mFocusedToken == token || (mFocusedToken == null && allowNull)) { + return true; + } + Log.w(LOGTAG, "Invalid token"); + return false; + } + + @Override // IGeckoEditableParent + public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) { + // On Gecko or binder thread. + if (DEBUG) { + // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply() + if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + Log.d( + LOGTAG, + "notifyIME(" + + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type) + + ")"); + } + } + + final IBinder token = child.asBinder(); + if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) { + synchronized (this) { + if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) { + // Focused child already exists and is alive. + Log.w(LOGTAG, "Already focused"); + return; + } + mFocusedToken = token; + return; + } + } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) { + // Always from parent process. + ThreadUtils.assertOnGeckoThread(); + } else if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) { + synchronized (this) { + onTextChange(token, "", 0, Integer.MAX_VALUE, false); + mActions.clear(); + mFocusedToken = null; + } + } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + geckoActionReply(mActions.poll()); + if (!mActions.isEmpty()) { + // Only post to IC thread below when the queue is empty. + return; + } + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icNotifyIME(child, type); + } + }); + } + + /* package */ void icNotifyIME( + final IGeckoEditableChild child, @IMENotificationType final int type) { + if (DEBUG) { + assertOnIcThread(); + } + + if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + if (mNeedSync) { + icSyncShadowText(); + } + return; + } + + switch (type) { + case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS: + if (mFocusedChild != null) { + // Already focused, so blur first. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false); + } + + mFocusedChild = child; + mNeedSync = false; + mText.syncShadowText(/* listener */ null); + + // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it + // comes _after_ notifyIME. In that case, the state is disabled here, and + // notifyIMEContext is responsible for calling restartInput. + if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN; + } else { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true); + } + break; + + case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR: + if (mFocusedChild != null) { + mFocusedChild = null; + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true); + } + break; + + case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB: + toggleSoftInput(/* force */ true, mIMEState); + return; // Don't notify listener. + + case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION: + { + // Gecko already committed its composition. However, Android keyboards + // have trouble dealing with us removing the composition manually on the + // Java side. Therefore, we keep the composition intact on the Java side. + // The text content should still be in-sync on both sides. + // + // Nevertheless, if we somehow lost the composition, we must force the + // keyboard to reset. + if (isComposing(mText.getShadowText())) { + // Still have composition; no need to reset. + return; // Don't notify listener. + } + // No longer have composition; perform reset. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ false); + return; // Don't notify listener. + } + + case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN: + case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT: + case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION: + default: + throw new IllegalArgumentException("Invalid notifyIME type: " + type); + } + + if (mListener != null) { + mListener.notifyIME(type); + } + } + + @Override // IGeckoEditableParent + public void notifyIMEContext( + final IBinder token, + @IMEState final int state, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + @IMEContextFlags final int flags) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("notifyIMEContext("); + sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state)) + .append(", type=\"") + .append(typeHint) + .append("\", inputmode=\"") + .append(modeHint) + .append("\", autocapitalize=\"") + .append(autocapitalize) + .append("\", flags=0x") + .append(Integer.toHexString(flags)) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + // Regular notifyIMEContext calls all come from the parent process (with the default child), + // so always allow calls from there. We can get additional notifyIMEContext calls during + // a session transfer; calls in those cases can come from child processes, and we must + // perform a token check in that situation. + if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags); + } + }); + } + + /* package */ void icNotifyIMEContext( + @IMEState final int originalState, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + @IMEContextFlags final int flags) { + if (DEBUG) { + assertOnIcThread(); + } + + // For some input type we will use a widget to display the ui, for those we must not + // display the ime. We can display a widget for date and time types and, if the sdk version + // is 11 or greater, for datetime/month/week as well. + final int state; + if ((typeHint != null + && (typeHint.equalsIgnoreCase("date") + || typeHint.equalsIgnoreCase("time") + || typeHint.equalsIgnoreCase("month") + || typeHint.equalsIgnoreCase("week") + || typeHint.equalsIgnoreCase("datetime-local"))) + || (modeHint != null && modeHint.equals("none"))) { + state = SessionTextInput.EditableListener.IME_STATE_DISABLED; + } else { + state = originalState; + } + + final int oldState = mIMEState; + mIMEState = state; + mIMETypeHint = (typeHint == null) ? "" : typeHint; + mIMEModeHint = (modeHint == null) ? "" : modeHint; + mIMEActionHint = (actionHint == null) ? "" : actionHint; + mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize; + mIMEFlags = flags; + + if (mListener != null) { + mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags); + } + + if (mFocusedChild == null) { + // We have no focus. + return; + } + + if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) { + if (DEBUG) { + final StringBuilder sb = new StringBuilder("icNotifyIMEContext: "); + sb.append("focus isn't changed. oldState=") + .append(oldState) + .append(", newState=") + .append(state); + Log.d(LOGTAG, sb.toString()); + } + if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED + || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD) + && state == SessionTextInput.EditableListener.IME_STATE_DISABLED) + || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED + && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED + || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) { + // Even if focus isn't changed, software keyboard state is changed. + // We have to show or dismiss it. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ true); + return; + } + } + + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR + // will dismiss it. + // So ignore to control software keyboard at this time. + return; + } + + // We changed state while focused. If the old state is unknown, it means this + // notifyIMEContext call came _after_ the notifyIME call, so we need to call + // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change + // counts as a content change. + if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true); + } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ false); + } + } + + private void icRestartInput( + @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) { + if (DEBUG) { + assertOnIcThread(); + } + + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')'); + } + + final GeckoSession session = mSession.get(); + if (session != null) { + session.getTextInput().getDelegate().restartInput(session, reason); + } + + if (!toggleSoftInput) { + return; + } + postToInputConnection( + new Runnable() { + @Override + public void run() { + int state = mIMEState; + if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR + && mFocusedChild == null) { + // On blur, notifyIMEContext() is called after notifyIME(). Therefore, + // mIMEState is not up-to-date here and we need to override it. + state = SessionTextInput.EditableListener.IME_STATE_DISABLED; + } + toggleSoftInput(/* force */ false, state); + } + }); + } + }); + } + + public void onCreateInputConnection(final EditorInfo outAttrs) { + final int state = mIMEState; + final String typeHint = mIMETypeHint; + final String modeHint = mIMEModeHint; + final String actionHint = mIMEActionHint; + final String autocapitalize = mIMEAutocapitalize; + final int flags = mIMEFlags; + + // Some keyboards require us to fill out outAttrs even if we return null. + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE; + outAttrs.actionLabel = null; + + if (modeHint.equals("none")) { + // inputmode=none hides VKB at force. + outAttrs.inputType = InputType.TYPE_NULL; + toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED); + return; + } + + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + outAttrs.inputType = InputType.TYPE_NULL; + toggleSoftInput(/* force */ false, state); + return; + } + + // We give priority to typeHint so that content authors can't annoy + // users by doing dumb things like opening the numeric keyboard for + // an email form field. + outAttrs.inputType = InputType.TYPE_CLASS_TEXT; + if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD + || "password".equalsIgnoreCase(typeHint)) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD; + } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI; + } else if (typeHint.equalsIgnoreCase("email")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + } else if (typeHint.equalsIgnoreCase("tel")) { + outAttrs.inputType = InputType.TYPE_CLASS_PHONE; + } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) { + outAttrs.inputType = + InputType.TYPE_CLASS_NUMBER + | InputType.TYPE_NUMBER_VARIATION_NORMAL + | InputType.TYPE_NUMBER_FLAG_DECIMAL; + } else { + // We look at modeHint + if (modeHint.equals("tel")) { + outAttrs.inputType = InputType.TYPE_CLASS_PHONE; + } else if (modeHint.equals("url")) { + outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI; + } else if (modeHint.equals("email")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + } else if (modeHint.equals("numeric")) { + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL; + } else if (modeHint.equals("decimal")) { + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL; + } else { + // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap + outAttrs.inputType |= + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE; + } + } + + if (autocapitalize.equals("characters")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS; + } else if (autocapitalize.equals("none")) { + // not set anymore. + } else if (autocapitalize.equals("sentences")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } else if (autocapitalize.equals("words")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS; + } else if (modeHint.length() == 0 + && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0 + && !typeHint.equalsIgnoreCase("text")) { + // auto-capitalized mode is the default for types other than text (bug 871884) + // except to password, url and email. + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } + + if (actionHint.equals("enter")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE; + } else if (actionHint.equals("go")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_GO; + } else if (actionHint.equals("done")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE; + } else if (actionHint.equals("next") || actionHint.equals("maybenext")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT; + } else if (actionHint.equals("previous")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS; + } else if (actionHint.equals("search") || typeHint.equals("search")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH; + } else if (actionHint.equals("send")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND; + } else if (actionHint.length() > 0) { + if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\""); + outAttrs.actionLabel = actionHint; + } + + if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) { + outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) { + // contenteditable allows image insertion. + outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"}; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final Spanned currentText = mText.getCurrentText(); + outAttrs.initialSelStart = Selection.getSelectionStart(currentText); + outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText); + outAttrs.setInitialSurroundingText(currentText); + } + + toggleSoftInput(/* force */ false, state); + } + + /* package */ void toggleSoftInput(final boolean force, final int state) { + if (DEBUG) { + Log.d(LOGTAG, "toggleSoftInput"); + } + // Can be called from UI or IC thread. + final int flags = mIMEFlags; + + // There are three paths that toggleSoftInput() can be called: + // 1) through calling restartInput(), which then indirectly calls + // onCreateInputConnection() and then toggleSoftInput(). + // 2) through calling toggleSoftInput() directly from restartInput(). + // This path is the fallback in case 1) does not happen. + // 3) through a system-generated onCreateInputConnection() call when the activity + // is restored from background, which then calls toggleSoftInput(). + // mSoftInputReentrancyGuard is needed to ensure that between the different paths, + // the soft input is only toggled exactly once. + + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + try { + final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet(); + final boolean isReentrant = reentrancyGuard > 1; + + // When using Find In Page, we can still receive notifyIMEContext calls due to the + // selection changing when highlighting. However in this case we don't want to + // show/hide the keyboard because the find box has the focus and is taking input from + // the keyboard. + final GeckoSession session = mSession.get(); + + if (session == null) { + return; + } + + final View view = session.getTextInput().getView(); + final boolean isFocused = (view == null) || view.hasFocus(); + + final boolean isUserAction = + ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0); + + if (!force && (isReentrant || !isFocused || !isUserAction)) { + if (DEBUG) { + Log.d( + LOGTAG, + "toggleSoftInput: no-op, reentrant=" + + isReentrant + + ", focused=" + + isFocused + + ", user=" + + isUserAction); + } + return; + } + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + session.getTextInput().getDelegate().hideSoftInput(session); + return; + } + { + final GeckoBundle bundle = new GeckoBundle(); + // This bit is subtle. We want to force-zoom to the input + // if we're _not_ force-showing the virtual keyboard. + // + // We only force-show the virtual keyboard as a result of + // something that _doesn't_ switch the focus, and we don't + // want to move the view out of the focused editor unless + // we _actually_ show toggle the keyboard. + bundle.putBoolean("force", !force); + session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle); + } + session.getTextInput().getDelegate().showSoftInput(session); + } finally { + mSoftInputReentrancyGuard.decrementAndGet(); + } + } + }); + } + + @Override // IGeckoEditableParent + public void onSelectionChange( + final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onSelectionChange("); + sb.append(start) + .append(", ") + .append(end) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (mIgnoreSelectionChange) { + mIgnoreSelectionChange = false; + } else { + mText.currentSetSelection(start, end); + } + + // We receive selection change notification after receiving replies for pending + // events, so we can reset text change bounds at this point. + mLastTextChangeStart = Integer.MAX_VALUE; + mLastTextChangeOldEnd = -1; + mLastTextChangeNewEnd = -1; + mLastTextChangeReplacedSelection = false; + + if (causedOnlyByComposition) { + // It is unnecessary to sync shadow text since this change is by composition from Java + // side. + return; + } + + // It is ready to synchronize Java text with Gecko text when no more input events is + // dispatched. + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icSyncShadowText(); + } + }); + } + + private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) { + return oldEnd - start == newText.length() + && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start); + } + + @Override // IGeckoEditableParent + public void onTextChange( + final IBinder token, + final CharSequence text, + final int start, + final int unboundedOldEnd, + final boolean causedOnlyByComposition) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onTextChange("); + debugAppend(sb, text) + .append(", ") + .append(start) + .append(", ") + .append(unboundedOldEnd) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (unboundedOldEnd >= Integer.MAX_VALUE / 2) { + // Integer.MAX_VALUE / 2 is a magic number to synchronize all. + // (See GeckoEditableSupport::FlushIMEText.) + // Previous text transactions are unnecessary now, so we have to ignore it. + mActions.clear(); + } + + final int currentLength = mText.getCurrentText().length(); + final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd; + final int newEnd = start + text.length(); + + if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) { + // | oldEnd > currentLength | signals entire text is cleared (e.g. for + // newly-focused editors). Simply replace the text in that case; replace in + // two steps to properly clear composing spans that span the whole range. + mText.currentReplace(0, currentLength, ""); + mText.currentReplace(0, 0, text); + + // Don't ignore the next selection change because we are re-syncing with Gecko + mIgnoreSelectionChange = false; + + mLastTextChangeStart = Integer.MAX_VALUE; + mLastTextChangeOldEnd = -1; + mLastTextChangeNewEnd = -1; + mLastTextChangeReplacedSelection = false; + + } else if (!geckoIsSameText(start, oldEnd, text)) { + final Spanned currentText = mText.getCurrentText(); + final int selStart = Selection.getSelectionStart(currentText); + final int selEnd = Selection.getSelectionEnd(currentText); + + // True if the selection was in the middle of the replaced text; in that case + // we don't know where to place the selection after replacement, and must rely + // on the Gecko selection. + mLastTextChangeReplacedSelection |= + (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd); + + // Gecko side initiated the text change. Replace in two steps to properly + // clear composing spans that span the whole range. + mText.currentReplace(start, oldEnd, ""); + mText.currentReplace(start, start, text); + + mLastTextChangeStart = Math.min(start, mLastTextChangeStart); + mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd); + mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd); + + } else { + // Nothing to do because the text is the same. This could happen when + // the composition is updated for example, in which case we want to keep the + // Java selection. + final Action action = mActions.peek(); + mIgnoreSelectionChange = + mIgnoreSelectionChange + || (action != null + && (action.mType == Action.TYPE_REPLACE_TEXT + || action.mType == Action.TYPE_SET_SPAN + || action.mType == Action.TYPE_REMOVE_SPAN)); + + mLastTextChangeStart = Math.min(start, mLastTextChangeStart); + mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd); + mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd); + } + + // onTextChange is always followed by onSelectionChange, so we let + // onSelectionChange schedule a shadow text sync. + } + + @Override // IGeckoEditableParent + public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onDefaultKeyEvent("); + sb.append("action=") + .append(event.getAction()) + .append(", ") + .append("keyCode=") + .append(event.getKeyCode()) + .append(", ") + .append("metaState=") + .append(event.getMetaState()) + .append(", ") + .append("time=") + .append(event.getEventTime()) + .append(", ") + .append("repeatCount=") + .append(event.getRepeatCount()) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + // Allow default key processing even if we're not focused. + if (!binderCheckToken(token, /* allowNull */ true)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.onDefaultKeyEvent(event); + } + }); + } + + @Override // IGeckoEditableParent + public void updateCompositionRects( + final IBinder token, final RectF[] rects, final RectF caretRect) { + // On Gecko or binder thread. + if (DEBUG) { + Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")"); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.updateCompositionRects(rects, caretRect); + } + }); + } + + // InvocationHandler interface + + static String getConstantName(final Class cls, final String prefix, final Object value) { + for (final Field fld : cls.getDeclaredFields()) { + try { + if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) { + return fld.getName(); + } + } catch (final IllegalAccessException e) { + } + } + return String.valueOf(value); + } + + private static String getPrintableChar(final char chr) { + if (chr >= 0x20 && chr <= 0x7e) { + return String.valueOf(chr); + } else if (chr == '\n') { + return "\u21b2"; + } + return String.format("\\u%04x", (int) chr); + } + + static StringBuilder debugAppend(final StringBuilder sb, final Object obj) { + if (obj == null) { + sb.append("null"); + } else if (obj instanceof GeckoEditable) { + sb.append("GeckoEditable"); + } else if (obj instanceof GeckoEditableChild) { + sb.append("GeckoEditableChild"); + } else if (Proxy.isProxyClass(obj.getClass())) { + debugAppend(sb, Proxy.getInvocationHandler(obj)); + } else if (obj instanceof Character) { + sb.append('\'').append(getPrintableChar((Character) obj)).append('\''); + } else if (obj instanceof CharSequence) { + final String str = obj.toString(); + sb.append('"'); + for (int i = 0; i < str.length(); i++) { + final char chr = str.charAt(i); + if (chr >= 0x20 && chr <= 0x7e) { + sb.append(chr); + } else { + sb.append(getPrintableChar(chr)); + } + } + sb.append('"'); + } else if (obj.getClass().isArray()) { + sb.append(obj.getClass().getComponentType().getSimpleName()) + .append('[') + .append(Array.getLength(obj)) + .append(']'); + } else { + sb.append(obj); + } + return sb; + } + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + throws Throwable { + final Object target; + final Class methodInterface = method.getDeclaringClass(); + if (DEBUG) { + // Editable methods should all be called from the IC thread + assertOnIcThread(); + } + if (methodInterface == Editable.class + || methodInterface == Appendable.class + || methodInterface == Spannable.class) { + // Method alters the Editable; route calls to our implementation + target = this; + } else { + target = mText.getShadowText(); + } + + final Object ret = method.invoke(target, args); + if (DEBUG) { + final StringBuilder log = new StringBuilder(method.getName()); + log.append("("); + if (args != null) { + for (final Object arg : args) { + debugAppend(log, arg).append(", "); + } + if (args.length > 0) { + log.setLength(log.length() - 2); + } + } + if (method.getReturnType().equals(Void.TYPE)) { + log.append(")"); + } else { + debugAppend(log.append(") = "), ret); + } + Log.d(LOGTAG, log.toString()); + } + return ret; + } + + // Spannable interface + + @Override + public void removeSpan(final Object what) { + if (what == null) { + return; + } + + if (what == Selection.SELECTION_START || what == Selection.SELECTION_END) { + Log.w(LOGTAG, "selection removed with removeSpan()"); + } + + icOfferAction(Action.newRemoveSpan(what)); + } + + @Override + public void setSpan(final Object what, final int start, final int end, final int flags) { + icOfferAction(Action.newSetSpan(what, start, end, flags)); + } + + // Appendable interface + + @Override + public Editable append(final CharSequence text) { + return replace(mProxy.length(), mProxy.length(), text, 0, text.length()); + } + + @Override + public Editable append(final CharSequence text, final int start, final int end) { + return replace(mProxy.length(), mProxy.length(), text, start, end); + } + + @Override + public Editable append(final char text) { + return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1); + } + + // Editable interface + + @Override + public InputFilter[] getFilters() { + return mFilters; + } + + @Override + public void setFilters(final InputFilter[] filters) { + mFilters = filters; + } + + @Override + public void clearSpans() { + /* XXX this clears the selection spans too, + but there is no way to clear the corresponding selection in Gecko */ + Log.w(LOGTAG, "selection cleared with clearSpans()"); + icOfferAction(Action.newRemoveSpan(/* what */ null)); + } + + @Override + public Editable replace( + final int st, final int en, final CharSequence source, final int start, final int end) { + CharSequence text = source; + if (start < 0 || start > end || end > text.length()) { + Log.e( + LOGTAG, + "invalid replace offsets: " + start + " to " + end + ", length: " + text.length()); + throw new IllegalArgumentException("invalid replace offsets"); + } + if (start != 0 || end != text.length()) { + text = text.subSequence(start, end); + } + if (mFilters != null) { + // Filter text before sending the request to Gecko + for (int i = 0; i < mFilters.length; ++i) { + final CharSequence cs = mFilters[i].filter(text, 0, text.length(), mProxy, st, en); + if (cs != null) { + text = cs; + } + } + } + if (text == source) { + // Always create a copy + text = new SpannableString(source); + } + icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en))); + return mProxy; + } + + @Override + public void clear() { + replace(0, mProxy.length(), "", 0, 0); + } + + @Override + public Editable delete(final int st, final int en) { + return replace(st, en, "", 0, 0); + } + + @Override + public Editable insert(final int where, final CharSequence text, final int start, final int end) { + return replace(where, where, text, start, end); + } + + @Override + public Editable insert(final int where, final CharSequence text) { + return replace(where, where, text, 0, text.length()); + } + + @Override + public Editable replace(final int st, final int en, final CharSequence text) { + return replace(st, en, text, 0, text.length()); + } + + /* GetChars interface */ + + @Override + public void getChars(final int start, final int end, final char[] dest, final int destoff) { + /* overridden Editable interface methods in GeckoEditable must not be called directly + outside of GeckoEditable. Instead, the call must go through mProxy, which ensures + that Java is properly synchronized with Gecko */ + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + /* Spanned interface */ + + @Override + public int getSpanEnd(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanFlags(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanStart(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public T[] getSpans(final int start, final int end, final Class type) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration + public int nextSpanTransition(final int start, final int limit, final Class type) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + /* CharSequence interface */ + + @Override + public char charAt(final int index) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int length() { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public CharSequence subSequence(final int start, final int end) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public String toString() { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + public boolean onKeyPreIme( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return false; + } + + public boolean onKeyDown( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event); + } + + public boolean onKeyUp( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return processKey(view, KeyEvent.ACTION_UP, keyCode, event); + } + + public boolean onKeyMultiple( + final @Nullable View view, + final int keyCode, + final int repeatCount, + final @NonNull KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters() + final String str = event.getCharacters(); + for (int i = 0; i < str.length(); i++) { + final KeyEvent charEvent = getCharKeyEvent(str.charAt(i)); + if (!processKey(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent) + || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) { + return false; + } + } + return true; + } + + for (int i = 0; i < repeatCount; i++) { + if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event) + || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) { + return false; + } + } + + return true; + } + + public boolean onKeyLongPress( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return false; + } + + /** Get a key that represents a given character. */ + private static KeyEvent getCharKeyEvent(final char c) { + final long time = SystemClock.uptimeMillis(); + return new KeyEvent( + time, time, KeyEvent.ACTION_MULTIPLE, KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) { + @Override + public int getUnicodeChar() { + return c; + } + + @Override + public int getUnicodeChar(final int metaState) { + return c; + } + }; + } + + private boolean processKey( + final @Nullable View view, + final int action, + final int keyCode, + final @NonNull KeyEvent event) { + if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) { + return false; + } + + postToInputConnection( + new Runnable() { + @Override + public void run() { + sendKeyEvent(view, action, event); + } + }); + return true; + } + + private static boolean shouldProcessKey(final int keyCode, final KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_MENU: + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_SEARCH: + // ignore HEADSETHOOK to allow hold-for-voice-search to work + case KeyEvent.KEYCODE_HEADSETHOOK: + return false; + } + return true; + } + + private static boolean isComposing(final Spanned text) { + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + return true; + } + } + + return false; + } + + private static int getComposingStart(final Spanned text) { + int composingStart = Integer.MAX_VALUE; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + composingStart = Math.min(composingStart, text.getSpanStart(span)); + } + } + + return composingStart; + } + + private static int getComposingEnd(final Spanned text) { + int composingEnd = -1; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + composingEnd = Math.max(composingEnd, text.getSpanEnd(span)); + } + } + + return composingEnd; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java new file mode 100644 index 0000000000..ec53d2803a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java @@ -0,0 +1,172 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.provider.Settings; +import android.util.Log; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * A class that automatically adjusts font size settings for web content in Gecko in accordance with + * the device's OS font scale setting. + * + * @see android.provider.Settings.System#FONT_SCALE + */ +/* package */ final class GeckoFontScaleListener extends ContentObserver { + private static final String LOGTAG = "GeckoFontScaleListener"; + + private static final float DEFAULT_FONT_SCALE = 1.0f; + + // We're referencing the *application* context, so this is in fact okay. + @SuppressLint("StaticFieldLeak") + private static final GeckoFontScaleListener sInstance = new GeckoFontScaleListener(); + + private Context mApplicationContext; + private GeckoRuntimeSettings mSettings; + + private boolean mAttached; + private boolean mEnabled; + private boolean mRunning; + + private float mPrevGeckoFontScale; + + public static GeckoFontScaleListener getInstance() { + return sInstance; + } + + private GeckoFontScaleListener() { + // Ensure the ContentObserver callback runs on the UI thread. + super(ThreadUtils.getUiHandler()); + } + + /** + * Prepare the GeckoFontScaleListener for usage. If it has been previously enabled, it will now + * start actively working. + */ + public void attachToContext(final Context context, final GeckoRuntimeSettings settings) { + ThreadUtils.assertOnUiThread(); + + if (mAttached) { + Log.w(LOGTAG, "Already attached!"); + return; + } + + mAttached = true; + mSettings = settings; + mApplicationContext = context.getApplicationContext(); + onEnabledChange(); + } + + /** + * Detaches the context and also stops the GeckoFontScaleListener if it was previously enabled. + * This will also restore the previously used font size settings. + */ + public void detachFromContext() { + ThreadUtils.assertOnUiThread(); + + if (!mAttached) { + Log.w(LOGTAG, "Already detached!"); + return; + } + + stop(); + mApplicationContext = null; + mSettings = null; + mAttached = false; + } + + /** + * Controls whether the GeckoFontScaleListener should automatically adjust font sizes for web + * content in Gecko. When disabling, this will restore the previously used font size settings. + * + *

    This method can be called at any time, but the GeckoFontScaleListener won't start actively + * adjusting font sizes until it has been attached to a context. + * + * @param enabled True if automatic font size setting should be enabled. + */ + public void setEnabled(final boolean enabled) { + ThreadUtils.assertOnUiThread(); + mEnabled = enabled; + onEnabledChange(); + } + + /** + * Get whether the GeckoFontScaleListener is currently enabled. + * + * @return True if the GeckoFontScaleListener is currently enabled. + */ + public boolean getEnabled() { + return mEnabled; + } + + private void onEnabledChange() { + if (!mAttached) { + return; + } + + if (mEnabled) { + start(); + } else { + stop(); + } + } + + private void start() { + if (mRunning) { + return; + } + + mPrevGeckoFontScale = mSettings.getFontSizeFactor(); + final ContentResolver contentResolver = mApplicationContext.getContentResolver(); + final Uri fontSizeSetting = Settings.System.getUriFor(Settings.System.FONT_SCALE); + contentResolver.registerContentObserver(fontSizeSetting, false, this); + onSystemFontScaleChange(contentResolver, false); + + mRunning = true; + } + + private void stop() { + if (!mRunning) { + return; + } + + final ContentResolver contentResolver = mApplicationContext.getContentResolver(); + contentResolver.unregisterContentObserver(this); + onSystemFontScaleChange(contentResolver, /*stopping*/ true); + + mRunning = false; + } + + private void onSystemFontScaleChange( + final ContentResolver contentResolver, final boolean stopping) { + float fontScale; + + if (!stopping) { // Either we were enabled, or else the system font scale changed. + fontScale = + Settings.System.getFloat(contentResolver, Settings.System.FONT_SCALE, DEFAULT_FONT_SCALE); + // Older Android versions don't sanitize the FONT_SCALE value. See Bug 1656078. + if (fontScale < 0) { + fontScale = DEFAULT_FONT_SCALE; + } + } else { // We were turned off. + fontScale = mPrevGeckoFontScale; + } + + mSettings.setFontSizeFactorInternal(fontScale); + } + + @UiThread // See constructor. + @Override + public void onChange(final boolean selfChange) { + onSystemFontScaleChange(mApplicationContext.getContentResolver(), false); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java new file mode 100644 index 0000000000..2d2f2d8dd3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java @@ -0,0 +1,829 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Selection; +import android.text.SpannableString; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.CursorAnchorInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputContentInfo; +import androidx.annotation.NonNull; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import org.mozilla.gecko.Clipboard; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.util.ThreadUtils; + +/* package */ final class GeckoInputConnection extends BaseInputConnection + implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener { + + private static final boolean DEBUG = false; + protected static final String LOGTAG = "GeckoInputConnection"; + + private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection"; + private static final String CUSTOM_HANDLER_TEST_CLASS = + "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput"; + + private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480; + + private static Handler sBackgroundHandler; + + // Managed only by notifyIMEContext; see comments in notifyIMEContext + @IMEState private int mIMEState; + private String mIMEActionHint = ""; + private int mLastSelectionStart; + private int mLastSelectionEnd; + + private String mCurrentInputMethod = ""; + + private final GeckoSession mSession; + private final View mView; + private final SessionTextInput.EditableClient mEditableClient; + protected int mBatchEditCount; + private ExtractedTextRequest mUpdateRequest; + private final InputConnection mKeyInputConnection; + private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; + + public static SessionTextInput.InputConnectionClient create( + final GeckoSession session, + final View targetView, + final SessionTextInput.EditableClient editable) { + SessionTextInput.InputConnectionClient ic = + new GeckoInputConnection(session, targetView, editable); + if (DEBUG) { + ic = wrapForDebug(ic); + } + return ic; + } + + private static SessionTextInput.InputConnectionClient wrapForDebug( + final SessionTextInput.InputConnectionClient ic) { + final InvocationHandler handler = + new InvocationHandler() { + private final StringBuilder mCallLevel = new StringBuilder(); + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + throws Throwable { + final StringBuilder log = new StringBuilder(mCallLevel); + log.append("> ").append(method.getName()).append("("); + if (args != null) { + for (int i = 0; i < args.length; i++) { + final Object arg = args[i]; + // translate argument values to constant names + if ("notifyIME".equals(method.getName()) && i == 0) { + log.append( + GeckoEditable.getConstantName( + SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg)); + } else if ("notifyIMEContext".equals(method.getName()) && i == 0) { + log.append( + GeckoEditable.getConstantName( + SessionTextInput.EditableListener.class, "IME_STATE_", arg)); + } else { + GeckoEditable.debugAppend(log, arg); + } + log.append(", "); + } + if (args.length > 0) { + log.setLength(log.length() - 2); + } + } + log.append(")"); + Log.d(LOGTAG, log.toString()); + + mCallLevel.append(' '); + Object ret = method.invoke(ic, args); + if (ret == ic) { + ret = proxy; + } + mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1)); + + log.setLength(mCallLevel.length()); + log.append("< ").append(method.getName()); + if (!method.getReturnType().equals(Void.TYPE)) { + GeckoEditable.debugAppend(log.append(": "), ret); + } + Log.d(LOGTAG, log.toString()); + return ret; + } + }; + + return (SessionTextInput.InputConnectionClient) + Proxy.newProxyInstance( + GeckoInputConnection.class.getClassLoader(), + new Class[] { + InputConnection.class, + SessionTextInput.InputConnectionClient.class, + SessionTextInput.EditableListener.class + }, + handler); + } + + protected GeckoInputConnection( + final GeckoSession session, + final View targetView, + final SessionTextInput.EditableClient editable) { + super(targetView, true); + mSession = session; + mView = targetView; + mEditableClient = editable; + mIMEState = IME_STATE_DISABLED; + // InputConnection that sends keys for plugins, which don't have full editors + mKeyInputConnection = new BaseInputConnection(targetView, false); + } + + @Override + public synchronized boolean beginBatchEdit() { + mBatchEditCount++; + if (mBatchEditCount == 1) { + mEditableClient.setBatchMode(true); + } + return true; + } + + @Override + public synchronized boolean endBatchEdit() { + if (mBatchEditCount <= 0) { + Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!"); + return true; + } + + mBatchEditCount--; + if (mBatchEditCount != 0) { + return true; + } + + // setBatchMode will call onTextChange and/or onSelectionChange for us. + mEditableClient.setBatchMode(false); + return true; + } + + @Override + public Editable getEditable() { + return mEditableClient.getEditable(); + } + + @Override + public boolean performContextMenuAction(final int id) { + final View view = getView(); + final Editable editable = getEditable(); + if (view == null || editable == null) { + return false; + } + final int selStart = Selection.getSelectionStart(editable); + final int selEnd = Selection.getSelectionEnd(editable); + + switch (id) { + case android.R.id.selectAll: + setSelection(0, editable.length()); + break; + case android.R.id.cut: + // If selection is empty, we'll select everything + if (selStart == selEnd) { + // Fill the clipboard + Clipboard.setText(view.getContext(), editable); + editable.clear(); + } else { + Clipboard.setText( + view.getContext(), + editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd))); + editable.delete(selStart, selEnd); + } + break; + case android.R.id.paste: + final String text = Clipboard.getText(view.getContext()); + if (text != null) { + commitText(text, 1); + } + break; + case android.R.id.copy: + // Copy the current selection or the empty string if nothing is selected. + final String copiedText = + selStart == selEnd + ? "" + : editable + .toString() + .substring(Math.min(selStart, selEnd), Math.max(selStart, selEnd)); + Clipboard.setText(view.getContext(), copiedText); + break; + } + return true; + } + + @Override + public boolean performEditorAction(final int editorAction) { + if (editorAction == EditorInfo.IME_ACTION_PREVIOUS && !mIMEActionHint.equals("previous")) { + // This action is [Previous] key on FireTV's keyboard. + // [Previous] closes software keyboard, and don't generate any keyboard event. + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().hideSoftInput(mSession); + } + }); + return true; + } + return super.performEditorAction(editorAction); + } + + @Override + public ExtractedText getExtractedText(final ExtractedTextRequest req, final int flags) { + if (req == null) return null; + + if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req; + + final Editable editable = getEditable(); + if (editable == null) { + return null; + } + final int selStart = Selection.getSelectionStart(editable); + final int selEnd = Selection.getSelectionEnd(editable); + + final ExtractedText extract = new ExtractedText(); + extract.flags = 0; + extract.partialStartOffset = -1; + extract.partialEndOffset = -1; + extract.selectionStart = selStart; + extract.selectionEnd = selEnd; + extract.startOffset = 0; + if ((req.flags & GET_TEXT_WITH_STYLES) != 0) { + extract.text = new SpannableString(editable); + } else { + extract.text = editable.toString(); + } + return extract; + } + + @Override // SessionTextInput.InputConnectionClient + public View getView() { + return mView; + } + + @NonNull + /* package */ GeckoSession.TextInputDelegate getInputDelegate() { + return mSession.getTextInput().getDelegate(); + } + + @Override // SessionTextInput.EditableListener + public void onTextChange() { + final Editable editable = getEditable(); + if (mUpdateRequest == null || editable == null) { + return; + } + + final ExtractedTextRequest request = mUpdateRequest; + final ExtractedText extractedText = new ExtractedText(); + extractedText.flags = 0; + // Update the entire Editable range + extractedText.partialStartOffset = -1; + extractedText.partialEndOffset = -1; + extractedText.selectionStart = Selection.getSelectionStart(editable); + extractedText.selectionEnd = Selection.getSelectionEnd(editable); + extractedText.startOffset = 0; + if ((request.flags & GET_TEXT_WITH_STYLES) != 0) { + extractedText.text = new SpannableString(editable); + } else { + extractedText.text = editable.toString(); + } + + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().updateExtractedText(mSession, request, extractedText); + } + }); + } + + @Override // SessionTextInput.EditableListener + public void onSelectionChange() { + + final Editable editable = getEditable(); + if (editable != null) { + mLastSelectionStart = Selection.getSelectionStart(editable); + mLastSelectionEnd = Selection.getSelectionEnd(editable); + notifySelectionChange(mLastSelectionStart, mLastSelectionEnd); + } + } + + private void notifySelectionChange(final int start, final int end) { + final Editable editable = getEditable(); + if (editable == null) { + return; + } + + final int compositionStart = getComposingSpanStart(editable); + final int compositionEnd = getComposingSpanEnd(editable); + + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate() + .updateSelection(mSession, start, end, compositionStart, compositionEnd); + } + }); + } + + @Override // SessionTextInput.EditableListener + public void onDiscardComposition() { + final View view = getView(); + if (view == null) { + return; + } + + // InputMethodManager.updateSelection will remove composition + // on most IMEs. But ATOK series do nothing. So we have to + // restart input method to remove composition as workaround. + if (!InputMethods.needsRestartInput(InputMethods.getCurrentInputMethod(view.getContext()))) { + return; + } + + view.post( + new Runnable() { + @Override + public void run() { + getInputDelegate() + .restartInput( + mSession, GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE); + } + }); + } + + @TargetApi(21) + @Override // SessionTextInput.EditableListener + public void updateCompositionRects(final RectF[] rects, final RectF caretRect) { + if (!(Build.VERSION.SDK_INT >= 21)) { + return; + } + + final View view = getView(); + if (view == null) { + return; + } + + final Editable content = getEditable(); + if (content == null) { + return; + } + + final int composingStart = getComposingSpanStart(content); + final int composingEnd = getComposingSpanEnd(content); + if (composingStart < 0 || composingEnd < 0) { + if (DEBUG) { + Log.d(LOGTAG, "No composition for updates"); + } + return; + } + + final CharSequence composition = content.subSequence(composingStart, composingEnd); + + view.post( + new Runnable() { + @Override + public void run() { + updateCompositionRectsOnUi(view, rects, caretRect, composition); + } + }); + } + + @TargetApi(21) + /* package */ void updateCompositionRectsOnUi( + final View view, final RectF[] rects, final RectF caretRect, final CharSequence composition) { + if (mCursorAnchorInfoBuilder == null) { + mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); + } + mCursorAnchorInfoBuilder.reset(); + + final Matrix matrix = new Matrix(); + mSession.getClientToScreenOffsetMatrix(matrix); + mCursorAnchorInfoBuilder.setMatrix(matrix); + + for (int i = 0; i < rects.length; i++) { + mCursorAnchorInfoBuilder.addCharacterBounds( + i, + rects[i].left, + rects[i].top, + rects[i].right, + rects[i].bottom, + CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); + } + + mCursorAnchorInfoBuilder.setComposingText(0, composition); + + if (!caretRect.isEmpty()) { + // Gecko doesn't provide baseline information of caret. + mCursorAnchorInfoBuilder.setInsertionMarkerLocation( + caretRect.left, + caretRect.top, + caretRect.bottom, + caretRect.bottom, + CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); + } + + final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build(); + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().updateCursorAnchorInfo(mSession, info); + } + }); + } + + @Override + public boolean requestCursorUpdates(final int cursorUpdateMode) { + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.ONE_SHOT); + } + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.START_MONITOR); + } else { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.END_MONITOR); + } + return true; + } + + @Override // SessionTextInput.EditableListener + public void onDefaultKeyEvent(final KeyEvent event) { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + GeckoInputConnection.this.performDefaultKeyAction(event); + } + }); + } + + private static synchronized Handler getBackgroundHandler() { + if (sBackgroundHandler != null) { + return sBackgroundHandler; + } + // Don't use GeckoBackgroundThread because Gecko thread may block waiting on + // GeckoBackgroundThread. If we were to use GeckoBackgroundThread, due to IME, + // GeckoBackgroundThread may end up also block waiting on Gecko thread and a + // deadlock occurs + final Thread backgroundThread = + new Thread( + new Runnable() { + @Override + public void run() { + Looper.prepare(); + synchronized (GeckoInputConnection.class) { + sBackgroundHandler = new Handler(); + GeckoInputConnection.class.notify(); + } + Looper.loop(); + // We should never be exiting the thread loop. + throw new IllegalThreadStateException("unreachable code"); + } + }, + LOGTAG); + backgroundThread.setDaemon(true); + backgroundThread.start(); + while (sBackgroundHandler == null) { + try { + // wait for new thread to set sBackgroundHandler + GeckoInputConnection.class.wait(); + } catch (final InterruptedException e) { + } + } + return sBackgroundHandler; + } + + private synchronized boolean canReturnCustomHandler() { + if (mIMEState == IME_STATE_DISABLED) { + return false; + } + for (final StackTraceElement frame : Thread.currentThread().getStackTrace()) { + // We only return our custom Handler to InputMethodManager's InputConnection + // proxy. For all other purposes, we return the regular Handler. + // InputMethodManager retrieves the Handler for its InputConnection proxy + // inside its method startInputInner(), so we check for that here. This is + // valid from Android 2.2 to at least Android 4.2. If this situation ever + // changes, we gracefully fall back to using the regular Handler. + if ("startInputInner".equals(frame.getMethodName()) + && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) { + // Only return our own Handler to InputMethodManager and only prior to 24. + return Build.VERSION.SDK_INT < 24; + } + if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName()) + && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) { + // InputConnection tests should also run on the custom handler + return true; + } + } + return false; + } + + private boolean isPhysicalKeyboardPresent() { + final View v = getView(); + if (v == null) { + return false; + } + final Configuration config = v.getContext().getResources().getConfiguration(); + return config.keyboard != Configuration.KEYBOARD_NOKEYS; + } + + @Override // InputConnection + public Handler getHandler() { + final Handler handler; + if (isPhysicalKeyboardPresent()) { + handler = ThreadUtils.getUiHandler(); + } else { + handler = getBackgroundHandler(); + } + return mEditableClient.setInputConnectionHandler(handler); + } + + @Override // SessionTextInput.InputConnectionClient + public Handler getHandler(final Handler defHandler) { + if (!canReturnCustomHandler()) { + return defHandler; + } + + return getHandler(); + } + + @Override // InputConnection + public void closeConnection() { + if (mBatchEditCount != 0) { + // GBoard may call this into batch edit mode then it doesn't call endBatchEdit. + // Since we are recycle GeckoInputConnection, we have to reset + // batch count even if IME/keyboard bug. + if (DEBUG) { + Log.d(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); + } + mBatchEditCount = 0; + // setBatchMode will call onTextChange and/or onSelectionChange for us. + mEditableClient.setBatchMode(false); + } + super.closeConnection(); + } + + @Override // SessionTextInput.InputConnectionClient + public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + if (mIMEState == IME_STATE_DISABLED) { + return null; + } + + final Context context = getView().getContext(); + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + if (Math.min(metrics.widthPixels, metrics.heightPixels) > INLINE_IME_MIN_DISPLAY_SIZE) { + // prevent showing full-screen keyboard only when the screen is tall enough + // to show some reasonable amount of the page (see bug 752709) + outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN; + } + + if (DEBUG) { + Log.d( + LOGTAG, + "mapped IME states to: inputType = " + + Integer.toHexString(outAttrs.inputType) + + ", imeOptions = " + + Integer.toHexString(outAttrs.imeOptions)); + } + + final String prevInputMethod = mCurrentInputMethod; + mCurrentInputMethod = InputMethods.getCurrentInputMethod(context); + if (DEBUG) { + Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod); + } + + outAttrs.initialSelStart = mLastSelectionStart; + outAttrs.initialSelEnd = mLastSelectionEnd; + return this; + } + + private boolean replaceComposingSpanWithSelection() { + final Editable content = getEditable(); + if (content == null) { + return false; + } + final int a = getComposingSpanStart(content); + final int b = getComposingSpanEnd(content); + if (a != -1 && b != -1) { + if (DEBUG) { + Log.d(LOGTAG, "removing composition at " + a + "-" + b); + } + removeComposingSpans(content); + Selection.setSelection(content, a, b); + } + return true; + } + + @Override + public boolean commitText(final CharSequence text, final int newCursorPosition) { + if (InputMethods.shouldCommitCharAsKey(mCurrentInputMethod) + && text.length() == 1 + && newCursorPosition > 0) { + if (DEBUG) { + Log.d(LOGTAG, "committing \"" + text + "\" as key"); + } + // mKeyInputConnection is a BaseInputConnection that commits text as keys; + // but we first need to replace any composing span with a selection, + // so that the new key events will generate characters to replace + // text from the old composing span + return replaceComposingSpanWithSelection() + && mKeyInputConnection.commitText(text, newCursorPosition); + } + return super.commitText(text, newCursorPosition); + } + + @Override + public boolean setSelection(final int start, final int end) { + if (start < 0 || end < 0) { + // Some keyboards (e.g. Samsung) can call setSelection with + // negative offsets. In that case we ignore the call, similar to how + // BaseInputConnection.setSelection ignores offsets that go past the length. + return true; + } + return super.setSelection(start, end); + } + + @Override + public boolean sendKeyEvent(final @NonNull KeyEvent event) { + final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event); + mEditableClient.sendKeyEvent(getView(), event.getAction(), translatedEvent); + return false; // seems to always return false + } + + private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_ENTER: + if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0 + && mIMEActionHint.equals("maybenext")) { + // XXX It is not good to dispatch tab key for web compatibility. + // See https://github.com/w3c/uievents/issues/253 and bug 1600540. + return new KeyEvent( + event.getDownTime(), + event.getEventTime(), + event.getAction(), + KeyEvent.KEYCODE_TAB, + 0); + } + break; + } + return event; + } + + // Called by OnDefaultKeyEvent handler, up from Gecko + /* package */ void performDefaultKeyAction(final KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MUTE: + case KeyEvent.KEYCODE_HEADSETHOOK: + case KeyEvent.KEYCODE_MEDIA_PLAY: + case KeyEvent.KEYCODE_MEDIA_PAUSE: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_MEDIA_STOP: + case KeyEvent.KEYCODE_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_REWIND: + case KeyEvent.KEYCODE_MEDIA_RECORD: + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + case KeyEvent.KEYCODE_MEDIA_CLOSE: + case KeyEvent.KEYCODE_MEDIA_EJECT: + case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK: + // Forward media keypresses to the registered handler so headset controls work + // Does the same thing as Chromium + // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445 + // These are all the keys dispatchMediaKeyEvent supports. + if (Build.VERSION.SDK_INT >= 19) { + // dispatchMediaKeyEvent is only available on Android 4.4+ + final Context viewContext = getView().getContext(); + final AudioManager am = + (AudioManager) viewContext.getSystemService(Context.AUDIO_SERVICE); + am.dispatchMediaKeyEvent(event); + } + break; + } + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + @Override + public boolean commitContent( + final InputContentInfo inputContentInfo, final int flags, final Bundle opts) { + final boolean requestPermission = + ((flags & InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0); + if (requestPermission) { + try { + inputContentInfo.requestPermission(); + } catch (final Exception e) { + Log.e(LOGTAG, "InputContentInfo.requestPermission() failed.", e); + return false; + } + } + + try (final InputStream inputStream = + getView() + .getContext() + .getContentResolver() + .openInputStream(inputContentInfo.getContentUri()); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + final byte[] data = new byte[4096]; + int readed; + while ((readed = inputStream.read(data)) != -1) { + outputStream.write(data, 0, readed); + } + mEditableClient.insertImage( + outputStream.toByteArray(), inputContentInfo.getDescription().getMimeType(0)); + } catch (final FileNotFoundException e) { + Log.e(LOGTAG, "Cannot open provider URI.", e); + return false; + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot read/write provider URI.", e); + return false; + } finally { + if (requestPermission) { + inputContentInfo.releasePermission(); + } + } + + return true; + } + + @Override // SessionTextInput.EditableListener + public void notifyIME(final @IMENotificationType int type) { + switch (type) { + case NOTIFY_IME_OF_FOCUS: + // Showing/hiding vkb is done in notifyIMEContext + if (mBatchEditCount != 0) { + Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); + mBatchEditCount = 0; + } + break; + + case NOTIFY_IME_OF_BLUR: + break; + + case NOTIFY_IME_OF_TOKEN: + case NOTIFY_IME_OPEN_VKB: + case NOTIFY_IME_REPLY_EVENT: + case NOTIFY_IME_TO_CANCEL_COMPOSITION: + case NOTIFY_IME_TO_COMMIT_COMPOSITION: + default: + if (DEBUG) { + throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type); + } + break; + } + } + + @Override // SessionTextInput.EditableListener + public synchronized void notifyIMEContext( + @IMEState final int state, + final String typeHint, + final String modeHint, + final String actionHint, + @IMEContextFlags final int flags) { + // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext, + // and not reset anywhere else. Usually, notifyIMEContext is called right after a + // focus or blur, so resetting mIMEState during the focus or blur seems harmless. + // However, this behavior is not guaranteed. Gecko may call notifyIMEContext + // independent of focus change; that is, a focus change may not be accompanied by + // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not + // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318) + /* When IME is 'disabled', IME processing is disabled. + In addition, the IME UI is hidden */ + mIMEState = state; + mIMEActionHint = (actionHint == null) ? "" : actionHint; + + // These fields are reset here and will be updated when restartInput is called below + mUpdateRequest = null; + mCurrentInputMethod = ""; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java new file mode 100644 index 0000000000..72b8db01f0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java @@ -0,0 +1,226 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.LinkedList; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** + * This class provides an {@link InputStream} wrapper for a Gecko nsIChannel (or really, + * nsIRequest). + */ +@WrapForJNI +@AnyThread +/* package */ class GeckoInputStream extends InputStream { + private static final String LOGTAG = "GeckoInputStream"; + + private LinkedList mBuffers = new LinkedList<>(); + private boolean mEOF; + private boolean mClosed; + private boolean mHaveError; + private long mReadTimeout; + private boolean mResumed; + private Support mSupport; + + /** + * This is only called via JNI. The support instance provides callbacks for the native + * counterpart. + * + * @param support An instance of {@link Support}, used for native callbacks. + */ + /* package */ GeckoInputStream(final @Nullable Support support) { + mSupport = support; + } + + public void setReadTimeoutMillis(final long millis) { + mReadTimeout = millis; + } + + @Override + public synchronized void close() throws IOException { + super.close(); + mClosed = true; + + if (mSupport != null) { + mSupport.close(); + mSupport = null; + } + } + + @Override + public synchronized int available() throws IOException { + if (mClosed) { + return 0; + } + + final ByteBuffer buf = mBuffers.peekFirst(); + return buf != null ? buf.remaining() : 0; + } + + private void ensureNotClosed() throws IOException { + if (mClosed) { + throw new IOException("Stream is closed"); + } + } + + @Override + public synchronized int read() throws IOException { + ensureNotClosed(); + + final int expect = Integer.SIZE / 8; + final byte[] bytes = new byte[expect]; + + int count = 0; + while (count < expect) { + final long bytesRead = read(bytes, count, expect - count); + if (bytesRead < 0) { + return -1; + } + + count += bytesRead; + } + + final ByteBuffer buffer = ByteBuffer.wrap(bytes); + return buffer.getInt(); + } + + @Override + public int read(final @NonNull byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public synchronized int read(final @NonNull byte[] dest, final int offset, final int length) + throws IOException { + ensureNotClosed(); + + final long startTime = System.currentTimeMillis(); + while (!mEOF && mBuffers.size() == 0) { + if (mReadTimeout > 0 && (System.currentTimeMillis() - startTime) >= mReadTimeout) { + throw new IOException("Timed out"); + } + + // The underlying channel is suspended, so resume that before + // waiting for a buffer. + if (!mResumed) { + if (mSupport != null) { + mSupport.resume(); + } + mResumed = true; + } + + try { + wait(mReadTimeout); + } catch (final InterruptedException e) { + } + } + + if (mEOF && mBuffers.size() == 0) { + if (mHaveError) { + throw new IOException("Unknown error"); + } + + // We have no data and we're not expecting more. + return -1; + } + + final ByteBuffer buf = mBuffers.peekFirst(); + final int readCount = Math.min(length, buf.remaining()); + buf.get(dest, offset, readCount); + + if (buf.remaining() == 0) { + // We're done with this buffer, advance the queue. + mBuffers.removeFirst(); + } + + return readCount; + } + + /** Called by native code to indicate that no more data will be sent via {@link #appendBuffer}. */ + @WrapForJNI(calledFrom = "gecko") + public synchronized void sendEof() { + if (mEOF) { + throw new IllegalStateException("Already have EOF"); + } + + mEOF = true; + notifyAll(); + } + + /** Called by native code to indicate that there was an error while reading the stream. */ + @WrapForJNI(calledFrom = "gecko") + public synchronized void sendError() { + if (mEOF) { + throw new IllegalStateException("Already have EOF"); + } + + mEOF = true; + mHaveError = true; + notifyAll(); + } + + /** + * Called by native code to indicate that there was an issue during appending data to the stream. + * The writing stream should still report EoF. Setting this error during writing will cause an + * IOException if readers try to read from the stream. + */ + @WrapForJNI(calledFrom = "gecko") + public synchronized void writeError() { + mHaveError = true; + notifyAll(); + } + + /** + * Called by native code to check if the stream is open. + * + * @return true if the stream is closed + */ + @WrapForJNI(calledFrom = "gecko") + /* package */ synchronized boolean isStreamClosed() { + return mClosed || mEOF; + } + + /** + * Called by native code to provide data for this stream. + * + * @param buf the bytes + * @throws IOException + */ + @WrapForJNI(exceptionMode = "nsresult", calledFrom = "gecko") + /* package */ synchronized void appendBuffer(final byte[] buf) throws IOException { + + if (mClosed) { + throw new IllegalStateException("Stream is closed"); + } + + if (mEOF) { + throw new IllegalStateException("EOF, no more data expected"); + } + + mBuffers.add(ByteBuffer.wrap(buf)); + notifyAll(); + } + + @WrapForJNI + private static class Support extends JNIObject { + @WrapForJNI(dispatchTo = "gecko") + private native void resume(); + + @WrapForJNI(dispatchTo = "gecko") + private native void close(); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java new file mode 100644 index 0000000000..c991913b75 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java @@ -0,0 +1,1072 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.SimpleArrayMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.IXPCOMEventTarget; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.XPCOMEventTarget; + +/** + * GeckoResult is a class that represents an asynchronous result. The result is initially pending, + * and at a later time, the result may be completed with {@link #complete a value} or {@link + * #completeExceptionally an exception} depending on the outcome of the asynchronous operation. For + * example, + * + *

    + * public GeckoResult<Integer> divide(final int dividend, final int divisor) {
    + *     final GeckoResult<Integer> result = new GeckoResult<>();
    + *     (new Thread(() -> {
    + *         if (divisor != 0) {
    + *             result.complete(dividend / divisor);
    + *         } else {
    + *             result.completeExceptionally(new ArithmeticException("Dividing by zero"));
    + *         }
    + *     })).start();
    + *     return result;
    + * }
    + * + *

    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, + * + *

    + * divide(42, 2).then(new GeckoResult.OnValueListener<Integer, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final Integer value) {
    + *         // value == 21
    + *     }
    + * }, new GeckoResult.OnExceptionListener<Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) {
    + *         // Not called
    + *     }
    + * });
    + * + *

    And to retrieve a completed exception, + * + *

    + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final Integer value) {
    + *         // Not called
    + *     }
    + * }, new GeckoResult.OnExceptionListener<Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) {
    + *         // exception instanceof ArithmeticException
    + *     }
    + * });
    + * + *

    {@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, + * + *

    + * divide(42, 2).then(new GeckoResult.OnValueListener<Integer, String>() {
    + *     @Override
    + *     public GeckoResult<String> onValue(final Integer value) {
    + *         return GeckoResult.fromValue(value.toString());
    + *     }
    + * }).then(new GeckoResult.OnValueListener<String, String>() {
    + *     @Override
    + *     public GeckoResult<String> onValue(final String value) {
    + *         return GeckoResult.fromValue("42 / 2 = " + value);
    + *     }
    + * }).then(new GeckoResult.OnValueListener<String, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final String value) {
    + *         // value == "42 / 2 = 21"
    + *         return null;
    + *     }
    + * });
    + * + *

    Chaining works with exception listeners as well. For example, + * + *

    + * divide(42, 0).then(new GeckoResult.OnExceptionListener<String>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) {
    + *         return "foo";
    + *     }
    + * }).then(new GeckoResult.OnValueListener<String, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final String value) {
    + *         // value == "foo"
    + *     }
    + * });
    + * + *

    A completed value/exception will propagate down the chain even if an intermediate step does + * not have a value/exception listener. For example, + * + *

    + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, String>() {
    + *     @Override
    + *     public GeckoResult<String> onValue(final Integer value) {
    + *         // Not called
    + *     }
    + * }).then(new GeckoResult.OnExceptionListener<Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) {
    + *         // exception instanceof ArithmeticException
    + *     }
    + * });
    + * + *

    However, any propagated value will be coerced to null. For example, + * + *

    + * divide(42, 2).then(new GeckoResult.OnExceptionListener<String>() {
    + *     @Override
    + *     public GeckoResult<String> onException(final Throwable exception) {
    + *         // Not called
    + *     }
    + * }).then(new GeckoResult.OnValueListener<String, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final String value) {
    + *         // value == null
    + *     }
    + * });
    + * + *

    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. + * + *

    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, + * + *

    + * GeckoResult.fromValue(42).then(new GeckoResult.OnValueListener<Integer, Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onValue(final Integer value) throws FooException {
    + *         throw new FooException();
    + *     }
    + * }).then(new GeckoResult.OnExceptionListener<Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) throws Exception {
    + *         // exception instanceof FooException
    + *         throw new BarException();
    + *     }
    + * }).then(new GeckoResult.OnExceptionListener<Void>() {
    + *     @Override
    + *     public GeckoResult<Void> onException(final Throwable exception) throws Throwable {
    + *         // exception instanceof BarException
    + *         return new BazException();
    + *     }
    + * });
    + * + * @param The type of the value delivered via the GeckoResult. + */ +@AnyThread +public class GeckoResult { + 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 cancel() { + return GeckoResult.fromValue(false); + } + } + + /** + * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#DENY} + */ + @AnyThread + @NonNull + public static GeckoResult deny() { + return GeckoResult.fromValue(AllowOrDeny.DENY); + } + + /** + * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#ALLOW} + */ + @AnyThread + @NonNull + public static GeckoResult allow() { + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + + // The default dispatcher for listeners on this GeckoResult. Other dispatchers can be specified + // when the listener is registered. + private final Dispatcher mDispatcher; + private boolean mComplete; + private T mValue; + private Throwable mError; + private boolean mIsUncaughtError; + private SimpleArrayMap> 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 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 Type for the result. + * @return The completed {@link GeckoResult} + */ + @WrapForJNI + public static @NonNull GeckoResult fromValue(@Nullable final U value) { + final GeckoResult 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 Type for the result if the result had been completed without exception. + * @return The completed {@link GeckoResult} + */ + @WrapForJNI + public static @NonNull GeckoResult fromException(@NonNull final Throwable error) { + final GeckoResult result = new GeckoResult<>(); + result.completeExceptionally(error); + return result; + } + + @Override + public synchronized int hashCode() { + return Arrays.hashCode(new Object[] {mComplete, mValue, mError}); + } + + // This can go away once we can rely on java.util.Objects.equals() (API 19) + private static boolean objectEquals(final Object a, final Object b) { + return a == b || (a != null && a.equals(b)); + } + + @Override + public synchronized boolean equals(final Object other) { + if (other instanceof GeckoResult) { + final GeckoResult result = (GeckoResult) other; + return result.mComplete == mComplete + && objectEquals(result.mError, mError) + && objectEquals(result.mValue, mValue); + } + + return false; + } + + /** + * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}. + * + * @param valueListener An instance of {@link OnValueListener}, called when the {@link + * GeckoResult} is completed with a value. + * @param Type of the new result that is returned by the listener. + * @return A new {@link GeckoResult} that the listener will complete. + */ + public @NonNull GeckoResult then(@NonNull final OnValueListener 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 Type of the new value that is returned by the mapper. + * @return A new {@link GeckoResult} that will contain the mapped value. + */ + public @NonNull GeckoResult map(@Nullable final OnValueMapper 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 Type of the new value that is returned by the mapper. + * @return A new {@link GeckoResult} that will contain the mapped value. + */ + public @NonNull GeckoResult map( + @Nullable final OnValueMapper valueMapper, + @Nullable final OnExceptionMapper exceptionMapper) { + final OnValueListener valueListener = + valueMapper != null ? value -> GeckoResult.fromValue(valueMapper.onValue(value)) : null; + final OnExceptionListener 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 Type of the new result that is returned by the listener. + * @return A new {@link GeckoResult} that the listener will complete. + */ + public @NonNull GeckoResult exceptionally( + @NonNull final OnExceptionListener exceptionListener) { + return then(null, exceptionListener); + } + + /** + * Replacement for {@link java.util.function.Consumer} for devices with minApi < 24. + * + * @param the type of the input for this consumer. + */ + // TODO: Remove this when we move to min API 24 + public interface Consumer { + /** + * 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 accept(@Nullable final Consumer 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 accept( + @Nullable final Consumer valueConsumer, + @Nullable final Consumer exceptionConsumer) { + final OnValueListener valueListener = + valueConsumer == null + ? null + : value -> { + valueConsumer.accept(value); + return null; + }; + + final OnExceptionListener exceptionListener = + exceptionConsumer == null + ? null + : value -> { + exceptionConsumer.accept(value); + return null; + }; + + return then(valueListener, exceptionListener); + } + + /** + * Adds listeners to be called when the {@link GeckoResult} is completed regardless of success + * status. Listeners will be invoked on the {@link Looper} returned from {@link #getLooper()}. If + * null, this method will throw {@link IllegalThreadStateException}. + * + *

    If the result is already complete when this method is called, listeners will be invoked in a + * future {@link Looper} iteration. + * + * @param finallyRunnable An instance of {@link Runnable}, called when the {@link GeckoResult} is + * completed with a value or a {@link Throwable}. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull GeckoResult finally_(@NonNull final Runnable finallyRunnable) { + final OnValueListener valueListener = + value -> { + finallyRunnable.run(); + return null; + }; + final OnExceptionListener exceptionListener = + value -> { + finallyRunnable.run(); + return null; + }; + return then(valueListener, exceptionListener); + } + + /* package */ @NonNull + GeckoResult getOrAccept(@Nullable final Consumer valueConsumer) { + return getOrAccept(valueConsumer, null); + } + + /* package */ @NonNull + GeckoResult getOrAccept( + @Nullable final Consumer valueConsumer, + @Nullable final Consumer 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 Type of the new result that is returned by the listeners. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull GeckoResult then( + @Nullable final OnValueListener valueListener, + @Nullable final OnExceptionListener exceptionListener) { + if (mDispatcher == null) { + throw new IllegalThreadStateException("Must have a Handler"); + } + + return thenInternal(mDispatcher, valueListener, exceptionListener); + } + + private @NonNull GeckoResult thenInternal( + @NonNull final Dispatcher dispatcher, + @Nullable final OnValueListener valueListener, + @Nullable final OnExceptionListener exceptionListener) { + if (valueListener == null && exceptionListener == null) { + throw new IllegalArgumentException("At least one listener should be non-null"); + } + + final GeckoResult result = new GeckoResult(); + result.mParent = this; + thenInternal( + dispatcher, + () -> { + try { + if (haveValue()) { + result.completeFrom(valueListener != null ? valueListener.onValue(mValue) : null); + } else if (!haveError()) { + // Listener called without completion? + throw new AssertionError(); + } else if (exceptionListener != null) { + result.completeFrom(exceptionListener.onException(mError)); + } else { + result.mIsUncaughtError = mIsUncaughtError; + result.completeExceptionally(mError); + } + } catch (final Throwable e) { + if (!result.mComplete) { + result.mIsUncaughtError = true; + result.completeExceptionally(e); + } else if (e instanceof RuntimeException) { + // This should only be UncaughtException, but we rethrow all RuntimeExceptions + // to avoid squelching logic errors in GeckoResult itself. + throw (RuntimeException) e; + } + } + }); + return result; + } + + private synchronized void thenInternal( + @NonNull final Dispatcher dispatcher, @NonNull final Runnable listener) { + if (mComplete) { + dispatcher.dispatch(listener); + } else { + if (!mListeners.containsKey(dispatcher)) { + mListeners.put(dispatcher, new ArrayList<>(1)); + } + mListeners.get(dispatcher).add(listener); + } + } + + @WrapForJNI + private void nativeThen( + @NonNull final GeckoCallback accept, @NonNull final GeckoCallback reject) { + // NB: We could use the lambda syntax here, but given all the layers + // of abstraction it's helpful to see the types written explicitly. + thenInternal( + DirectDispatcher.sInstance, + new OnValueListener() { + @Override + public GeckoResult onValue(final T value) { + accept.call(value); + return null; + } + }, + new OnExceptionListener() { + @Override + public GeckoResult 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 withHandler(final @Nullable Handler handler) { + final GeckoResult 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 + * null. + * + * @param pending the input {@link GeckoResult}s. + * @param 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 GeckoResult> allOf(final @NonNull GeckoResult... 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 + * null. + * + * @param pending the input {@link GeckoResult}s. + * @param 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 GeckoResult> allOf(final @Nullable List> pending) { + if (pending == null) { + return GeckoResult.fromValue(null); + } + + return new AllOfResult<>(pending); + } + + private static class AllOfResult extends GeckoResult> { + private boolean mFailed = false; + private int mResultCount = 0; + private final List mAccumulator; + private final List> mPending; + + public AllOfResult(final @NonNull List> 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> it = pending.listIterator(); + while (it.hasNext()) { + final int index = it.nextIndex(); + it.next().accept(value -> onResult(value, index), this::onError); + } + } + + private void onResult(final V value, final int index) { + if (mFailed) { + // Some other element in the list already failed, nothing to do here + return; + } + + mResultCount++; + mAccumulator.set(index, value); + + if (mResultCount == mPending.size()) { + complete(mAccumulator); + } + } + + private void onError(final Throwable error) { + mFailed = true; + completeExceptionally(error); + } + } + + private void dispatchLocked() { + if (!mComplete) { + throw new IllegalStateException("Cannot dispatch unless result is complete"); + } + + if (mListeners.isEmpty()) { + if (mIsUncaughtError) { + // We have no listeners to forward the uncaught exception to; + // rethrow the exception to make it visible. + throw new UncaughtException(mError); + } + return; + } + + if (mDispatcher == null) { + throw new AssertionError("Shouldn't have listeners with null dispatcher"); + } + + for (int i = 0; i < mListeners.size(); ++i) { + final Dispatcher dispatcher = mListeners.keyAt(i); + final ArrayList jobs = mListeners.valueAt(i); + dispatcher.dispatch( + () -> { + for (final Runnable job : jobs) { + job.run(); + } + }); + } + mListeners.clear(); + } + + /** + * Completes this result based on another result. + * + * @param other The result that this result should mirror + */ + public void completeFrom(final @Nullable GeckoResult 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. + * + *

    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 (final InterruptedException e) { + } + + remaining = timeoutMillis - (SystemClock.uptimeMillis() - start); + } + + if (!mComplete) { + throw new TimeoutException(); + } + + if (haveError()) { + throw mError; + } + + return mValue; + } + + /** + * Complete the result with the specified value. IllegalStateException is thrown if the result is + * already complete. + * + * @param value The value used to complete the result. + * @throws IllegalStateException If the result is already completed. + */ + @WrapForJNI + public synchronized void complete(final @Nullable T value) { + if (mComplete) { + throw new IllegalStateException("result is already complete"); + } + + mValue = value; + mComplete = true; + + dispatchLocked(); + notifyAll(); + } + + /** + * Complete the result with the specified {@link Throwable}. IllegalStateException is thrown if + * the result is already complete. + * + * @param exception The {@link Throwable} used to complete the result. + * @throws IllegalStateException If the result is already completed. + */ + @WrapForJNI + public synchronized void completeExceptionally(@NonNull final Throwable exception) { + if (mComplete) { + throw new IllegalStateException("result is already complete"); + } + + if (exception == null) { + throw new IllegalArgumentException("Throwable must not be null"); + } + + mError = exception; + mComplete = true; + + dispatchLocked(); + notifyAll(); + } + + /** + * An interface used to deliver values to listeners of a {@link GeckoResult} + * + * @param Type of the value delivered via {@link #onValue(Object)} + * @param Type of the value for the result returned from {@link #onValue(Object)} + */ + public interface OnValueListener { + /** + * 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 onValue(@Nullable T value) throws Throwable; + } + + /** + * An interface used to map {@link GeckoResult} values. + * + * @param Type of the value delivered via {@link #onValue} + * @param Type of the new value returned by {@link #onValue} + */ + public interface OnValueMapper { + /** + * 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 Type of the vale for the result returned from {@link #onException(Throwable)} + */ + public interface OnExceptionListener { + /** + * 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 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 cancel() { + if (haveValue() || haveError()) { + return GeckoResult.fromValue(false); + } + + if (mCancellationDelegate != null) { + return mCancellationDelegate + .cancel() + .then( + value -> { + if (value) { + try { + this.completeExceptionally(new CancellationException()); + } catch (final IllegalStateException e) { + // Can't really do anything about this. + } + } + return GeckoResult.fromValue(value); + }); + } + + if (mParent != null) { + return mParent.cancel(); + } + + return GeckoResult.fromValue(false); + } + + /** + * Sets the instance of {@link CancellationDelegate} that will be invoked by {@link #cancel()}. + * + * @param delegate an instance of CancellationDelegate. + */ + public void setCancellationDelegate(final @Nullable CancellationDelegate delegate) { + mCancellationDelegate = delegate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java new file mode 100644 index 0000000000..890d78c8b1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java @@ -0,0 +1,1054 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.Process; +import android.provider.Settings; +import android.text.format.DateFormat; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; +import androidx.annotation.UiThread; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.OnLifecycleEvent; +import androidx.lifecycle.ProcessLifecycleOwner; +import java.io.File; +import java.io.FileNotFoundException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoNetworkManager; +import org.mozilla.gecko.GeckoScreenChangeListener; +import org.mozilla.gecko.GeckoScreenOrientation; +import org.mozilla.gecko.GeckoScreenOrientation.ScreenOrientation; +import org.mozilla.gecko.GeckoSystemStateListener; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.process.MemoryController; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.DebugConfig; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public final class GeckoRuntime implements Parcelable { + private static final String LOGTAG = "GeckoRuntime"; + private static final boolean DEBUG = false; + + private static final String CONFIG_FILE_PATH_TEMPLATE = + "/data/local/tmp/%s-geckoview-config.yaml"; + + /** + * Intent action sent to the crash handler when a crash is encountered. + * + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + */ + public static final String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a String with the + * path to a Breakpad minidump file containing information about the crash. Several crash + * reporters are able to ingest this in a crash report, including Sentry and Mozilla's Socorro.
    + *
    + * 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 + * + *

    Key=Value
    + * + * Be aware, it may contain sensitive data such as the URI that was loaded at the time of the + * crash. + */ + public static final String EXTRA_EXTRAS_PATH = "extrasPath"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String matching + * one of the `CRASHED_PROCESS_TYPE_*` constants, describing what type of process the crash + * occurred in. + * + * @see GeckoSession.ContentDelegate#onCrash(GeckoSession) + */ + public static final String EXTRA_CRASH_PROCESS_TYPE = "processType"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating the main application process was + * affected by the crash, which is therefore fatal. + */ + public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a foreground child process, such as a + * content process, crashed. The application may be able to recover from this crash, but it was + * likely noticable to the user. + */ + public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a background child process crashed. This + * should have been recovered from automatically, and will have had minimal impact to the user, if + * any. + */ + public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD"; + + private final MemoryController mMemoryController = new MemoryController(); + + @Retention(RetentionPolicy.SOURCE) + @StringDef( + value = { + CRASHED_PROCESS_TYPE_MAIN, + CRASHED_PROCESS_TYPE_FOREGROUND_CHILD, + CRASHED_PROCESS_TYPE_BACKGROUND_CHILD + }) + public @interface CrashedProcessType {} + + private final class LifecycleListener implements LifecycleObserver { + private boolean mPaused = false; + + public LifecycleListener() {} + + @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) + void onCreate() { + Log.d(LOGTAG, "Lifecycle: onCreate"); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + void onStart() { + Log.d(LOGTAG, "Lifecycle: onStart"); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + void onResume() { + Log.d(LOGTAG, "Lifecycle: onResume"); + if (mPaused) { + // Do not trigger the first onResume event because it breaks nsAppShell::sPauseCount counter + // thresholds. + GeckoThread.onResume(); + } + mPaused = false; + // Can resume location services, checks if was in use before going to background + GeckoAppShell.resumeLocation(); + // Monitor network status and send change notifications to Gecko + // while active. + GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext()); + + // Set settings that may have changed between last app opening + GeckoAppShell.setIs24HourFormat( + DateFormat.is24HourFormat(GeckoAppShell.getApplicationContext())); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + void onPause() { + Log.d(LOGTAG, "Lifecycle: onPause"); + mPaused = true; + // Pause listening for locations when in background + GeckoAppShell.pauseLocation(); + // Stop monitoring network status while inactive. + GeckoNetworkManager.getInstance().stop(); + GeckoThread.onPause(); + } + } + + private static GeckoRuntime sDefaultRuntime; + + /** + * Get the default runtime for the given context. This will create and initialize the runtime with + * the default settings. + * + *

    Note: Only use this for session-less apps. For regular apps, use create() instead. + * + * @param context An application context for the default runtime. + * @return The (static) default runtime for the context. + */ + @UiThread + public static synchronized @NonNull GeckoRuntime getDefault(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + if (DEBUG) { + Log.d(LOGTAG, "getDefault"); + } + if (sDefaultRuntime == null) { + sDefaultRuntime = new GeckoRuntime(); + sDefaultRuntime.attachTo(context); + sDefaultRuntime.init(context, new GeckoRuntimeSettings()); + } + + return sDefaultRuntime; + } + + private static GeckoRuntime sRuntime; + private GeckoRuntimeSettings mSettings; + private Delegate mDelegate; + private ServiceWorkerDelegate mServiceWorkerDelegate; + private WebNotificationDelegate mNotificationDelegate; + private ActivityDelegate mActivityDelegate; + private OrientationController mOrientationController; + private StorageController mStorageController; + private final WebExtensionController mWebExtensionController; + private WebPushController mPushController; + private final ContentBlockingController mContentBlockingController; + private final Autocomplete.StorageProxy mAutocompleteStorageProxy; + private final ProfilerController mProfilerController; + private final GeckoScreenChangeListener mScreenChangeListener; + + private GeckoRuntime() { + mWebExtensionController = new WebExtensionController(this); + mContentBlockingController = new ContentBlockingController(); + mAutocompleteStorageProxy = new Autocomplete.StorageProxy(); + mProfilerController = new ProfilerController(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + mScreenChangeListener = new GeckoScreenChangeListener(); + } else { + mScreenChangeListener = null; + } + + if (sRuntime != null) { + throw new IllegalStateException("Only one GeckoRuntime instance is allowed"); + } + sRuntime = this; + } + + @WrapForJNI + @UiThread + /* package */ @Nullable + static GeckoRuntime getInstance() { + return sRuntime; + } + + /** + * Called by mozilla::dom::ClientOpenWindow to retrieve the window id to use for a + * ServiceWorkerClients.openWindow() request. + * + * @param url validated Url being requested to be opened in a new window. + * @return SessionID to use for the request. + */ + @SuppressLint("WrongThread") // for .isOpen() which is called on the UI thread + @WrapForJNI(calledFrom = "gecko") + private static @NonNull GeckoResult serviceWorkerOpenWindow(final @NonNull String url) { + if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) { + final GeckoResult result = new GeckoResult<>(); + // perform the onOpenWindow call in the UI thread + ThreadUtils.runOnUiThread( + () -> { + sRuntime + .mServiceWorkerDelegate + .onOpenWindow(url) + .accept( + session -> { + if (session != null) { + if (!session.isOpen()) { + session.open(sRuntime); + } + result.complete(session.getId()); + } else { + result.complete(null); + } + }); + }); + return result; + } else { + return GeckoResult.fromException( + new java.lang.RuntimeException("No available Service Worker delegate.")); + } + } + + /** + * Attach the runtime to the given context. + * + * @param context The new context to attach to. + */ + @UiThread + public void attachTo(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + if (DEBUG) { + Log.d(LOGTAG, "attachTo " + context.getApplicationContext()); + } + final Context appContext = context.getApplicationContext(); + if (!appContext.equals(GeckoAppShell.getApplicationContext())) { + GeckoAppShell.setApplicationContext(appContext); + } + } + + private final BundleEventListener mEventListener = + new BundleEventListener() { + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + final Class crashHandler = GeckoRuntime.this.getSettings().mCrashHandler; + + if ("Gecko:Exited".equals(event) && mDelegate != null) { + mDelegate.onShutdown(); + EventDispatcher.getInstance() + .unregisterUiThreadListener(mEventListener, "Gecko:Exited"); + } else if ("GeckoView:Test:NewTab".equals(event)) { + final String url = message.getString("url", "about:blank"); + serviceWorkerOpenWindow(url) + .then( + (GeckoResult.OnValueListener) + value -> { + callback.sendSuccess(value); + return null; + }) + .exceptionally( + (GeckoResult.OnExceptionListener) + error -> { + callback.sendError(error + " Could not open tab."); + return null; + }); + } else if ("GeckoView:ChildCrashReport".equals(event) && crashHandler != null) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent i = new Intent(ACTION_CRASHED, null, context, crashHandler); + i.putExtra(EXTRA_MINIDUMP_PATH, message.getString(EXTRA_MINIDUMP_PATH)); + i.putExtra(EXTRA_EXTRAS_PATH, message.getString(EXTRA_EXTRAS_PATH)); + i.putExtra(EXTRA_CRASH_PROCESS_TYPE, message.getString(EXTRA_CRASH_PROCESS_TYPE)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); + } + } + } + }; + + private static String getProcessName(final Context context) { + final ActivityManager manager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final List infos = manager.getRunningAppProcesses(); + if (infos == null) { + return null; + } + for (final ActivityManager.RunningAppProcessInfo info : infos) { + if (info.pid == Process.myPid()) { + return info.processName; + } + } + + return null; + } + + /* package */ boolean init( + final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) { + if (DEBUG) { + Log.d(LOGTAG, "init"); + } + int flags = GeckoThread.FLAG_PRELOAD_CHILD; + + if (settings.getPauseForDebuggerEnabled()) { + flags |= GeckoThread.FLAG_DEBUGGING; + } + + final Class crashHandler = settings.getCrashHandler(); + if (crashHandler != null) { + try { + final ServiceInfo info = + context.getPackageManager().getServiceInfo(new ComponentName(context, crashHandler), 0); + if (info.processName.equals(getProcessName(context))) { + throw new IllegalArgumentException( + "Crash handler service must run in a separate process"); + } + + EventDispatcher.getInstance() + .registerUiThreadListener(mEventListener, "GeckoView:ChildCrashReport"); + + flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER; + } catch (final PackageManager.NameNotFoundException e) { + throw new IllegalArgumentException("Crash handler must be registered as a service"); + } + } + + GeckoAppShell.useMaxScreenDepth(settings.getUseMaxScreenDepth()); + GeckoAppShell.setDisplayDensityOverride(settings.getDisplayDensityOverride()); + GeckoAppShell.setDisplayDpiOverride(settings.getDisplayDpiOverride()); + GeckoAppShell.setScreenSizeOverride(settings.getScreenSizeOverride()); + GeckoAppShell.setCrashHandlerService(settings.getCrashHandler()); + GeckoFontScaleListener.getInstance().attachToContext(context, settings); + + Bundle extras = settings.getExtras(); + String[] args = settings.getArguments(); + Map prefs = settings.getPrefsMap(); + + // Older versions have problems with SnakeYaml + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String configFilePath = settings.getConfigFilePath(); + if (configFilePath == null) { + // Default to /data/local/tmp/$PACKAGE-geckoview-config.yaml if android:debuggable="true" + // or if this application is the current Android "debug_app", and to not read configuration + // from a file otherwise. + if (isApplicationDebuggable(context) || isApplicationCurrentDebugApp(context)) { + configFilePath = + String.format(CONFIG_FILE_PATH_TEMPLATE, context.getApplicationInfo().packageName); + } + } + + if (configFilePath != null && !configFilePath.isEmpty()) { + try { + final DebugConfig debugConfig = DebugConfig.fromFile(new File(configFilePath)); + Log.i(LOGTAG, "Adding debug configuration from: " + configFilePath); + prefs = debugConfig.mergeIntoPrefs(prefs); + args = debugConfig.mergeIntoArgs(args); + extras = debugConfig.mergeIntoExtras(extras); + } catch (final DebugConfig.ConfigException e) { + Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e); + } catch (final FileNotFoundException e) { + } + } + } + + final GeckoThread.InitInfo info = + GeckoThread.InitInfo.builder() + .args(args) + .extras(extras) + .flags(flags) + .prefs(prefs) + .outFilePath(extras != null ? extras.getString("out_file") : null) + .build(); + + if (info.xpcshell + && !"org.mozilla.geckoview.test_runner" + .equals(context.getApplicationContext().getPackageName())) { + throw new IllegalArgumentException("Only the test app can run -xpcshell."); + } + + if (info.xpcshell) { + // Xpcshell tests need multi-e10s to work properly + settings.setProcessCount(BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_COUNT); + } + + if (!GeckoThread.init(info)) { + Log.w(LOGTAG, "init failed (could not initiate GeckoThread)"); + return false; + } + + if (!GeckoThread.launch()) { + Log.w(LOGTAG, "init failed (GeckoThread already launched)"); + return false; + } + + mSettings = settings; + + // Bug 1453062 -- the EventDispatcher should really live here (or in GeckoThread) + EventDispatcher.getInstance() + .registerUiThreadListener(mEventListener, "Gecko:Exited", "GeckoView:Test:NewTab"); + + // Attach and commit settings. + mSettings.attachTo(this); + + // Initialize the system ClipboardManager by accessing it on the main thread. + GeckoAppShell.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE); + + // Add process lifecycle listener to react to backgrounding events. + ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleListener()); + + // Add Display Manager listener to listen screen orientation change. + if (mScreenChangeListener != null) { + mScreenChangeListener.enable(); + } + + mProfilerController.addMarker( + "GeckoView Initialization START", mProfilerController.getProfilerTime()); + return true; + } + + private boolean isApplicationDebuggable(final @NonNull Context context) { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + return (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + private boolean isApplicationCurrentDebugApp(final @NonNull Context context) { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + + final String currentDebugApp; + if (Build.VERSION.SDK_INT >= 17) { + currentDebugApp = + Settings.Global.getString(context.getContentResolver(), Settings.Global.DEBUG_APP); + } else { + currentDebugApp = + Settings.System.getString(context.getContentResolver(), Settings.System.DEBUG_APP); + } + return applicationInfo.packageName.equals(currentDebugApp); + } + + /* package */ void setDefaultPrefs(final GeckoBundle prefs) { + EventDispatcher.getInstance().dispatch("GeckoView:SetDefaultPrefs", prefs); + } + + /** + * Create a new runtime with default settings and attach it to the given context. + * + *

    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"); + } + + context.registerComponentCallbacks(runtime.mMemoryController); + + return runtime; + } + + /** Shutdown the runtime. This will invalidate all attached sessions. */ + @AnyThread + public void shutdown() { + if (DEBUG) { + Log.d(LOGTAG, "shutdown"); + } + + GeckoSystemStateListener.getInstance().shutdown(); + + if (mScreenChangeListener != null) { + mScreenChangeListener.disable(); + } + + GeckoThread.forceQuit(); + } + + public interface Delegate { + /** + * This is called when the runtime shuts down. Any GeckoSession instances that were opened with + * this instance are now considered closed. + */ + @UiThread + void onShutdown(); + } + + /** + * Set a delegate for receiving callbacks relevant to to this GeckoRuntime. + * + * @param delegate an implementation of {@link GeckoRuntime.Delegate}. + */ + @UiThread + public void setDelegate(final @Nullable Delegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Returns the current delegate, if any. + * + * @return an instance of {@link GeckoRuntime.Delegate} or null if no delegate has been set. + */ + @UiThread + public @Nullable Delegate getDelegate() { + return mDelegate; + } + + /** + * Set the {@link Autocomplete.StorageDelegate} instance on this runtime. This delegate is + * required for handling autocomplete storage requests. + * + * @param delegate The {@link Autocomplete.StorageDelegate} handling autocomplete storage + * requests. + */ + @UiThread + public void setAutocompleteStorageDelegate( + final @Nullable Autocomplete.StorageDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mAutocompleteStorageProxy.setDelegate(delegate); + } + + /** + * Get the {@link Autocomplete.StorageDelegate} instance set on this runtime. + * + * @return The {@link Autocomplete.StorageDelegate} set on this runtime. + */ + @UiThread + public @Nullable Autocomplete.StorageDelegate getAutocompleteStorageDelegate() { + ThreadUtils.assertOnUiThread(); + return mAutocompleteStorageProxy.getDelegate(); + } + + @UiThread + public interface ServiceWorkerDelegate { + + /** + * This is called when a service worker tries to open a new window using client.openWindow() The + * GeckoView application should provide an open {@link GeckoSession} to open the url. + * + * @param url Url which the Service Worker wishes to open in a new window. + * @return New or existing open {@link GeckoSession} in which to open the requested url. + * @see Service + * Worker API + * @see openWindow() + */ + @UiThread + @NonNull + GeckoResult onOpenWindow(@NonNull String url); + } + + /** + * Sets the {@link ServiceWorkerDelegate} to be used for Service Worker requests. + * + * @param serviceWorkerDelegate An instance of {@link ServiceWorkerDelegate}. + * @see Service + * Worker API + */ + @UiThread + public void setServiceWorkerDelegate( + final @Nullable ServiceWorkerDelegate serviceWorkerDelegate) { + mServiceWorkerDelegate = serviceWorkerDelegate; + } + + /** + * Gets the {@link ServiceWorkerDelegate} to be used for Service Worker requests. + * + * @return the {@link ServiceWorkerDelegate} instance set by {@link #setServiceWorkerDelegate} + */ + @UiThread + @Nullable + public ServiceWorkerDelegate getServiceWorkerDelegate() { + return mServiceWorkerDelegate; + } + + /** + * Sets the delegate to be used for handling Web Notifications. + * + * @param delegate An instance of {@link WebNotificationDelegate}. + * @see Web + * Notifications + */ + @UiThread + public void setWebNotificationDelegate(final @Nullable WebNotificationDelegate delegate) { + mNotificationDelegate = delegate; + } + + @WrapForJNI + /* package */ float textScaleFactor() { + return getSettings().getFontSizeFactor(); + } + + @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 Web + * Authentication API + */ + 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 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 startActivityForResult(final @NonNull PendingIntent intent) { + if (!ThreadUtils.isOnUiThread()) { + // Delegates expect to be called on the UI thread. + final GeckoResult result = new GeckoResult<>(); + + ThreadUtils.runOnUiThread( + () -> { + final GeckoResult 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 result = mActivityDelegate.onStartActivityForResult(intent); + if (result == null) { + result = GeckoResult.fromException(new IllegalStateException("No result")); + } + + return result; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull GeckoRuntimeSettings getSettings() { + return mSettings; + } + + /** Notify Gecko that the screen orientation has changed. */ + @UiThread + public void orientationChanged() { + ThreadUtils.assertOnUiThread(); + GeckoScreenOrientation.getInstance().update(); + } + + /** + * Notify Gecko that the device configuration has changed. + * + * @param newConfig The new Configuration object, {@link android.content.res.Configuration}. + */ + @UiThread + public void configurationChanged(final @NonNull Configuration newConfig) { + ThreadUtils.assertOnUiThread(); + GeckoSystemStateListener.getInstance().updateNightMode(newConfig.uiMode); + } + + /** + * Notify Gecko that the screen orientation has changed. + * + * @param newOrientation The new screen orientation, as retrieved e.g. from the current {@link + * android.content.res.Configuration}. + */ + @UiThread + public void orientationChanged(final int newOrientation) { + ThreadUtils.assertOnUiThread(); + GeckoScreenOrientation.getInstance().update(newOrientation); + } + + /** + * Get the orientation controller for this runtime. The orientation controller can be used to + * manage changes to and locking of the screen orientation. + * + * @return The {@link OrientationController} for this instance. + */ + @UiThread + public @NonNull OrientationController getOrientationController() { + ThreadUtils.assertOnUiThread(); + + if (mOrientationController == null) { + mOrientationController = new OrientationController(); + } + return mOrientationController; + } + + /** + * Converts GeckoScreenOrientation to ActivityInfo orientation + * + * @return A {@link ActivityInfo} orientation. + */ + @AnyThread + private int toAndroidOrientation(final int geckoOrientation) { + if (geckoOrientation == ScreenOrientation.PORTRAIT_PRIMARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.PORTRAIT_SECONDARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_PRIMARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_SECONDARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.DEFAULT.value) { + return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + } else if (geckoOrientation == ScreenOrientation.PORTRAIT.value) { + return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE.value) { + return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.ANY.value) { + return ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } + + /** + * Lock screen orientation using OrientationController's onOrientationLock. + * + * @return A {@link GeckoResult} that resolves an orientation lock. + */ + @WrapForJNI(calledFrom = "gecko") + private @NonNull GeckoResult lockScreenOrientation(final int aOrientation) { + final GeckoResult res = new GeckoResult<>(); + ThreadUtils.runOnUiThread( + () -> { + final OrientationController.OrientationDelegate delegate = + getOrientationController().getDelegate(); + if (delegate == null) { + // Delegate is not set + res.completeExceptionally(new Exception("Not supported")); + return; + } + final GeckoResult response = + delegate.onOrientationLock(toAndroidOrientation(aOrientation)); + if (response == null) { + // Delegate is default. So lock orientation is not implemented + res.completeExceptionally(new Exception("Not supported")); + return; + } + res.completeFrom(response.map(v -> v == AllowOrDeny.ALLOW)); + }); + return res; + } + + /** Unlock screen orientation using OrientationController's onOrientationUnlock. */ + @WrapForJNI(calledFrom = "gecko") + private void unlockScreenOrientation() { + ThreadUtils.runOnUiThread( + () -> { + final OrientationController.OrientationDelegate delegate = + getOrientationController().getDelegate(); + if (delegate != null) { + delegate.onOrientationUnlock(); + } + }); + } + + /** + * Get the storage controller for this runtime. The storage controller can be used to manage + * persistent storage data accumulated by {@link GeckoSession}. + * + * @return The {@link StorageController} for this instance. + */ + @UiThread + public @NonNull StorageController getStorageController() { + ThreadUtils.assertOnUiThread(); + + if (mStorageController == null) { + mStorageController = new StorageController(); + } + return mStorageController; + } + + /** + * Get the Web Push controller for this runtime. The Web Push controller can be used to allow + * content to use the Web Push API. + * + * @return The {@link WebPushController} for this instance. + */ + @UiThread + public @NonNull WebPushController getWebPushController() { + ThreadUtils.assertOnUiThread(); + + if (mPushController == null) { + mPushController = new WebPushController(); + } + + return mPushController; + } + + /** + * Appends notes to crash report. + * + * @param notes The application notes to append to the crash report. + */ + @AnyThread + public void appendAppNotesToCrashReport(@NonNull final String notes) { + final String notesWithNewLine = notes + "\n"; + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + GeckoAppShell.nativeAppendAppNotesToCrashReport(notesWithNewLine); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + GeckoAppShell.class, + "nativeAppendAppNotesToCrashReport", + String.class, + notesWithNewLine); + } + // This function already adds a newline + GeckoAppShell.appendAppNotesToCrashReport(notes); + } + + @Override // Parcelable + @AnyThread + public int describeContents() { + return 0; + } + + @Override // Parcelable + @AnyThread + public void writeToParcel(final Parcel out, final int flags) { + out.writeParcelable(mSettings, flags); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + mSettings = source.readParcelable(getClass().getClassLoader()); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @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..b74d7476e1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java @@ -0,0 +1,1314 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import static android.os.Build.VERSION; + +import android.app.Service; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.LocaleList; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.LinkedHashMap; +import java.util.Locale; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoSystemStateListener; +import org.mozilla.gecko.util.GeckoBundle; + +@AnyThread +public final class GeckoRuntimeSettings extends RuntimeSettings { + private static final String LOGTAG = "GeckoRuntimeSettings"; + + /** Settings builder used to construct the settings object. */ + @AnyThread + public static final class Builder extends RuntimeSettings.Builder { + @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 {@link VERSION#SDK_INT} > 21, on + * older devices this will be silently ignored. + * + * @param configFilePath Configuration file path to read from, or null to use + * default location /data/local/tmp/$PACKAGE-geckoview-config.yaml. + * @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. + * + *

    The default factor is 1.0. + * + *

    This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic + * font size adjustment} has already been enabled. + * + * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 + * disables both this feature and {@link Builder#fontInflation font inflation}. + * @return The builder instance. + */ + public @NonNull Builder fontSizeFactor(final float fontSizeFactor) { + getSettings().setFontSizeFactor(fontSizeFactor); + return this; + } + + /** + * Enable the Enterprise Roots feature. + * + *

    When Enabled, GeckoView will fetch the third-party root certificates added to the Android + * OS CA store and will use them internally. + * + * @param enabled whether to enable this feature or not + * @return The builder instance + */ + public @NonNull Builder enterpriseRootsEnabled(final boolean enabled) { + getSettings().setEnterpriseRootsEnabled(enabled); + return this; + } + + /** + * Set whether or not font inflation for non mobile-friendly pages should be enabled. The + * default value of this setting is false. + * + *

    When enabled, font sizes will be increased on all pages that are lacking a <meta> + * 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. + * + *

    The magnitude of font inflation applied depends on the {@link Builder#fontSizeFactor font + * size factor} currently in use. + * + *

    This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic + * font size adjustment} has already been enabled. + * + * @param enabled A flag determining whether or not font inflation should be enabled. + * @return The builder instance. + */ + public @NonNull Builder fontInflation(final boolean enabled) { + getSettings().setFontInflationEnabled(enabled); + return this; + } + + /** + * Set the display density override. + * + * @param density The display density value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder displayDensityOverride(final float density) { + getSettings().mDisplayDensityOverride = density; + return this; + } + + /** + * Set the display DPI override. + * + * @param dpi The display DPI value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder displayDpiOverride(final int dpi) { + getSettings().mDisplayDpiOverride = dpi; + return this; + } + + /** + * Set the screen size override. + * + * @param width The screen width value to use for overriding the system default. + * @param height The screen height value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder screenSizeOverride(final int width, final int height) { + getSettings().mScreenWidthOverride = width; + getSettings().mScreenHeightOverride = height; + return this; + } + + /** + * Set whether login forms should be filled automatically if only one viable candidate is + * provided via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}. + * + * @param enabled A flag determining whether login autofill should be enabled. + * @return The builder instance. + */ + public @NonNull Builder loginAutofillEnabled(final boolean enabled) { + getSettings().setLoginAutofillEnabled(enabled); + return this; + } + + /** + * When set, the specified {@link android.app.Service} will be started by an {@link + * android.content.Intent} with action {@link GeckoRuntime#ACTION_CRASHED} when a crash is + * encountered. Crash details can be found in the Intent extras, such as {@link + * GeckoRuntime#EXTRA_MINIDUMP_PATH}.
    + *
    + * 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.
    + *
    + * In practice, you have one of three options once the crash handler is started: + * + *

      + *
    • Call {@link android.app.Service#startForeground(int, android.app.Notification)}. You + * can then take as much time as necessary to report the crash. + *
    • 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. + *
    • Schedule work via {@link android.app.job.JobScheduler}. This will allow you to do + * substantial work in the background without execution limits. + *
    + * + *
    + * 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 privacy + * policy for information on how this data will be handled. + * + * @param handler The class for the crash handler Service. + * @return This builder instance. + * @see Android + * Background Execution Limits + * @see GeckoRuntime#ACTION_CRASHED + */ + public @NonNull Builder crashHandler(final @Nullable Class 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 user-scalable=no is set + * on the viewport. + * + * @param flag True if force user scalable zooming should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder forceUserScalableEnabled(final boolean flag) { + getSettings().mForceUserScalable.set(flag); + return this; + } + + /** + * Sets whether and where insecure (non-HTTPS) connections are allowed. + * + * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + * @return This Builder instance. + */ + public @NonNull Builder allowInsecureConnections(final @HttpsOnlyMode int level) { + getSettings().setAllowInsecureConnections(level); + return this; + } + } + + private GeckoRuntime mRuntime; + /* package */ String[] mArgs; + /* package */ Bundle mExtras; + /* package */ String mConfigFilePath; + + /* package */ ContentBlocking.Settings mContentBlocking; + + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull ContentBlocking.Settings getContentBlocking() { + return mContentBlocking; + } + + /* package */ final Pref mWebManifest = new Pref("dom.manifest.enabled", true); + /* package */ final Pref mJavaScript = new Pref("javascript.enabled", true); + /* package */ final Pref mRemoteDebugging = + new Pref("devtools.debugger.remote-enabled", false); + /* package */ final Pref mWebFonts = + new Pref("browser.display.use_document_fonts", 1); + /* package */ final Pref mConsoleOutput = + new Pref("geckoview.console.enabled", false); + /* package */ float mFontSizeFactor = 1f; + /* package */ final Pref mEnterpriseRootsEnabled = + new Pref<>("security.enterprise_roots.enabled", false); + /* package */ final Pref mFontInflationMinTwips = + new Pref<>("font.size.inflation.minTwips", 0); + /* package */ final Pref mInputAutoZoom = new Pref<>("formhelper.autozoom", true); + /* package */ final Pref mDoubleTapZooming = + new Pref<>("apz.allow_double_tap_zooming", true); + /* package */ final Pref mGlMsaaLevel = new Pref<>("webgl.msaa-samples", 4); + /* package */ final Pref mTelemetryEnabled = + new Pref<>("toolkit.telemetry.geckoview.streaming", false); + /* package */ final Pref mGeckoViewLogLevel = new Pref<>("geckoview.logging", "Debug"); + /* package */ final Pref mConsoleServiceToLogcat = + new Pref<>("consoleservice.logcat", true); + /* package */ final Pref mDevToolsConsoleToLogcat = + new Pref<>("devtools.console.stdout.chrome", true); + /* package */ final Pref mAboutConfig = new Pref<>("general.aboutConfig.enable", false); + /* package */ final Pref mForceUserScalable = + new Pref<>("browser.ui.zoom.force-user-scalable", false); + /* package */ final Pref mAutofillLogins = + new Pref("signon.autofillForms", true); + /* package */ final Pref mHttpsOnly = + new Pref("dom.security.https_only_mode", false); + /* package */ final Pref mHttpsOnlyPrivateMode = + new Pref("dom.security.https_only_mode_pbm", false); + /* package */ final Pref mProcessCount = new Pref<>("dom.ipc.processCount", 2); + + /* package */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM; + + /* package */ boolean mForceEnableAccessibility; + /* package */ boolean mDebugPause; + /* package */ boolean mUseMaxScreenDepth; + /* package */ float mDisplayDensityOverride = -1.0f; + /* package */ int mDisplayDpiOverride; + /* package */ int mScreenWidthOverride; + /* package */ int mScreenHeightOverride; + /* package */ Class mCrashHandler; + /* package */ String[] mRequestedLocales; + /* package */ RuntimeTelemetry.Proxy mTelemetryProxy; + + /** + * Attach and commit the settings to the given runtime. + * + * @param runtime The runtime to attach to. + */ + /* package */ void attachTo(final @NonNull GeckoRuntime runtime) { + mRuntime = runtime; + commit(); + + if (mTelemetryProxy != null) { + mTelemetryProxy.attach(); + } + } + + @Override // RuntimeSettings + public @Nullable GeckoRuntime getRuntime() { + return mRuntime; + } + + /* package */ GeckoRuntimeSettings() { + this(null); + } + + /* package */ GeckoRuntimeSettings(final @Nullable GeckoRuntimeSettings settings) { + super(/* parent */ null); + + if (settings == null) { + mArgs = new String[0]; + mExtras = new Bundle(); + mContentBlocking = new ContentBlocking.Settings(this /* parent */, null /* settings */); + return; + } + + updateSettings(settings); + } + + private void updateSettings(final @NonNull GeckoRuntimeSettings settings) { + updatePrefs(settings); + + mArgs = settings.getArguments().clone(); + mExtras = new Bundle(settings.getExtras()); + mContentBlocking = new ContentBlocking.Settings(this /* parent */, settings.mContentBlocking); + + mForceEnableAccessibility = settings.mForceEnableAccessibility; + mDebugPause = settings.mDebugPause; + mUseMaxScreenDepth = settings.mUseMaxScreenDepth; + mDisplayDensityOverride = settings.mDisplayDensityOverride; + mDisplayDpiOverride = settings.mDisplayDpiOverride; + mScreenWidthOverride = settings.mScreenWidthOverride; + mScreenHeightOverride = settings.mScreenHeightOverride; + mCrashHandler = settings.mCrashHandler; + mRequestedLocales = settings.mRequestedLocales; + mConfigFilePath = settings.mConfigFilePath; + mTelemetryProxy = settings.mTelemetryProxy; + } + + /* package */ void commit() { + commitLocales(); + commitResetPrefs(); + } + + /** + * Get the custom Gecko process arguments. + * + * @return The Gecko process arguments. + */ + public @NonNull String[] getArguments() { + return mArgs; + } + + /** + * Get the custom Gecko intent extras. + * + * @return The Gecko intent extras. + */ + public @NonNull Bundle getExtras() { + return mExtras; + } + + /** + * Path to configuration file from which GeckoView will read configuration options such as Gecko + * process arguments, environment variables, and preferences. + * + *

    Note: this feature is only available for {@link VERSION#SDK_INT} > 21. + * + * @return Path to configuration file from which GeckoView will read configuration options, or + * null for default location /data/local/tmp/$PACKAGE-geckoview-config.yaml + * . + */ + public @Nullable String getConfigFilePath() { + return mConfigFilePath; + } + + /** + * Get whether JavaScript support is enabled. + * + * @return Whether JavaScript support is enabled. + */ + public boolean getJavaScriptEnabled() { + return mJavaScript.get(); + } + + /** + * Set whether JavaScript support should be enabled. + * + * @param flag A flag determining whether JavaScript should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setJavaScriptEnabled(final boolean flag) { + mJavaScript.commit(flag); + return this; + } + + /** + * Get whether remote debugging support is enabled. + * + * @return True if remote debugging support is enabled. + */ + public boolean getRemoteDebuggingEnabled() { + return mRemoteDebugging.get(); + } + + /** + * Set whether remote debugging support should be enabled. + * + * @param enabled True if remote debugging should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setRemoteDebuggingEnabled(final boolean enabled) { + mRemoteDebugging.commit(enabled); + return this; + } + + /** + * Get whether web fonts support is enabled. + * + * @return Whether web fonts support is enabled. + */ + public boolean getWebFontsEnabled() { + return mWebFonts.get() != 0 ? true : false; + } + + /** + * Set whether support for web fonts should be enabled. + * + * @param flag A flag determining whether web fonts should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setWebFontsEnabled(final boolean flag) { + mWebFonts.commit(flag ? 1 : 0); + return this; + } + + /** + * Gets whether the pause-for-debugger is enabled or not. + * + * @return True if the pause is enabled. + */ + public boolean getPauseForDebuggerEnabled() { + return mDebugPause; + } + + /** + * Gets whether accessibility is force enabled or not. + * + * @return true if accessibility is force enabled. + */ + public boolean getForceEnableAccessibility() { + return mForceEnableAccessibility; + } + + /** + * Sets whether accessibility is force enabled or not. + * + *

    Useful when testing accessibility. + * + * @param value whether accessibility is force enabled or not + * @return this GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setForceEnableAccessibility(final boolean value) { + mForceEnableAccessibility = value; + SessionAccessibility.setForceEnabled(value); + return this; + } + + /** + * Gets whether the compositor should use the maximum screen depth when rendering. + * + * @return True if the maximum screen depth should be used. + */ + public boolean getUseMaxScreenDepth() { + return mUseMaxScreenDepth; + } + + /** + * Gets the display density override value. + * + * @return Returns a positive number. Will return null if not set. + */ + public @Nullable Float getDisplayDensityOverride() { + if (mDisplayDensityOverride > 0.0f) { + return mDisplayDensityOverride; + } + return null; + } + + /** + * Gets the display DPI override value. + * + * @return Returns a positive number. Will return null if not set. + */ + public @Nullable Integer getDisplayDpiOverride() { + if (mDisplayDpiOverride > 0) { + return mDisplayDpiOverride; + } + return null; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable Class getCrashHandler() { + return mCrashHandler; + } + + /** + * Gets the screen size override value. + * + * @return Returns a Rect containing the dimensions to use for the window size. Will return null + * if not set. + */ + public @Nullable Rect getScreenSizeOverride() { + if ((mScreenWidthOverride > 0) && (mScreenHeightOverride > 0)) { + return new Rect(0, 0, mScreenWidthOverride, mScreenHeightOverride); + } + return null; + } + + /** + * Gets the list of requested locales. + * + * @return A list of locale codes in Gecko format ("en" or "en-US"). + */ + public @Nullable String[] getLocales() { + return mRequestedLocales; + } + + /** + * Set the locale. + * + * @param requestedLocales An ordered list of locales in Gecko format ("en-US"). + */ + public void setLocales(final @Nullable String[] requestedLocales) { + mRequestedLocales = requestedLocales; + commitLocales(); + } + + private void commitLocales() { + final GeckoBundle data = new GeckoBundle(1); + data.putStringArray("requestedLocales", mRequestedLocales); + data.putString("acceptLanguages", computeAcceptLanguages()); + EventDispatcher.getInstance().dispatch("GeckoView:SetLocale", data); + } + + private String computeAcceptLanguages() { + final LinkedHashMap locales = new LinkedHashMap<>(); + + // Explicitly-set app prefs come first: + if (mRequestedLocales != null) { + for (final String locale : mRequestedLocales) { + locales.put(locale.toLowerCase(Locale.ROOT), locale); + } + } + // OS prefs come second: + for (final String locale : getDefaultLocales()) { + final String localeLowerCase = locale.toLowerCase(Locale.ROOT); + if (!locales.containsKey(localeLowerCase)) { + locales.put(localeLowerCase, locale); + } + } + + return TextUtils.join(",", locales.values()); + } + + private static String[] getDefaultLocales() { + if (VERSION.SDK_INT >= 24) { + final LocaleList localeList = LocaleList.getDefault(); + final String[] locales = new String[localeList.size()]; + for (int i = 0; i < localeList.size(); i++) { + locales[i] = localeList.get(i).toLanguageTag(); + } + return locales; + } + final String[] locales = new String[1]; + final Locale locale = Locale.getDefault(); + if (VERSION.SDK_INT >= 21) { + locales[0] = locale.toLanguageTag(); + return locales; + } + + locales[0] = getLanguageTag(locale); + return locales; + } + + private static String getLanguageTag(final Locale locale) { + final StringBuilder out = new StringBuilder(locale.getLanguage()); + final String country = locale.getCountry(); + final String variant = locale.getVariant(); + if (!TextUtils.isEmpty(country)) { + out.append('-').append(country); + } + if (!TextUtils.isEmpty(variant)) { + out.append('-').append(variant); + } + // e.g. "en", "en-US", or "en-US-POSIX". + return out.toString(); + } + + /** + * Sets whether Web Manifest processing support is enabled. + * + * @param enabled A flag determining whether Web Manifest processing support is enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setWebManifestEnabled(final boolean enabled) { + mWebManifest.commit(enabled); + return this; + } + + /** + * Get whether or not Web Manifest processing support is enabled. + * + * @return True if web manifest processing support is enabled. + */ + public boolean getWebManifestEnabled() { + return mWebManifest.get(); + } + + /** + * Set whether or not web console messages should go to logcat. + * + *

    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. + * + *

    The default factor is 1.0. + * + *

    Currently, any changes only take effect after a reload of the session. + * + *

    This setting cannot be modified while {@link + * GeckoRuntimeSettings#setAutomaticFontSizeAdjustment automatic font size adjustment} is enabled. + * + * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 disables + * both this feature and {@link GeckoRuntimeSettings#setFontInflationEnabled font inflation}. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setFontSizeFactor(final float fontSizeFactor) { + if (getAutomaticFontSizeAdjustment()) { + throw new IllegalStateException("Not allowed when automatic font size adjustment is enabled"); + } + return setFontSizeFactorInternal(fontSizeFactor); + } + + /* + * Enable the Enteprise Roots feature. + * + * When Enabled, GeckoView will fetch the third-party root certificates added to the + * Android OS CA store and will use them internally. + * + * @param enabled whether to enable this feature or not + * @return This GeckoRuntimeSettings instance + */ + public @NonNull GeckoRuntimeSettings setEnterpriseRootsEnabled(final boolean enabled) { + mEnterpriseRootsEnabled.commit(enabled); + return this; + } + + /** + * Gets whether the Enteprise Roots feature is enabled or not. + * + * @return true if the feature is enabled, false otherwise. + */ + public boolean getEnterpriseRootsEnabled() { + return mEnterpriseRootsEnabled.get(); + } + + private static final float DEFAULT_FONT_SIZE_FACTOR = 1f; + + private float sanitizeFontSizeFactor(final float fontSizeFactor) { + if (fontSizeFactor < 0) { + if (BuildConfig.DEBUG_BUILD) { + throw new IllegalArgumentException("fontSizeFactor cannot be < 0"); + } else { + Log.e(LOGTAG, "fontSizeFactor cannot be < 0"); + return DEFAULT_FONT_SIZE_FACTOR; + } + } + + return fontSizeFactor; + } + + /* package */ @NonNull + GeckoRuntimeSettings setFontSizeFactorInternal(final float fontSizeFactor) { + final float newFactor = sanitizeFontSizeFactor(fontSizeFactor); + if (mFontSizeFactor == newFactor) { + return this; + } + mFontSizeFactor = newFactor; + if (getFontInflationEnabled()) { + final int scaledFontInflation = Math.round(FONT_INFLATION_BASE_VALUE * newFactor); + mFontInflationMinTwips.commit(scaledFontInflation); + } + GeckoSystemStateListener.onDeviceChanged(); + return this; + } + + /** + * Gets the currently applied font size factor. + * + * @return The currently applied font size factor. + */ + public float getFontSizeFactor() { + return mFontSizeFactor; + } + + /** + * Set whether or not font inflation for non mobile-friendly pages should be enabled. The default + * value of this setting is false. + * + *

    When enabled, font sizes will be increased on all pages that are lacking a <meta> + * 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. + * + *

    The magnitude of font inflation applied depends on the {@link + * GeckoRuntimeSettings#setFontSizeFactor font size factor} currently in use. + * + *

    Currently, any changes only take effect after a reload of the session. + * + * @param enabled A flag determining whether or not font inflation should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setFontInflationEnabled(final boolean enabled) { + final int minTwips = enabled ? Math.round(FONT_INFLATION_BASE_VALUE * getFontSizeFactor()) : 0; + mFontInflationMinTwips.commit(minTwips); + return this; + } + + /** + * Get whether or not font inflation for non mobile-friendly pages is currently enabled. + * + * @return True if font inflation is enabled. + */ + public boolean getFontInflationEnabled() { + return mFontInflationMinTwips.get() > 0; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({COLOR_SCHEME_LIGHT, COLOR_SCHEME_DARK, COLOR_SCHEME_SYSTEM}) + public @interface ColorScheme {} + + /** A light theme for web content is preferred. */ + public static final int COLOR_SCHEME_LIGHT = 0; + + /** A dark theme for web content is preferred. */ + public static final int COLOR_SCHEME_DARK = 1; + + /** The preferred color scheme will be based on system settings. */ + public static final int COLOR_SCHEME_SYSTEM = -1; + + /** + * Gets the preferred color scheme override for web content. + * + * @return One of the {@link GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants. + */ + public @ColorScheme int getPreferredColorScheme() { + return mPreferredColorScheme; + } + + /** + * Sets the preferred color scheme override for web content. + * + * @param scheme The preferred color scheme. Must be one of the {@link + * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setPreferredColorScheme(final @ColorScheme int scheme) { + if (mPreferredColorScheme != scheme) { + mPreferredColorScheme = scheme; + GeckoSystemStateListener.onDeviceChanged(); + } + return this; + } + + /** + * Gets whether auto-zoom to editable fields is enabled. + * + * @return True if auto-zoom is enabled, false otherwise. + */ + public boolean getInputAutoZoomEnabled() { + return mInputAutoZoom.get(); + } + + /** + * Set whether auto-zoom to editable fields should be enabled. + * + * @param flag True if auto-zoom should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setInputAutoZoomEnabled(final boolean flag) { + mInputAutoZoom.commit(flag); + return this; + } + + /** + * Gets whether double-tap zooming is enabled. + * + * @return True if double-tap zooming is enabled, false otherwise. + */ + public boolean getDoubleTapZoomingEnabled() { + return mDoubleTapZooming.get(); + } + + /** + * Sets whether double tap zooming is enabled. + * + * @param flag true if double tap zooming should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setDoubleTapZoomingEnabled(final boolean flag) { + mDoubleTapZooming.commit(flag); + return this; + } + + /** + * Gets the current WebGL MSAA level. + * + * @return number of MSAA samples, 0 if MSAA is disabled. + */ + public int getGlMsaaLevel() { + return mGlMsaaLevel.get(); + } + + /** + * Sets the WebGL MSAA level. + * + * @param level number of MSAA samples, 0 if MSAA should be disabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setGlMsaaLevel(final int level) { + mGlMsaaLevel.commit(level); + return this; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable RuntimeTelemetry.Delegate getTelemetryDelegate() { + return mTelemetryProxy.getDelegate(); + } + + /** + * Gets whether about:config is enabled or not. + * + * @return True if about:config is enabled, false otherwise. + */ + public boolean getAboutConfigEnabled() { + return mAboutConfig.get(); + } + + /** + * Sets whether or not about:config should be enabled. This is a page that allows users to + * directly modify Gecko preferences. Modification of some preferences may cause the app to break + * in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc. + * + * @param flag True if about:config should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setAboutConfigEnabled(final boolean flag) { + mAboutConfig.commit(flag); + return this; + } + + /** + * Gets whether or not force user scalable zooming should be enabled or not. + * + * @return True if force user scalable zooming should be enabled, false otherwise. + */ + public boolean getForceUserScalableEnabled() { + return mForceUserScalable.get(); + } + + /** + * Sets whether or not pinch-zooming should be enabled when user-scalable=no is set + * on the viewport. + * + * @param flag True if force user scalable zooming should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setForceUserScalableEnabled(final boolean flag) { + mForceUserScalable.commit(flag); + return this; + } + + /** + * Get whether login form autofill is enabled. + * + * @return True if login autofill is enabled. + */ + public boolean getLoginAutofillEnabled() { + return mAutofillLogins.get(); + } + + /** + * Set whether login forms should be filled automatically if only one viable candidate is provided + * via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}. + * + * @param enabled A flag determining whether login autofill should be enabled. + * @return The builder instance. + */ + public @NonNull GeckoRuntimeSettings setLoginAutofillEnabled(final boolean enabled) { + mAutofillLogins.commit(enabled); + return this; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ALLOW_ALL, HTTPS_ONLY_PRIVATE, HTTPS_ONLY}) + public @interface HttpsOnlyMode {} + + /** Allow all insecure connections */ + public static final int ALLOW_ALL = 0; + + /** Allow insecure connections in normal browsing, but only HTTPS in private browsing. */ + public static final int HTTPS_ONLY_PRIVATE = 1; + + /** Only allow HTTPS connections. */ + public static final int HTTPS_ONLY = 2; + + /** + * Get whether and where insecure (non-HTTPS) connections are allowed. + * + * @return One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + */ + public @HttpsOnlyMode int getAllowInsecureConnections() { + final boolean httpsOnly = mHttpsOnly.get(); + final boolean httpsOnlyPrivate = mHttpsOnlyPrivateMode.get(); + if (httpsOnly) { + return HTTPS_ONLY; + } else if (httpsOnlyPrivate) { + return HTTPS_ONLY_PRIVATE; + } + return ALLOW_ALL; + } + + /** + * Set whether and where insecure (non-HTTPS) connections are allowed. + * + * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setAllowInsecureConnections(final @HttpsOnlyMode int level) { + switch (level) { + case ALLOW_ALL: + mHttpsOnly.commit(false); + mHttpsOnlyPrivateMode.commit(false); + break; + case HTTPS_ONLY_PRIVATE: + mHttpsOnly.commit(false); + mHttpsOnlyPrivateMode.commit(true); + break; + case HTTPS_ONLY: + mHttpsOnly.commit(true); + mHttpsOnlyPrivateMode.commit(false); + break; + default: + throw new IllegalArgumentException("Invalid setting for setAllowInsecureConnections"); + } + return this; + } + + // For internal use only + /* protected */ @NonNull + GeckoRuntimeSettings setProcessCount(final int processCount) { + mProcessCount.commit(processCount); + return this; + } + + @Override // Parcelable + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + + out.writeStringArray(mArgs); + mExtras.writeToParcel(out, flags); + ParcelableUtils.writeBoolean(out, mForceEnableAccessibility); + ParcelableUtils.writeBoolean(out, mDebugPause); + ParcelableUtils.writeBoolean(out, mUseMaxScreenDepth); + out.writeFloat(mDisplayDensityOverride); + out.writeInt(mDisplayDpiOverride); + out.writeInt(mScreenWidthOverride); + out.writeInt(mScreenHeightOverride); + out.writeString(mCrashHandler != null ? mCrashHandler.getName() : null); + out.writeStringArray(mRequestedLocales); + out.writeString(mConfigFilePath); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + super.readFromParcel(source); + + mArgs = source.createStringArray(); + mExtras.readFromParcel(source); + mForceEnableAccessibility = ParcelableUtils.readBoolean(source); + mDebugPause = ParcelableUtils.readBoolean(source); + mUseMaxScreenDepth = ParcelableUtils.readBoolean(source); + mDisplayDensityOverride = source.readFloat(); + mDisplayDpiOverride = source.readInt(); + mScreenWidthOverride = source.readInt(); + mScreenHeightOverride = source.readInt(); + + final String crashHandlerName = source.readString(); + if (crashHandlerName != null) { + try { + @SuppressWarnings("unchecked") + final Class handler = + (Class) Class.forName(crashHandlerName); + + mCrashHandler = handler; + } catch (final ClassNotFoundException e) { + } + } + + mRequestedLocales = source.createStringArray(); + mConfigFilePath = source.readString(); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @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..6fc9abdc1c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java @@ -0,0 +1,7146 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.IInterface; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewStructure; +import android.view.WindowManager; +import android.view.inputmethod.CursorAnchorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.widget.Magnifier; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; +import androidx.annotation.UiThread; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.AbstractSequentialList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.MagnifiableSurfaceView; +import org.mozilla.gecko.NativeQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo; + +public class GeckoSession { + private static final String LOGTAG = "GeckoSession"; + private static final boolean DEBUG = false; + + // Type of changes given to onWindowChanged. + // Window has been cleared due to the session being closed. + private static final int WINDOW_CLOSE = 0; + // Window has been set due to the session being opened. + private static final int WINDOW_OPEN = 1; // Window has been opened. + // Window has been cleared due to the session being transferred to another session. + private static final int WINDOW_TRANSFER_OUT = 2; // Window has been transfer. + // Window has been set due to another session being transferred to this one. + private static final int WINDOW_TRANSFER_IN = 3; + + private static final int DATA_URI_MAX_LENGTH = 2 * 1024 * 1024; + + // Delay running compositor memory pressure by 10s to avoid interfering with tab switching. + private static final int NOTIFY_MEMORY_PRESSURE_DELAY_MS = 10 * 1000; + + private final Runnable mNotifyMemoryPressure = + new Runnable() { + @Override + public void run() { + if (mCompositorReady) { + mCompositor.notifyMemoryPressure(); + } + } + }; + + private enum State implements NativeQueue.State { + INITIAL(0), + READY(1); + + private final int mRank; + + private State(final int rank) { + mRank = rank; + } + + @Override + public boolean is(final NativeQueue.State other) { + return this == other; + } + + @Override + public boolean isAtLeast(final NativeQueue.State other) { + return (other instanceof State) && mRank >= ((State) other).mRank; + } + } + + private final NativeQueue mNativeQueue = new NativeQueue(State.INITIAL, State.READY); + + private final EventDispatcher mEventDispatcher = new EventDispatcher(mNativeQueue); + + private final SessionTextInput mTextInput = new SessionTextInput(this, mNativeQueue); + private SessionAccessibility mAccessibility; + private SessionFinder mFinder; + private SessionPdfFileSaver mPdfFileSaver; + + /** {@code SessionMagnifier} handles magnifying glass. */ + /* package */ interface SessionMagnifier { + /** + * Get the current {@link android.view.View} for magnifying glass. + * + * @return Current View for magnifying glass or null if not set. + */ + @UiThread + default @Nullable View getView() { + return null; + } + + /** + * Set the current {@link android.view.View} for magnifying glass. + * + * @param view View for magnifying glass or null to clear current View. + */ + @UiThread + default void setView(final @NonNull View view) {} + + /** + * Show magnifying glass. + * + * @param sourceCenter The source center of view that magnifying glass is attached + */ + @UiThread + default void show(final @NonNull PointF sourceCenter) {} + + /** Dismiss magnifying glass. */ + @UiThread + default void dismiss() {} + } + + @TargetApi(Build.VERSION_CODES.P) + private class SessionMagnifierP implements GeckoSession.SessionMagnifier { + private @Nullable View mView; + private @Nullable Magnifier mMagnifier; + private final @NonNull Compositor mCompositor; + + private SessionMagnifierP(final Compositor compositor) { + mCompositor = compositor; + } + + @Override + @UiThread + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + + return mView; + } + + @Override + @UiThread + public void setView(final @NonNull View view) { + ThreadUtils.assertOnUiThread(); + + if (mMagnifier != null) { + mMagnifier.dismiss(); + mMagnifier = null; + } + mView = view; + } + + @Override + @UiThread + public void show(final @NonNull PointF sourceCenter) { + ThreadUtils.assertOnUiThread(); + + if (mView == null) { + return; + } + if (mMagnifier == null) { + mMagnifier = new Magnifier(mView); + } + + if (mView instanceof MagnifiableSurfaceView) { + final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView; + view.setMagnifierSurface(mCompositor.getMagnifiableSurface()); + } + mMagnifier.show(sourceCenter.x, sourceCenter.y); + if (mView instanceof MagnifiableSurfaceView) { + final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView; + view.setMagnifierSurface(null); + } + } + + @Override + @UiThread + public void dismiss() { + ThreadUtils.assertOnUiThread(); + + if (mMagnifier == null) { + return; + } + + mMagnifier.dismiss(); + mMagnifier = null; + } + } + + private SessionMagnifier mMagnifier; + + private String mId; + + /* package */ String getId() { + return mId; + } + + private boolean mShouldPinOnScreen; + + // All fields are accessed on UI thread only. + private PanZoomController mPanZoomController = new PanZoomController(this); + private OverscrollEdgeEffect mOverscroll; + private CompositorController mController; + private Autofill.Support mAutofillSupport; + + private boolean mAttachedCompositor; + private boolean mCompositorReady; + private SurfaceInfo mSurfaceInfo; + private GeckoDisplay.NewSurfaceProvider mNewSurfaceProvider; + + // All fields of coordinates are in screen units. + private int mLeft; + private int mTop; // Top of the surface (including toolbar); + private int mClientTop; // Top of the client area (i.e. excluding toolbar); + private int mWidth; + private int mHeight; // Height of the surface (including toolbar); + private int mClientHeight; // Height of the client area (i.e. excluding toolbar); + private int mFixedBottomOffset = + 0; // The margin for fixed elements attached to the bottom of the viewport. + private int mDynamicToolbarMaxHeight = 0; // The maximum height of the dynamic toolbar + private float mViewportLeft; + private float mViewportTop; + private float mViewportZoom = 1.0f; + + // + // NOTE: These values are also defined in + // gfx/layers/ipc/UiCompositorControllerMessageTypes.h and must be kept in sync. Any + // new AnimatorMessageType added here must also be added there. + // + // Sent from compositor after first paint + /* package */ static final int FIRST_PAINT = 0; + // Sent from compositor when a layer has been updated + /* package */ static final int LAYERS_UPDATED = 1; + // Special message sent from UiCompositorControllerChild once it is open + /* package */ static final int COMPOSITOR_CONTROLLER_OPEN = 2; + // Special message sent from controller to query if the compositor controller is open. + /* package */ static final int IS_COMPOSITOR_CONTROLLER_OPEN = 3; + + /* protected */ class Compositor extends JNIObject { + public boolean isReady() { + return GeckoSession.this.isCompositorReady(); + } + + @WrapForJNI(calledFrom = "ui") + private void onCompositorAttached() { + GeckoSession.this.onCompositorAttached(); + } + + @WrapForJNI(calledFrom = "ui") + private void onCompositorDetached() { + // Clear out any pending calls on the UI thread. + GeckoSession.this.onCompositorDetached(); + } + + @WrapForJNI(dispatchTo = "gecko") + @Override + protected native void disposeNative(); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void attachNPZC(PanZoomController.NativeProvider npzc); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void onBoundsChanged(int left, int top, int width, int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void setDynamicToolbarMaxHeight(int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void notifyMemoryPressure(); + + // Gecko thread pauses compositor; blocks UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void syncPauseCompositor(); + + // UI thread resumes compositor and notifies Gecko thread; does not block UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void syncResumeResizeCompositor( + int x, int y, int width, int height, Object surface, Object surfaceControl); + + // Returns a Surface that content has been rendered in to, which should be used when the + // magnifier is shown. This may differ from the Surface we have passed to + // syncResumeResizeCompositor(). + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native Surface getMagnifiableSurface(); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void setMaxToolbarHeight(int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void setFixedBottomOffset(int offset); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void sendToolbarAnimatorMessage(int message); + + @WrapForJNI(calledFrom = "ui") + private void recvToolbarAnimatorMessage(final int message) { + GeckoSession.this.handleCompositorMessage(message); + } + + @WrapForJNI(calledFrom = "ui") + private void requestNewSurface() { + final GeckoDisplay.NewSurfaceProvider provider = GeckoSession.this.mNewSurfaceProvider; + if (provider != null) { + provider.requestNewSurface(); + } else { + Log.w(LOGTAG, "Cannot request new Surface: No NewSurfaceProvider set."); + } + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void setDefaultClearColor(int color); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + /* package */ native void requestScreenPixels( + final GeckoResult result, + final Bitmap target, + final int x, + final int y, + final int srcWidth, + final int srcHeight, + final int outWidth, + final int outHeight); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void enableLayerUpdateNotifications(boolean enable); + + // The compositor invokes this function just before compositing a frame where the + // document is different from the document composited on the last frame. In these + // cases, the viewport information we have in Java is no longer valid and needs to + // be replaced with the new viewport information provided. + @WrapForJNI(calledFrom = "ui") + private void updateRootFrameMetrics( + final float scrollX, final float scrollY, final float zoom) { + GeckoSession.this.onMetricsChanged(scrollX, scrollY, zoom); + } + + @WrapForJNI(calledFrom = "ui") + private void updateOverscrollVelocity(final float x, final float y) { + GeckoSession.this.updateOverscrollVelocity(x, y); + } + + @WrapForJNI(calledFrom = "ui") + private void updateOverscrollOffset(final float x, final float y) { + GeckoSession.this.updateOverscrollOffset(x, y); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void onSafeAreaInsetsChanged(int top, int right, int bottom, int left); + + @WrapForJNI(calledFrom = "ui") + public void setPointerIcon( + final int defaultCursor, final Bitmap customCursor, final float x, final float y) { + GeckoSession.this.setPointerIcon(defaultCursor, customCursor, x, y); + } + + @Override + protected void finalize() throws Throwable { + disposeNative(); + } + } + + /* package */ final Compositor mCompositor = new Compositor(); + + @WrapForJNI(stubName = "GetCompositor", calledFrom = "ui") + private Object getCompositorFromNative() { + // Only used by native code. + return mCompositorReady ? mCompositor : null; + } + + private final GeckoSessionHandler mHistoryHandler = + new GeckoSessionHandler( + "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 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 result = delegate.getVisited(GeckoSession.this, urls); + + if (result == null) { + callback.sendSuccess(null); + return; + } + + result.accept( + visited -> callback.sendSuccess(visited), + exception -> callback.sendError("Failed to fetch visited statuses for URIs")); + } else if ("GeckoView:StateUpdated".equals(event)) { + + final GeckoBundle update = message.getBundle("data"); + + if (update == null) { + return; + } + final int previousHistorySize = mStateCache.size(); + mStateCache.updateSessionState(update); + + final ProgressDelegate progressDelegate = getProgressDelegate(); + if (progressDelegate != null) { + final SessionState state = new SessionState(mStateCache); + if (!state.isEmpty()) { + progressDelegate.onSessionStateChange(GeckoSession.this, state); + } + } + + if (update.getBundle("historychange") != null) { + final SessionState state = new SessionState(mStateCache); + + delegate.onHistoryStateChange(GeckoSession.this, state); + + // If the previous history was larger than one entry and the new size is one, it means + // the + // History has been purged and the navigation delegate needs to be update. + if ((previousHistorySize > 1) + && (state.size() == 1) + && mNavigationHandler.getDelegate() != null) { + mNavigationHandler.getDelegate().onCanGoForward(GeckoSession.this, false); + mNavigationHandler.getDelegate().onCanGoBack(GeckoSession.this, false); + } + } + } + } + }; + + private final WebExtension.SessionController mWebExtensionController; + + private final GeckoSessionHandler mContentHandler = + new GeckoSessionHandler( + "GeckoViewContent", + this, + new String[] { + "GeckoView:ContentCrash", + "GeckoView:ContentKill", + "GeckoView:ContextMenu", + "GeckoView:DOMMetaViewportFit", + "GeckoView:PageTitleChanged", + "GeckoView:DOMWindowClose", + "GeckoView:ExternalResponse", + "GeckoView:FocusRequest", + "GeckoView:FullScreenEnter", + "GeckoView:FullScreenExit", + "GeckoView:WebAppManifest", + "GeckoView:FirstContentfulPaint", + "GeckoView:PaintStatusReset", + "GeckoView:PreviewImage", + "GeckoView:CookieBannerEvent:Detected", + "GeckoView:CookieBannerEvent:Handled", + "GeckoView:SavePdf", + "GeckoView:GetNimbusFeature" + }) { + @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"), + message.getString("textContent")); + + delegate.onContextMenu( + GeckoSession.this, message.getInt("screenX"), message.getInt("screenY"), elem); + + } else if ("GeckoView:DOMMetaViewportFit".equals(event)) { + delegate.onMetaViewportFitChange(GeckoSession.this, message.getString("viewportfit")); + } else if ("GeckoView:PageTitleChanged".equals(event)) { + delegate.onTitleChange(GeckoSession.this, message.getString("title")); + } else if ("GeckoView:FocusRequest".equals(event)) { + delegate.onFocusRequest(GeckoSession.this); + } else if ("GeckoView:DOMWindowClose".equals(event)) { + delegate.onCloseRequest(GeckoSession.this); + } else if ("GeckoView:FullScreenEnter".equals(event)) { + delegate.onFullScreen(GeckoSession.this, true); + } else if ("GeckoView:FullScreenExit".equals(event)) { + delegate.onFullScreen(GeckoSession.this, false); + } else if ("GeckoView:WebAppManifest".equals(event)) { + final GeckoBundle manifest = message.getBundle("manifest"); + if (manifest == null) { + return; + } + + try { + delegate.onWebAppManifest( + GeckoSession.this, fixupWebAppManifest(manifest.toJSONObject())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Failed to convert web app manifest to JSON", e); + } + } else if ("GeckoView:FirstContentfulPaint".equals(event)) { + delegate.onFirstContentfulPaint(GeckoSession.this); + } else if ("GeckoView:PaintStatusReset".equals(event)) { + delegate.onPaintStatusReset(GeckoSession.this); + } else if ("GeckoView:PreviewImage".equals(event)) { + delegate.onPreviewImage(GeckoSession.this, message.getString("previewImageUrl")); + } else if ("GeckoView:CookieBannerEvent:Detected".equals(event)) { + delegate.onCookieBannerDetected(GeckoSession.this); + } else if ("GeckoView:CookieBannerEvent:Handled".equals(event)) { + delegate.onCookieBannerHandled(GeckoSession.this); + } else if ("GeckoView:SavePdf".equals(event)) { + final GeckoResult result = + SessionPdfFileSaver.createResponse( + GeckoSession.this, + message.getString("url"), + message.getString("filename"), + message.getString("originalUrl"), + message.getBoolean("skipConfirmation"), + message.getBoolean("requestExternalApp")); + if (result == null) { + callback.sendError("Failed to create response"); + return; + } + result.accept( + response -> + ThreadUtils.runOnUiThread( + () -> delegate.onExternalResponse(GeckoSession.this, response)), + exception -> callback.sendError("Failed to create response")); + } else if ("GeckoView:GetNimbusFeature".equals(event)) { + final String featureId = message.getString("featureId"); + final JSONObject res = delegate.onGetNimbusFeature(GeckoSession.this, featureId); + if (res == null) { + callback.sendError("No Nimbus data for the feature " + featureId); + return; + } + try { + callback.sendSuccess(GeckoBundle.fromJSONObject(res)); + } catch (final JSONException e) { + callback.sendError( + "No Nimbus data for the feature " + featureId + ": conversion failed."); + } + } + } + }; + + private final GeckoSessionHandler mNavigationHandler = + new GeckoSessionHandler( + "GeckoViewNavigation", + this, + new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"}, + new String[] { + "GeckoView:OnLoadError", "GeckoView:OnLoadRequest", + }) { + // This needs to match nsIBrowserDOMWindow.idl + private int convertGeckoTarget(final int geckoTarget) { + switch (geckoTarget) { + case 0: // OPEN_DEFAULTWINDOW + case 1: // OPEN_CURRENTWINDOW + return NavigationDelegate.TARGET_WINDOW_CURRENT; + default: // OPEN_NEWWINDOW, OPEN_NEWTAB + return NavigationDelegate.TARGET_WINDOW_NEW; + } + } + + @Override + public void handleDefaultMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + + if ("GeckoView:OnLoadRequest".equals(event)) { + callback.sendSuccess(false); + } else if ("GeckoView:OnLoadError".equals(event)) { + callback.sendSuccess(null); + } else { + super.handleDefaultMessage(event, message, callback); + } + } + + // For .isOpen(), the linter is not smart enough to figure out we're asserting that we're on + // the UI thread. + @SuppressLint("WrongThread") + @Override + public void handleMessage( + final NavigationDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri")); + if ("GeckoView:LocationChange".equals(event)) { + if (message.getBoolean("isTopLevel")) { + final GeckoBundle[] perms = message.getBundleArray("permissions"); + final List permList = + PermissionDelegate.ContentPermission.fromBundleArray(perms); + delegate.onLocationChange(GeckoSession.this, message.getString("uri"), permList); + } + delegate.onCanGoBack(GeckoSession.this, message.getBoolean("canGoBack")); + delegate.onCanGoForward(GeckoSession.this, message.getBoolean("canGoForward")); + } else if ("GeckoView:OnLoadRequest".equals(event)) { + final NavigationDelegate.LoadRequest request = + new NavigationDelegate.LoadRequest( + message.getString("uri"), + message.getString("triggerUri"), + message.getInt("where"), + message.getInt("flags"), + message.getBoolean("hasUserGesture"), + /* isDirectNavigation */ false); + + if (!IntentUtils.isUriSafeForScheme(request.uri)) { + callback.sendError("Blocked unsafe intent URI"); + + delegate.onLoadError( + GeckoSession.this, + request.uri, + new WebRequestError( + WebRequestError.ERROR_MALFORMED_URI, + WebRequestError.ERROR_CATEGORY_URI, + null)); + + return; + } + + final GeckoResult 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 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 result = delegate.onNewSession(GeckoSession.this, uri); + if (result == null) { + callback.sendSuccess(false); + return; + } + + final String newSessionId = message.getString("newSessionId"); + callback.resolveTo( + result.map( + session -> { + ThreadUtils.assertOnUiThread(); + if (session == null) { + return false; + } + + if (session.isOpen()) { + throw new AssertionError("Must use an unopened GeckoSession instance"); + } + + if (GeckoSession.this.mWindow == null) { + throw new IllegalArgumentException("Session is not attached to a window"); + } + + session.open(GeckoSession.this.mWindow.runtime, newSessionId); + return true; + })); + } + } + }; + + private final GeckoSessionHandler mPrintHandler = + new GeckoSessionHandler( + "GeckoViewPrint", this, new String[] {"GeckoView:DotPrintRequest"}) { + @Override + public void handleMessage( + final PrintDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + + if ("GeckoView:DotPrintRequest".equals(event)) { + final Long cbcId = message.getLong("canonicalBrowsingContextId"); + final GeckoResult pdfResult = saveAsPdfByBrowsingContext(cbcId); + final GeckoBundle bundle = new GeckoBundle(); + pdfResult + .accept( + pdfStream -> { + final GeckoResult dialogFinished = + delegate.onPrintWithStatus(pdfStream); + try { + dialogFinished + .accept( + isDialogFinished -> { + bundle.putBoolean("isPdfSuccessful", true); + mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle); + }) + .exceptionally( + e -> { + bundle.putBoolean("isPdfSuccessful", false); + if (e instanceof GeckoPrintException) { + bundle.putInt("errorReason", ((GeckoPrintException) e).code); + } + mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle); + return null; + }); + } catch (final Exception e) { + bundle.putBoolean("isPdfSuccessful", false); + mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle); + Log.e(LOGTAG, "Print delegate needs to be fully implemented to print.", e); + } + }) + .exceptionally( + e -> { + bundle.putBoolean("isPdfSuccessful", false); + if (e instanceof GeckoPrintException) { + bundle.putInt("errorReason", ((GeckoPrintException) e).code); + } + mEventDispatcher.dispatch("GeckoView:DotPrintFinish", bundle); + Log.e(LOGTAG, "Could not complete DotPrintRequest.", e); + return null; + }); + } + } + }; + + private final GeckoSessionHandler mProcessHangHandler = + new GeckoSessionHandler( + "GeckoViewProcessHangMonitor", this, new String[] {"GeckoView:HangReport"}) { + + @Override + protected void handleMessage( + final ContentDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback eventCallback) { + Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri")); + + final GeckoResult 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 mProgressHandler = + new GeckoSessionHandler( + "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 mScrollHandler = + new GeckoSessionHandler( + "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 mContentBlockingHandler = + new GeckoSessionHandler( + "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 mPermissionHandler = + new GeckoSessionHandler( + "GeckoViewPermission", + this, + new String[] { + "GeckoView:AndroidPermission", + "GeckoView:ContentPermission", + "GeckoView:MediaPermission" + }) { + @Override + public void handleMessage( + final PermissionDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage: " + event); + if (delegate == null) { + callback.sendSuccess(/* granted */ false); + return; + } + if ("GeckoView:AndroidPermission".equals(event)) { + delegate.onAndroidPermissionsRequest( + GeckoSession.this, + message.getStringArray("perms"), + new PermissionCallback("android", callback)); + } else if ("GeckoView:ContentPermission".equals(event)) { + final GeckoResult res = + delegate.onContentPermissionRequest( + GeckoSession.this, new PermissionDelegate.ContentPermission(message)); + if (res == null) { + callback.sendSuccess(PermissionDelegate.ContentPermission.VALUE_PROMPT); + return; + } + + callback.resolveTo(res); + } else if ("GeckoView:MediaPermission".equals(event)) { + final GeckoBundle[] videoBundles = message.getBundleArray("video"); + final GeckoBundle[] audioBundles = message.getBundleArray("audio"); + PermissionDelegate.MediaSource[] videos = null; + PermissionDelegate.MediaSource[] audios = null; + + if (videoBundles != null) { + videos = new PermissionDelegate.MediaSource[videoBundles.length]; + for (int i = 0; i < videoBundles.length; i++) { + videos[i] = new PermissionDelegate.MediaSource(videoBundles[i]); + } + } + + if (audioBundles != null) { + audios = new PermissionDelegate.MediaSource[audioBundles.length]; + for (int i = 0; i < audioBundles.length; i++) { + audios[i] = new PermissionDelegate.MediaSource(audioBundles[i]); + } + } + + delegate.onMediaPermissionRequest( + GeckoSession.this, + message.getString("uri"), + videos, + audios, + new PermissionCallback("media", callback)); + } + } + }; + + private final GeckoSessionHandler mSelectionActionDelegate = + new GeckoSessionHandler( + "GeckoViewSelectionAction", + this, + new String[] { + "GeckoView:HideSelectionAction", + "GeckoView:ShowSelectionAction", + "GeckoView:HideMagnifier", + "GeckoView:ShowMagnifier", + "GeckoView:ClipboardPermissionRequest", + "GeckoView:DismissClipboardPermissionRequest", + }) { + @Override + public void handleMessage( + final SelectionActionDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage: " + event); + if ("GeckoView:ShowSelectionAction".equals(event)) { + final @SelectionActionDelegateAction HashSet actionsSet = + new HashSet<>(Arrays.asList(message.getStringArray("actions"))); + final SelectionActionDelegate.Selection selection = + new SelectionActionDelegate.Selection(message, actionsSet, mEventDispatcher); + + delegate.onShowActionRequest(GeckoSession.this, selection); + + } else if ("GeckoView:HideSelectionAction".equals(event)) { + final String reasonString = message.getString("reason"); + final int reason; + if ("invisibleselection".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION; + } else if ("presscaret".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION; + } else if ("scroll".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL; + } else if ("visibilitychange".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_NO_SELECTION; + } else { + throw new IllegalArgumentException(); + } + + delegate.onHideAction(GeckoSession.this, reason); + } else if ("GeckoView:ShowMagnifier".equals(event)) { + final PointF point = message.getPointF("screenPoint"); + if (point == null) { + throw new IllegalArgumentException("Invalid argument"); + } + + // Magnifier is surface coordinate. + point.x -= GeckoSession.this.mLeft; + point.y -= GeckoSession.this.mClientTop; + GeckoSession.this.getMagnifier().show(point); + } else if ("GeckoView:HideMagnifier".equals(event)) { + GeckoSession.this.getMagnifier().dismiss(); + } else if ("GeckoView:ClipboardPermissionRequest".equals(event)) { + final SelectionActionDelegate.ClipboardPermission permission = + new SelectionActionDelegate.ClipboardPermission(message); + + final GeckoResult result = + delegate.onShowClipboardPermissionRequest(GeckoSession.this, permission); + callback.resolveTo( + result.map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return true; + } + if (value == AllowOrDeny.DENY) { + return false; + } + throw new IllegalArgumentException("Invalid response"); + })); + } else if ("GeckoView:DismissClipboardPermissionRequest".equals(event)) { + delegate.onDismissClipboardPermissionRequest(GeckoSession.this); + } + } + }; + + private final GeckoSessionHandler mMediaHandler = + new GeckoSessionHandler( + "GeckoViewMedia", + this, + new String[] { + "GeckoView:MediaRecordingStatusChanged", + }) { + @Override + public void handleMessage( + final MediaDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + if ("GeckoView:MediaRecordingStatusChanged".equals(event)) { + final GeckoBundle[] deviceBundles = message.getBundleArray("devices"); + final MediaDelegate.RecordingDevice[] devices = + new MediaDelegate.RecordingDevice[deviceBundles.length]; + for (int i = 0; i < deviceBundles.length; i++) { + devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]); + } + delegate.onRecordingStatusChanged(GeckoSession.this, devices); + return; + } + } + }; + + private final MediaSession.Handler mMediaSessionHandler = new MediaSession.Handler(this); + + /* package */ int handlersCount; + + private final GeckoSessionHandler[] mSessionHandlers = + new GeckoSessionHandler[] { + mContentHandler, + mHistoryHandler, + mMediaHandler, + mNavigationHandler, + mPermissionHandler, + mPrintHandler, + 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 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 mOwner; + private NativeQueue mNativeQueue; + private Binder mBinder; + + public Window( + final @NonNull GeckoRuntime runtime, + final @NonNull GeckoSession owner, + final @NonNull NativeQueue nativeQueue) { + this.runtime = runtime; + mOwner = new WeakReference<>(owner); + mNativeQueue = nativeQueue; + } + + @Override // IInterface + public Binder asBinder() { + if (mBinder == null) { + mBinder = new Binder(); + mBinder.attachInterface(this, Window.class.getName()); + } + return mBinder; + } + + // Create a new Gecko window and assign an initial set of Java session objects to it. + @WrapForJNI(dispatchTo = "proxy") + public static native void open( + Window instance, + NativeQueue queue, + Compositor compositor, + EventDispatcher dispatcher, + SessionAccessibility.NativeProvider sessionAccessibility, + GeckoBundle initData, + String id, + String chromeUri, + boolean privateMode); + + @Override // JNIObject + public void disposeNative() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeDisposeNative(); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, this, "nativeDisposeNative"); + } + } + + @WrapForJNI(dispatchTo = "proxy", stubName = "DisposeNative") + private native void nativeDisposeNative(); + + // Force the underlying Gecko window to close and release assigned Java objects. + public void close() { + // Reset our queue, so we don't end up with queued calls on a disposed object. + synchronized (this) { + if (mNativeQueue == null) { + // Already closed elsewhere. + return; + } + mNativeQueue.reset(State.INITIAL); + mNativeQueue = null; + mOwner = new WeakReference<>(null); + } + + // Detach ourselves from the binder as well, to prevent this window from being + // read from any parcels. + asBinder().attachInterface(null, Window.class.getName()); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeClose(); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, "nativeClose"); + } + } + + @WrapForJNI(dispatchTo = "proxy", stubName = "Close") + private native void nativeClose(); + + @WrapForJNI(dispatchTo = "proxy", stubName = "Transfer") + private native void nativeTransfer( + NativeQueue queue, + Compositor compositor, + EventDispatcher dispatcher, + SessionAccessibility.NativeProvider sessionAccessibility, + GeckoBundle initData); + + @WrapForJNI(dispatchTo = "proxy") + public native void attachEditable(IGeckoEditableParent parent); + + @WrapForJNI(dispatchTo = "proxy") + public native void attachAccessibility( + SessionAccessibility.NativeProvider sessionAccessibility); + + @WrapForJNI(dispatchTo = "proxy") + public native void printToPdf(GeckoResult geckoResult); + + @WrapForJNI(dispatchTo = "proxy") + private native void printToPdf(GeckoResult geckoResult, long browserContextId); + + @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 onLoadRequest( + final @NonNull String uri, + final int windowType, + final int flags, + final @Nullable String triggeringUri, + final boolean hasUserGesture, + final boolean isTopLevel) { + final ProfilerController profilerController = runtime.getProfilerController(); + final Double onLoadRequestProfilerStartTime = profilerController.getProfilerTime(); + final Runnable addMarker = + () -> + profilerController.addMarker( + "GeckoSession.onLoadRequest", onLoadRequestProfilerStartTime); + + final GeckoSession session = mOwner.get(); + if (session == null) { + // Don't handle any load request if we can't get the session for some reason. + return GeckoResult.fromValue(false); + } + final GeckoResult res = new GeckoResult<>(); + + ThreadUtils.postToUiThread( + new Runnable() { + @Override + public void run() { + final NavigationDelegate delegate = session.getNavigationDelegate(); + + if (delegate == null) { + res.complete(false); + addMarker.run(); + return; + } + + if (!IntentUtils.isUriSafeForScheme(uri)) { + delegate.onLoadError( + session, + uri, + new WebRequestError( + WebRequestError.ERROR_MALFORMED_URI, + WebRequestError.ERROR_CATEGORY_URI, + null)); + res.complete(true); + addMarker.run(); + return; + } + + final String trigger = TextUtils.isEmpty(triggeringUri) ? null : triggeringUri; + final NavigationDelegate.LoadRequest req = + new NavigationDelegate.LoadRequest( + uri, + trigger, + windowType, + flags, + hasUserGesture, + false /* isDirectNavigation */); + final GeckoResult reqResponse = + isTopLevel + ? delegate.onLoadRequest(session, req) + : delegate.onSubframeLoadRequest(session, req); + + if (reqResponse == null) { + res.complete(false); + addMarker.run(); + return; + } + + reqResponse.accept( + value -> { + if (value == AllowOrDeny.DENY) { + res.complete(true); + } else { + res.complete(false); + } + addMarker.run(); + }, + ex -> { + // This is incredibly ugly and unreadable because checkstyle sucks. + res.complete(false); + addMarker.run(); + }); + } + }); + + return res; + } + + @WrapForJNI(calledFrom = "ui") + private void passExternalWebResponse(final WebResponse response) { + final GeckoSession session = mOwner.get(); + if (session == null) { + return; + } + final ContentDelegate delegate = session.getContentDelegate(); + if (delegate != null) { + delegate.onExternalResponse(session, response); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void onShowDynamicToolbar() { + final Window self = this; + ThreadUtils.runOnUiThread( + () -> { + final GeckoSession session = self.mOwner.get(); + if (session == null) { + return; + } + final ContentDelegate delegate = session.getContentDelegate(); + if (delegate != null) { + delegate.onShowDynamicToolbar(session); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private void onUpdateSessionStore(final GeckoBundle aBundle) { + ThreadUtils.runOnUiThread( + () -> { + final GeckoSession session = mOwner.get(); + if (session == null) { + return; + } + GeckoBundle scroll = aBundle.getBundle("scroll"); + if (scroll == null) { + scroll = new GeckoBundle(); + aBundle.putBundle("scroll", scroll); + } + + // Here we unfortunately need to do some re-mapping since `zoom` is passed in a separate + // bunds and we wish to keep the bundle format. + scroll.putBundle("zoom", aBundle.getBundle("zoom")); + final SessionState stateCache = session.mStateCache; + stateCache.updateSessionState(aBundle); + final SessionState state = new SessionState(stateCache); + if (!state.isEmpty()) { + final ProgressDelegate progressDelegate = session.getProgressDelegate(); + if (progressDelegate != null) { + progressDelegate.onSessionStateChange(session, state); + } else { + } + } + }); + } + } + + private class Listener implements BundleEventListener { + /* package */ void registerListeners() { + getEventDispatcher() + .registerUiThreadListener( + this, + "GeckoView:PinOnScreen", + "GeckoView:Prompt", + "GeckoView:Prompt:Dismiss", + "GeckoView:Prompt:Update", + null); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event); + + if ("GeckoView:PinOnScreen".equals(event)) { + GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned")); + } else if ("GeckoView:Prompt".equals(event)) { + mPromptController.handleEvent(GeckoSession.this, message.getBundle("prompt"), callback); + } else if ("GeckoView:Prompt:Dismiss".equals(event)) { + mPromptController.dismissPrompt(message.getString("id")); + } else if ("GeckoView:Prompt:Update".equals(event)) { + mPromptController.updatePrompt(message.getBundle("prompt")); + } + } + } + + private final PromptController mPromptController; + + protected @Nullable Window mWindow; + private GeckoSessionSettings mSettings; + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSession() { + this(null); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSession(final @Nullable GeckoSessionSettings settings) { + mSettings = new GeckoSessionSettings(settings, this); + mListener.registerListeners(); + + mWebExtensionController = new WebExtension.SessionController(this); + mPromptController = new PromptController(); + + mAutofillSupport = new Autofill.Support(this); + mAutofillSupport.registerListeners(); + + if (BuildConfig.DEBUG_BUILD && handlersCount != mSessionHandlers.length) { + throw new AssertionError("Add new handler to handlers list"); + } + } + + /* package */ @Nullable + GeckoRuntime getRuntime() { + if (mWindow == null) { + return null; + } + return mWindow.runtime; + } + + /* package */ synchronized void abandonWindow() { + if (mWindow == null) { + return; + } + + onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ true); + mWindow = null; + onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ false); + } + + /** + * Return whether this session is open. + * + * @return True if session is open. + * @see #open + * @see #close + */ + @UiThread + public boolean isOpen() { + ThreadUtils.assertOnUiThread(); + return mWindow != null; + } + + /* package */ boolean isReady() { + return mNativeQueue.isReady(); + } + + private GeckoBundle createInitData() { + final GeckoBundle initData = new GeckoBundle(2); + initData.putBundle("settings", mSettings.toBundle()); + + final GeckoBundle modules = new GeckoBundle(mSessionHandlers.length); + for (final GeckoSessionHandler handler : mSessionHandlers) { + modules.putBoolean(handler.getName(), handler.isEnabled()); + } + initData.putBundle("modules", modules); + return initData; + } + + /** + * Opens the session. + * + *

    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) { + open(runtime, UUID.randomUUID().toString().replace("-", "")); + } + + /* package */ void open(final @NonNull GeckoRuntime runtime, final String id) { + ThreadUtils.assertOnUiThread(); + + if (isOpen()) { + // We will leak the existing Window if we open another one. + throw new IllegalStateException("Session is open"); + } + + final String chromeUri = mSettings.getChromeUri(); + final boolean isPrivate = mSettings.getUsePrivateMode(); + + mId = id; + mWindow = new Window(runtime, this, mNativeQueue); + mWebExtensionController.setRuntime(runtime); + + onWindowChanged(WINDOW_OPEN, /* inProgress */ true); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + Window.open( + mWindow, + mNativeQueue, + mCompositor, + mEventDispatcher, + mAccessibility != null ? mAccessibility.nativeProvider : null, + createInitData(), + mId, + chromeUri, + isPrivate); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + Window.class, + "open", + Window.class, + mWindow, + NativeQueue.class, + mNativeQueue, + Compositor.class, + mCompositor, + EventDispatcher.class, + mEventDispatcher, + SessionAccessibility.NativeProvider.class, + mAccessibility != null ? mAccessibility.nativeProvider : null, + GeckoBundle.class, + createInitData(), + String.class, + mId, + String.class, + chromeUri, + isPrivate); + } + + onWindowChanged(WINDOW_OPEN, /* inProgress */ false); + } + + /** + * Closes the session. + * + *

    This frees the underlying Gecko objects and unloads the current page. The session may be + * reopened later, but page state is not restored. Call this when you are finished using a + * GeckoSession instance. + * + * @see #open + * @see #isOpen + */ + @UiThread + public void close() { + ThreadUtils.assertOnUiThread(); + + if (!isOpen()) { + Log.w(LOGTAG, "Attempted to close a GeckoSession that was already closed."); + return; + } + + onWindowChanged(WINDOW_CLOSE, /* inProgress */ true); + + // We need to ensure the compositor releases any Surface it currently holds. + onSurfaceDestroyed(); + + mWindow.close(); + mWindow.disposeNative(); + // Can't access the compositor after we dispose of the window + mCompositorReady = false; + mWindow = null; + + onWindowChanged(WINDOW_CLOSE, /* inProgress */ false); + } + + private void onWindowChanged(final int change, final boolean inProgress) { + if ((change == WINDOW_OPEN || change == WINDOW_TRANSFER_IN) && !inProgress) { + mTextInput.onWindowChanged(mWindow); + } + if ((change == WINDOW_CLOSE || change == WINDOW_TRANSFER_OUT) && !inProgress) { + getAutofillSupport().clear(); + } + } + + /** + * Get the SessionTextInput instance for this session. May be called on any thread. + * + * @return SessionTextInput instance. + */ + @AnyThread + public @NonNull SessionTextInput getTextInput() { + // May be called on any thread. + return mTextInput; + } + + /** + * Get the SessionAccessibility instance for this session. + * + * @return SessionAccessibility instance. + */ + @UiThread + public @NonNull SessionAccessibility getAccessibility() { + ThreadUtils.assertOnUiThread(); + if (mAccessibility != null) { + return mAccessibility; + } + + mAccessibility = new SessionAccessibility(this); + if (mWindow != null) { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + mWindow.attachAccessibility(mAccessibility.nativeProvider); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + mWindow, + "attachAccessibility", + SessionAccessibility.NativeProvider.class, + mAccessibility.nativeProvider); + } + } + return mAccessibility; + } + + /** + * Get the SessionMagnifier instance for this session. + * + * @return SessionMagnifier instance. + */ + @UiThread + /* package */ @NonNull + SessionMagnifier getMagnifier() { + ThreadUtils.assertOnUiThread(); + if (mMagnifier == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + mMagnifier = new SessionMagnifierP(mCompositor); + } else { + mMagnifier = new SessionMagnifier() {}; + } + } + + return mMagnifier; + } + + // The priority of the GeckoSession, either default or high. + @Retention(RetentionPolicy.SOURCE) + @IntDef({PRIORITY_DEFAULT, PRIORITY_HIGH}) + public @interface Priority {} + + /** Value for Priority when it is default. */ + public static final int PRIORITY_DEFAULT = 0; + + /** Value for Priority when it is high. */ + public static final int PRIORITY_HIGH = 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + LOAD_FLAGS_NONE, + LOAD_FLAGS_BYPASS_CACHE, + LOAD_FLAGS_BYPASS_PROXY, + LOAD_FLAGS_EXTERNAL, + LOAD_FLAGS_ALLOW_POPUPS, + LOAD_FLAGS_FORCE_ALLOW_DATA_URI, + LOAD_FLAGS_REPLACE_HISTORY, + LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE, + }) + public @interface LoadFlags {} + + // These flags follow similarly named ones in Gecko's nsIWebNavigation.idl + // https://searchfox.org/mozilla-central/source/docshell/base/nsIWebNavigation.idl + // + // We do not use the same values directly in order to insulate ourselves from + // changes in Gecko. Instead, the flags are converted in GeckoViewNavigation.jsm. + + /** Default load flag, no special considerations. */ + public static final int LOAD_FLAGS_NONE = 0; + + /** Bypass the cache. */ + public static final int LOAD_FLAGS_BYPASS_CACHE = 1 << 0; + + /** Bypass the proxy, if one has been configured. */ + public static final int LOAD_FLAGS_BYPASS_PROXY = 1 << 1; + + /** The load is coming from an external app. Perform additional checks. */ + public static final int LOAD_FLAGS_EXTERNAL = 1 << 2; + + /** Popup blocking will be disabled for this load */ + public static final int LOAD_FLAGS_ALLOW_POPUPS = 1 << 3; + + /** Bypass the URI classifier (content blocking and Safe Browsing). */ + public static final int LOAD_FLAGS_BYPASS_CLASSIFIER = 1 << 4; + + /** + * Allows a top-level data: navigation to occur. E.g. view-image is an explicit user action which + * should be allowed. + */ + public static final int LOAD_FLAGS_FORCE_ALLOW_DATA_URI = 1 << 5; + + /** This flag specifies that any existing history entry should be replaced. */ + public static final int LOAD_FLAGS_REPLACE_HISTORY = 1 << 6; + + /** This load should bypass the NavigationDelegate.onLoadRequest. */ + public static final int LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE = 1 << 7; + + /** + * Filter headers according to the CORS safelisted rules. + * + *

    See + * CORS-safelisted request header . + */ + public static final int HEADER_FILTER_CORS_SAFELISTED = 1; + + /** + * Allows most headers. + * + *

    Note: the Host and Connection 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}) + public @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. + * + *

    
    +   *     session.load(new Loader().uri("http://mozilla.org"));
    +   * 
    + * + * This class can also be used to load data: URIs, either from a byte[] + * array or a String using {@link #data}, e.g. + * + *
    
    +   *     session.load(new Loader().data("the data:1234,5678", "text/plain"));
    +   * 
    + * + * 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. + * + *

    
    +   *     session.load(new Loader()
    +   *          .url("http://mozilla.org")
    +   *          .referrer("http://my-referrer.com")
    +   *          .flags(...));
    +   * 
    + */ + @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 byte array containing the data to load. + * @param mimeType a String 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 String array containing the data to load. + * @param mimeType a String 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 GeckoSession 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 String 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 + * CORS-safelisted request header . + * + * @param headers a Map containing headers that will be added to this load. + * @return this {@link Loader} instance. + */ + @NonNull + public Loader additionalHeaders(final @NonNull Map headers) { + final GeckoBundle bundle = new GeckoBundle(headers.size()); + for (final Map.Entry entry : headers.entrySet()) { + if (entry.getKey() == null) { + // Ignore null keys + continue; + } + bundle.putString(entry.getKey(), entry.getValue()); + } + mHeaders = bundle; + return this; + } + + /** + * Modify the header filter behavior. By default only CORS safelisted headers are allowed. + * + * @param filter one of the {@link GeckoSession#HEADER_FILTER_CORS_SAFELISTED HEADER_FILTER_*} + * constants. + * @return this {@link Loader} instance. + */ + @NonNull + public Loader headerFilter(final @HeaderFilter int filter) { + mHeaderFilter = filter; + return this; + } + + /** + * Set the load flags for this load. + * + * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*} + * that will be used as the referrer + * @return this {@link Loader} instance. + */ + @NonNull + public Loader flags(final @LoadFlags int flags) { + mLoadFlags = flags; + return this; + } + } + + /** + * Load page using the {@link Loader} specified. + * + * @param request Loader for this request. + * @see Loader + */ + @AnyThread + public void load(final @NonNull Loader request) { + if (request.mUri == null) { + throw new IllegalArgumentException( + "You need to specify at least one between `uri` and `data`."); + } + + if (request.mReferrerUri != null && request.mReferrerSession != null) { + throw new IllegalArgumentException( + "Cannot specify both a referrer session and a referrer URI."); + } + + final NavigationDelegate navDelegate = mNavigationHandler.getDelegate(); + final boolean isDataUriTooLong = !maybeCheckDataUriLength(request); + if (navDelegate == null && isDataUriTooLong) { + throw new IllegalArgumentException("data URI is too long"); + } + + final int loadFlags = + request.mIsDataUri + // If this is a data: load then we need to force allow it. + ? request.mLoadFlags | LOAD_FLAGS_FORCE_ALLOW_DATA_URI + : request.mLoadFlags; + + // For performance reasons we short-circuit the delegate here + // instead of making Gecko call it for direct load calls. + final NavigationDelegate.LoadRequest loadRequest = + new NavigationDelegate.LoadRequest( + request.mUri, + null, /* triggerUri */ + 1, /* geckoTarget: OPEN_CURRENTWINDOW */ + 0, /* flags */ + false, /* hasUserGesture */ + true /* isDirectNavigation */); + + shouldLoadUri(loadRequest, loadFlags) + .getOrAccept( + allowOrDeny -> { + if (allowOrDeny == AllowOrDeny.DENY) { + return; + } + + if (isDataUriTooLong) { + ThreadUtils.runOnUiThread( + () -> { + navDelegate.onLoadError( + this, + request.mUri, + new WebRequestError( + WebRequestError.ERROR_DATA_URI_TOO_LONG, + WebRequestError.ERROR_CATEGORY_URI, + null)); + }); + return; + } + + final GeckoBundle msg = new GeckoBundle(); + msg.putString("uri", request.mUri); + msg.putInt("flags", loadFlags); + msg.putInt("headerFilter", request.mHeaderFilter); + + if (request.mReferrerUri != null) { + msg.putString("referrerUri", request.mReferrerUri); + } + + if (request.mReferrerSession != null) { + msg.putString("referrerSessionId", request.mReferrerSession.mId); + } + + if (request.mHeaders != null) { + msg.putBundle("headers", request.mHeaders); + } + + mEventDispatcher.dispatch("GeckoView:LoadUri", msg); + }); + } + + /** + * Load the given URI. + * + *

    Convenience method for + * + *

    
    +   *     session.load(new Loader().uri(uri));
    +   * 
    + * + * @param uri The URI of the resource to load. + */ + @AnyThread + public void loadUri(final @NonNull String uri) { + load(new Loader().uri(uri)); + } + + private GeckoResult shouldLoadUri( + final NavigationDelegate.LoadRequest request, final int loadFlags) { + final NavigationDelegate delegate = mNavigationHandler.getDelegate(); + if (delegate == null || (loadFlags & LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE) != 0) { + return GeckoResult.allow(); + } + + // Always run the callback on the UI thread regardless of what thread we were called in. + final GeckoResult result = new GeckoResult<>(ThreadUtils.getUiHandler()); + + ThreadUtils.runOnUiThread( + () -> { + final GeckoResult delegateResult = delegate.onLoadRequest(this, request); + + if (delegateResult == null) { + result.complete(AllowOrDeny.ALLOW); + } else { + delegateResult.getOrAccept( + allowOrDeny -> result.complete(allowOrDeny), + error -> result.completeExceptionally(error)); + } + }); + + return result; + } + + /** Reload the current URI. */ + @AnyThread + public void reload() { + reload(LOAD_FLAGS_NONE); + } + + /** + * Reload the current URI. + * + * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*} + */ + @AnyThread + public void reload(final @LoadFlags int flags) { + final GeckoBundle msg = new GeckoBundle(); + msg.putInt("flags", flags); + mEventDispatcher.dispatch("GeckoView:Reload", msg); + } + + /** Stop loading. */ + @AnyThread + public void stop() { + mEventDispatcher.dispatch("GeckoView:Stop", null); + } + + /** + * Go back in history and assumes the call was based on a user interaction. + * + * @see #goBack(boolean) + */ + @AnyThread + public void goBack() { + goBack(true); + } + + /** + * Go back in history. + * + * @param userInteraction Whether the action was invoked by a user interaction. + */ + @AnyThread + public void goBack(final boolean userInteraction) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("userInteraction", userInteraction); + mEventDispatcher.dispatch("GeckoView:GoBack", msg); + } + + /** + * Go forward in history and assumes the call was based on a user interaction. + * + * @see #goForward(boolean) + */ + @AnyThread + public void goForward() { + goForward(true); + } + + /** + * Go forward in history. + * + * @param userInteraction Whether the action was invoked by a user interaction. + */ + @AnyThread + public void goForward(final boolean userInteraction) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("userInteraction", userInteraction); + mEventDispatcher.dispatch("GeckoView:GoForward", msg); + } + + /** + * Navigate to an index in browser history; the index of the currently viewed page can be + * retrieved from an up-to-date HistoryList by calling {@link + * HistoryDelegate.HistoryList#getCurrentIndex()}. + * + * @param index The index of the location in browser history you want to navigate to. + */ + @AnyThread + public void gotoHistoryIndex(final int index) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putInt("index", index); + mEventDispatcher.dispatch("GeckoView:GotoHistoryIndex", msg); + } + + /** + * Returns a WebExtensionController for this GeckoSession. Delegates attached to this controller + * will receive events specific to this session. + * + * @return an instance of {@link WebExtension.SessionController}. + */ + @UiThread + public @NonNull WebExtension.SessionController getWebExtensionController() { + return mWebExtensionController; + } + + /** + * Purge history for the session. The session history is used for back and forward history. + * Purging the session history means {@link NavigationDelegate#onCanGoBack(GeckoSession, boolean)} + * and {@link NavigationDelegate#onCanGoForward(GeckoSession, boolean)} will be false. + */ + @AnyThread + public void purgeHistory() { + mEventDispatcher.dispatch("GeckoView:PurgeHistory", null); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FINDER_FIND_BACKWARDS, + FINDER_FIND_LINKS_ONLY, + FINDER_FIND_MATCH_CASE, + FINDER_FIND_WHOLE_WORD + }) + public @interface FinderFindFlags {} + + /** Go backwards when finding the next match. */ + public static final int FINDER_FIND_BACKWARDS = 1; + + /** Perform case-sensitive match; default is to perform a case-insensitive match. */ + public static final int FINDER_FIND_MATCH_CASE = 1 << 1; + + /** Must match entire words; default is to allow matching partial words. */ + public static final int FINDER_FIND_WHOLE_WORD = 1 << 2; + + /** Limit matches to links on the page. */ + public static final int FINDER_FIND_LINKS_ONLY = 1 << 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FINDER_DISPLAY_HIGHLIGHT_ALL, + FINDER_DISPLAY_DIM_PAGE, + FINDER_DISPLAY_DRAW_LINK_OUTLINE + }) + public @interface FinderDisplayFlags {} + + /** Highlight all find-in-page matches. */ + public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1; + + /** Dim the rest of the page when showing a find-in-page match. */ + public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1; + + /** Draw outlines around matching links. */ + public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2; + + /** Represent the result of a find-in-page operation. */ + @AnyThread + public static class FinderResult { + /** Whether a match was found. */ + public final boolean found; + + /** Whether the search wrapped around the top or bottom of the page. */ + public final boolean wrapped; + + /** Ordinal number of the current match starting from 1, or 0 if no match. */ + public final int current; + + /** Total number of matches found so far, or -1 if unknown. */ + public final int total; + + /** Search string. */ + @NonNull public final String searchString; + + /** + * Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS + * FINDER_FIND_*} flags. + */ + @FinderFindFlags public final int flags; + + /** URI of the link, if the current match is a link, or null otherwise. */ + @Nullable public final String linkUri; + + /** Bounds of the current match in client coordinates, or null if unknown. */ + @Nullable public final RectF clientRect; + + /* package */ FinderResult(@NonNull final GeckoBundle bundle) { + found = bundle.getBoolean("found"); + wrapped = bundle.getBoolean("wrapped"); + current = bundle.getInt("current", 0); + total = bundle.getInt("total", -1); + searchString = bundle.getString("searchString"); + flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags")); + linkUri = bundle.getString("linkURL"); + clientRect = bundle.getRectF("clientRect"); + } + + /** Empty constructor for tests */ + protected FinderResult() { + found = false; + wrapped = false; + current = 0; + total = 0; + flags = 0; + searchString = ""; + linkUri = ""; + clientRect = null; + } + } + + /** + * Get the SessionFinder instance for this session, to perform find-in-page operations. + * + * @return SessionFinder instance. + */ + @AnyThread + public @NonNull SessionFinder getFinder() { + if (mFinder == null) { + mFinder = new SessionFinder(getEventDispatcher()); + } + return mFinder; + } + + /** + * Checks whether we have a rule for this session. Uses the browsing context or any of its + * children, calls nsICookieBannerService.hasRuleForBrowsingContextTree + * + * @return {@link GeckoResult} with boolean + */ + @AnyThread + public @NonNull GeckoResult hasCookieBannerRuleForBrowsingContextTree() { + return mEventDispatcher.queryBoolean("GeckoView:HasCookieBannerRuleForBrowsingContextTree"); + } + + /** + * Get the SessionPdfFileSaver instance for this session, to save a pdf document. + * + * @return SessionPdfFileSaver instance. + */ + @AnyThread + public @NonNull SessionPdfFileSaver getPdfFileSaver() { + if (mPdfFileSaver == null) { + mPdfFileSaver = new SessionPdfFileSaver(this); + } + return mPdfFileSaver; + } + + /** Represent the result of a save-pdf operation. */ + @AnyThread + public static class PdfSaveResult { + /** Binary data representing a PDF. */ + @NonNull public final byte[] bytes; + + /** PDF file name. */ + @NonNull public final String filename; + + public final boolean isPrivate; + + /* package */ PdfSaveResult(@NonNull final GeckoBundle bundle) { + filename = bundle.getString("filename"); + isPrivate = bundle.getBoolean("isPrivate"); + bytes = bundle.getByteArray("bytes"); + } + + /** Empty constructor for tests */ + protected PdfSaveResult() { + filename = ""; + isPrivate = false; + bytes = new byte[0]; + } + } + + /** + * Check if the document being viewed is a pdf. + * + * @return Result of the check operation as a {@link GeckoResult} object. + */ + @AnyThread + public @NonNull GeckoResult isPdfJs() { + return mEventDispatcher.queryBoolean("GeckoView:IsPdfJs"); + } + + /** + * Set this GeckoSession as active or inactive, which represents if the session is currently + * visible or not. Setting a GeckoSession to inactive will significantly reduce its memory + * footprint, but should only be done if the GeckoSession is not currently visible. Note that a + * session can be active (i.e. visible) but not focused. When a session is set inactive, it will + * flush the session state and trigger a `ProgressDelegate.onSessionStateChange` callback. + * + * @param active A boolean determining whether the GeckoSession is active. + * @see #setFocused + */ + @AnyThread + public void setActive(final boolean active) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("active", active); + mEventDispatcher.dispatch("GeckoView:SetActive", msg); + + if (!active) { + mEventDispatcher.dispatch("GeckoView:FlushSessionState", null); + ThreadUtils.postToUiThreadDelayed(mNotifyMemoryPressure, NOTIFY_MEMORY_PRESSURE_DELAY_MS); + } else { + // Delete any pending memory pressure events since we're active again. + ThreadUtils.removeUiThreadCallbacks(mNotifyMemoryPressure); + } + + ThreadUtils.runOnUiThread(() -> getAutofillSupport().onActiveChanged(active)); + } + + /** + * Move focus to this session or away from this session. Only one session has focus at a given + * time. Note that a session can be unfocused but still active (i.e. visible). + * + * @param focused True if the session should gain focus or false if the session should lose focus. + * @see #setActive + */ + @AnyThread + public void setFocused(final boolean focused) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("focused", focused); + mEventDispatcher.dispatch("GeckoView:SetFocused", msg); + } + + /** + * Notify GeckoView of the priority for this GeckoSession. + * + *

    Set this GeckoSession to high priority (PRIORITY_HIGH) whenever the app wants to signal to + * GeckoView that this GeckoSession is important to the app. GeckoView will keep the session state + * as long as possible. Set this to default priority (PRIORITY_DEFAULT) in any other case. + * + * @param priorityHint Priority of the geckosession, either high priority or default. + */ + @AnyThread + public void setPriorityHint(final @Priority int priorityHint) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putInt("priorityHint", priorityHint); + mEventDispatcher.dispatch("GeckoView:SetPriorityHint", msg); + } + + /** Class representing a saved session state. */ + @AnyThread + public static class SessionState extends AbstractSequentialList + 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 { + private final SessionState mState; + private int mIndex; + + private SessionStateIterator(final @NonNull SessionState state) { + this(state, 0); + } + + private SessionStateIterator(final @NonNull SessionState state, final int index) { + mIndex = index; + mState = state; + } + + @Override /* ListIterator */ + public void add(final HistoryDelegate.HistoryItem item) { + throw new UnsupportedOperationException(); + } + + @Override /* ListIterator */ + public boolean hasNext() { + final GeckoBundle[] entries = mState.getHistoryEntries(); + + if (entries == null) { + Log.w(LOGTAG, "No history entries found."); + return false; + } + + if (mIndex >= mState.getHistoryEntries().length) { + return false; + } + return true; + } + + @Override /* ListIterator */ + public boolean hasPrevious() { + if (mIndex <= 0) { + return false; + } + return true; + } + + @Override /* ListIterator */ + public HistoryDelegate.HistoryItem next() { + if (hasNext()) { + mIndex++; + return new SessionStateItem(mState.getHistoryEntries()[mIndex - 1]); + } else { + throw new NoSuchElementException(); + } + } + + @Override /* ListIterator */ + public int nextIndex() { + return mIndex; + } + + @Override /* ListIterator */ + public HistoryDelegate.HistoryItem previous() { + if (hasPrevious()) { + mIndex--; + return new SessionStateItem(mState.getHistoryEntries()[mIndex]); + } else { + throw new NoSuchElementException(); + } + } + + @Override /* ListIterator */ + public int previousIndex() { + return mIndex - 1; + } + + @Override /* ListIterator */ + public void remove() { + throw new UnsupportedOperationException(); + } + + @Override /* ListIterator */ + public void set(final @NonNull HistoryDelegate.HistoryItem item) { + throw new UnsupportedOperationException(); + } + } + + private SessionState() { + mState = new GeckoBundle(3); + } + + private SessionState(final @NonNull GeckoBundle state) { + mState = new GeckoBundle(state); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SessionState(final @NonNull SessionState state) { + mState = new GeckoBundle(state.mState); + } + + /* package */ void updateSessionState(final @NonNull GeckoBundle updateData) { + if (updateData == null) { + Log.w(LOGTAG, "Session state update has no data field."); + return; + } + + final GeckoBundle history = updateData.getBundle("historychange"); + final GeckoBundle scroll = updateData.getBundle("scroll"); + final GeckoBundle formdata = updateData.getBundle("formdata"); + + if (history != null) { + mState.putBundle("history", history); + } + + if (scroll != null) { + mState.putBundle("scrolldata", scroll); + } + + if (formdata != null) { + mState.putBundle("formdata", formdata); + } + + return; + } + + @Override + public int hashCode() { + return mState.hashCode(); + } + + @Override + public boolean equals(final Object other) { + if (other == null || !(other instanceof SessionState)) { + return false; + } + + final SessionState otherState = (SessionState) other; + + return this.mState.equals(otherState.mState); + } + + /** + * Creates a new SessionState instance from a value previously returned by {@link #toString()}. + * + * @param value The serialized SessionState in String form. + * @return A new SessionState instance if input is valid; otherwise null. + */ + public static @Nullable SessionState fromString(final @Nullable String value) { + final GeckoBundle bundleState; + try { + bundleState = GeckoBundle.fromJSONObject(new JSONObject(value)); + } catch (final Exception e) { + Log.e(LOGTAG, "String does not represent valid session state."); + return null; + } + + if (bundleState == null) { + return null; + } + + return new SessionState(bundleState); + } + + @Override + public @Nullable String toString() { + if (mState == null) { + Log.w(LOGTAG, "Can't convert SessionState with null state to string"); + return null; + } + + String res; + try { + res = mState.toJSONObject().toString(); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert session state to string."); + res = null; + } + + return res; + } + + @Override // Parcelable + public int describeContents() { + return 0; + } + + @Override // Parcelable + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(toString()); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + if (source.readString() == null) { + Log.w(LOGTAG, "Can't reproduce session state from Parcel"); + } + + try { + mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert string to session state."); + mState = null; + } + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SessionState createFromParcel(final Parcel source) { + if (source.readString() == null) { + Log.w(LOGTAG, "Can't create session state from Parcel"); + } + + GeckoBundle res; + try { + res = GeckoBundle.fromJSONObject(new JSONObject(source.readString())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert parcel to session state."); + res = null; + } + + return new SessionState(res); + } + + @Override + public SessionState[] newArray(final int size) { + return new SessionState[size]; + } + }; + + @Override /* AbstractSequentialList */ + public @NonNull HistoryDelegate.HistoryItem get(final int index) { + final GeckoBundle[] entries = getHistoryEntries(); + + if (entries == null || index < 0 || index >= entries.length) { + throw new NoSuchElementException(); + } + + return new SessionStateItem(entries[index]); + } + + @Override /* AbstractSequentialList */ + public @NonNull Iterator iterator() { + return listIterator(0); + } + + @Override /* AbstractSequentialList */ + public @NonNull ListIterator listIterator(final int index) { + return new SessionStateIterator(this, index); + } + + @Override /* AbstractSequentialList */ + public int size() { + final GeckoBundle[] entries = getHistoryEntries(); + + if (entries == null) { + Log.w(LOGTAG, "No history entries found."); + return 0; + } + + return entries.length; + } + + @Override /* HistoryList */ + public int getCurrentIndex() { + final GeckoBundle history = getHistory(); + + if (history == null) { + throw new IllegalStateException("No history state exists."); + } + + return history.getInt("index") + history.getInt("fromIdx"); + } + + // Some helpers for common code. + private GeckoBundle getHistory() { + if (mState == null) { + return null; + } + + return mState.getBundle("history"); + } + + private GeckoBundle[] getHistoryEntries() { + final GeckoBundle history = getHistory(); + + if (history == null) { + return null; + } + + return history.getBundleArray("entries"); + } + } + + private SessionState mStateCache = new SessionState(); + + /** + * Restore a saved state to this GeckoSession; only data that is saved (history, scroll position, + * zoom, and form data) will be restored. These will overwrite the corresponding state of this + * GeckoSession. + * + * @param state A saved session state; this should originate from onSessionStateChange(). + */ + @AnyThread + public void restoreState(final @NonNull SessionState state) { + mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState); + } + + /** + * Get whether this GeckoSession has form data. + * + * @return a {@link GeckoResult} result of if there is existing form data. + */ + @AnyThread + public @NonNull GeckoResult containsFormData() { + return mEventDispatcher.queryBoolean("GeckoView:ContainsFormData"); + } + + // This is the GeckoDisplay acquired via acquireDisplay(), if any. + private GeckoDisplay mDisplay; + + /* package */ interface Owner { + void onRelease(); + } + + private static final WeakReference NO_OWNER = new WeakReference<>(null); + private WeakReference mOwner = NO_OWNER; + + @UiThread + /* package */ void releaseOwner() { + ThreadUtils.assertOnUiThread(); + mOwner = NO_OWNER; + } + + @UiThread + /* package */ void setOwner(final Owner owner) { + ThreadUtils.assertOnUiThread(); + final Owner oldOwner = mOwner.get(); + if (oldOwner != null && owner != oldOwner) { + oldOwner.onRelease(); + } + mOwner = new WeakReference<>(owner); + } + + /* package */ GeckoDisplay getDisplay() { + return mDisplay; + } + + /** + * Acquire the GeckoDisplay instance for providing the session with a drawing Surface. Be sure to + * call {@link GeckoDisplay#surfaceChanged(SurfaceInfo)} on the acquired display if there is + * already a valid Surface. + * + * @return GeckoDisplay instance. + * @see #releaseDisplay(GeckoDisplay) + */ + @UiThread + public @NonNull GeckoDisplay acquireDisplay() { + ThreadUtils.assertOnUiThread(); + + if (mDisplay != null) { + throw new IllegalStateException("Display already acquired"); + } + + mDisplay = new GeckoDisplay(this); + return mDisplay; + } + + /** + * Release an acquired GeckoDisplay instance. Be sure to call {@link + * GeckoDisplay#surfaceDestroyed()} before releasing the display if it still has a valid Surface. + * + * @param display Acquired GeckoDisplay instance. + * @see #acquireDisplay() + */ + @UiThread + public void releaseDisplay(final @NonNull GeckoDisplay display) { + ThreadUtils.assertOnUiThread(); + + if (display != mDisplay) { + throw new IllegalArgumentException("Display not attached"); + } + + mDisplay = null; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull GeckoSessionSettings getSettings() { + return mSettings; + } + + /** Exits fullscreen mode */ + @AnyThread + public void exitFullScreen() { + mEventDispatcher.dispatch("GeckoViewContent:ExitFullScreen", null); + } + + /** + * Set the content callback handler. This will replace the current handler. + * + * @param delegate An implementation of ContentDelegate. + */ + @UiThread + public void setContentDelegate(final @Nullable ContentDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mContentHandler.setDelegate(delegate, this); + mProcessHangHandler.setDelegate(delegate, this); + } + + /** + * Get the content callback handler. + * + * @return The current content callback handler. + */ + @UiThread + public @Nullable ContentDelegate getContentDelegate() { + ThreadUtils.assertOnUiThread(); + return mContentHandler.getDelegate(); + } + + /** + * Set the progress callback handler. This will replace the current handler. + * + * @param delegate An implementation of ProgressDelegate. + */ + @UiThread + public void setProgressDelegate(final @Nullable ProgressDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mProgressHandler.setDelegate(delegate, this); + } + + /** + * Get the progress callback handler. + * + * @return The current progress callback handler. + */ + @UiThread + public @Nullable ProgressDelegate getProgressDelegate() { + ThreadUtils.assertOnUiThread(); + return mProgressHandler.getDelegate(); + } + + /** + * Set the navigation callback handler. This will replace the current handler. + * + * @param delegate An implementation of NavigationDelegate. + */ + @UiThread + public void setNavigationDelegate(final @Nullable NavigationDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mNavigationHandler.setDelegate(delegate, this); + } + + /** + * Get the navigation callback handler. + * + * @return The current navigation callback handler. + */ + @UiThread + public @Nullable NavigationDelegate getNavigationDelegate() { + ThreadUtils.assertOnUiThread(); + return mNavigationHandler.getDelegate(); + } + + /** + * Set the content scroll callback handler. This will replace the current handler. + * + * @param delegate An implementation of ScrollDelegate. + */ + @UiThread + public void setScrollDelegate(final @Nullable ScrollDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mScrollHandler.setDelegate(delegate, this); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable ScrollDelegate getScrollDelegate() { + ThreadUtils.assertOnUiThread(); + return mScrollHandler.getDelegate(); + } + + /** + * Set the history tracking delegate for this session, replacing the current delegate if one is + * set. + * + * @param delegate The history tracking delegate, or {@code null} to unset. + */ + @AnyThread + public void setHistoryDelegate(final @Nullable HistoryDelegate delegate) { + mHistoryHandler.setDelegate(delegate, this); + } + + /** + * @return The history tracking delegate for this session. + */ + @AnyThread + public @Nullable HistoryDelegate getHistoryDelegate() { + return mHistoryHandler.getDelegate(); + } + + /** + * Set the content blocking callback handler. This will replace the current handler. + * + * @param delegate An implementation of {@link ContentBlocking.Delegate}. + */ + @AnyThread + public void setContentBlockingDelegate(final @Nullable ContentBlocking.Delegate delegate) { + mContentBlockingHandler.setDelegate(delegate, this); + } + + /** + * Get the content blocking callback handler. + * + * @return The current content blocking callback handler. + */ + @AnyThread + public @Nullable ContentBlocking.Delegate getContentBlockingDelegate() { + return mContentBlockingHandler.getDelegate(); + } + + /** + * Set the current prompt delegate for this GeckoSession. + * + * @param delegate PromptDelegate instance or null to use the built-in delegate. + */ + @AnyThread + public void setPromptDelegate(final @Nullable PromptDelegate delegate) { + mPromptDelegate = delegate; + } + + /** + * Get the current prompt delegate for this GeckoSession. + * + * @return PromptDelegate instance or null if using built-in delegate. + */ + @AnyThread + public @Nullable PromptDelegate getPromptDelegate() { + return mPromptDelegate; + } + + /** + * Set the current selection action delegate for this GeckoSession. + * + * @param delegate SelectionActionDelegate instance or null to unset. + */ + @UiThread + public void setSelectionActionDelegate(final @Nullable SelectionActionDelegate delegate) { + ThreadUtils.assertOnUiThread(); + + if (getSelectionActionDelegate() != null) { + // When the delegate is changed or cleared, make sure onHideAction is called + // one last time to hide any existing selection action UI. Gecko doesn't keep + // track of the old delegate, so we can't rely on Gecko to do that for us. + getSelectionActionDelegate() + .onHideAction(this, GeckoSession.SelectionActionDelegate.HIDE_REASON_NO_SELECTION); + } + mSelectionActionDelegate.setDelegate(delegate, this); + } + + /** + * Set the media callback handler. This will replace the current handler. + * + * @param delegate An implementation of MediaDelegate. + */ + @AnyThread + public void setMediaDelegate(final @Nullable MediaDelegate delegate) { + mMediaHandler.setDelegate(delegate, this); + } + + /** + * Get the Media callback handler. + * + * @return The current Media callback handler. + */ + @AnyThread + public @Nullable MediaDelegate getMediaDelegate() { + return mMediaHandler.getDelegate(); + } + + /** + * Set the media session delegate. This will replace the current handler. + * + * @param delegate An implementation of {@link MediaSession.Delegate}. + */ + @AnyThread + public void setMediaSessionDelegate(final @Nullable MediaSession.Delegate delegate) { + mMediaSessionHandler.setDelegate(delegate, this); + } + + /** + * Get the media session delegate. + * + * @return The current media session delegate. + */ + @AnyThread + public @Nullable MediaSession.Delegate getMediaSessionDelegate() { + return mMediaSessionHandler.getDelegate(); + } + + /** + * Get the current selection action delegate for this GeckoSession. + * + * @return SelectionActionDelegate instance or null if not set. + */ + @AnyThread + public @Nullable SelectionActionDelegate getSelectionActionDelegate() { + return mSelectionActionDelegate.getDelegate(); + } + + @UiThread + protected void setShouldPinOnScreen(final boolean pinned) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mShouldPinOnScreen = pinned; + } + + /* package */ boolean shouldPinOnScreen() { + ThreadUtils.assertOnUiThread(); + return mShouldPinOnScreen; + } + + @AnyThread + /* package */ @NonNull + EventDispatcher getEventDispatcher() { + return mEventDispatcher; + } + + public interface ProgressDelegate { + /** Class representing security information for a site. */ + public class SecurityInformation { + @Retention(RetentionPolicy.SOURCE) + @IntDef({SECURITY_MODE_UNKNOWN, SECURITY_MODE_IDENTIFIED, SECURITY_MODE_VERIFIED}) + public @interface SecurityMode {} + + public static final int SECURITY_MODE_UNKNOWN = 0; + public static final int SECURITY_MODE_IDENTIFIED = 1; + public static final int SECURITY_MODE_VERIFIED = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({CONTENT_UNKNOWN, CONTENT_BLOCKED, CONTENT_LOADED}) + public @interface ContentType {} + + public static final int CONTENT_UNKNOWN = 0; + public static final int CONTENT_BLOCKED = 1; + public static final int CONTENT_LOADED = 2; + + /** Indicates whether or not the site is secure. */ + public final boolean isSecure; + + /** Indicates whether or not the site is a security exception. */ + public final boolean isException; + + /** Contains the origin of the certificate. */ + public final @Nullable String origin; + + /** Contains the host associated with the certificate. */ + public final @NonNull String host; + + /** The server certificate in use, if any. */ + public final @Nullable X509Certificate certificate; + + /** + * Indicates the security level of the site; possible values are SECURITY_MODE_UNKNOWN, + * SECURITY_MODE_IDENTIFIED, and SECURITY_MODE_VERIFIED. SECURITY_MODE_IDENTIFIED indicates + * domain validation only, while SECURITY_MODE_VERIFIED indicates extended validation. + */ + public final @SecurityMode int securityMode; + + /** + * Indicates the presence of passive mixed content; possible values are CONTENT_UNKNOWN, + * CONTENT_BLOCKED, and CONTENT_LOADED. + */ + public final @ContentType int mixedModePassive; + + /** + * Indicates the presence of active mixed content; possible values are CONTENT_UNKNOWN, + * CONTENT_BLOCKED, and CONTENT_LOADED. + */ + public final @ContentType int mixedModeActive; + + /* package */ SecurityInformation(final GeckoBundle identityData) { + final GeckoBundle mode = identityData.getBundle("mode"); + + mixedModePassive = mode.getInt("mixed_display"); + mixedModeActive = mode.getInt("mixed_active"); + + securityMode = mode.getInt("identity"); + + isSecure = identityData.getBoolean("secure"); + isException = identityData.getBoolean("securityException"); + origin = identityData.getString("origin"); + host = identityData.getString("host"); + + X509Certificate decodedCert = null; + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + final String certString = identityData.getString("certificate"); + if (certString != null) { + final byte[] certBytes = Base64.decode(certString, Base64.NO_WRAP); + decodedCert = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes)); + } + } catch (final CertificateException e) { + Log.e(LOGTAG, "Failed to decode certificate", e); + } + + certificate = decodedCert; + } + + /** Empty constructor for tests */ + protected SecurityInformation() { + mixedModePassive = CONTENT_UNKNOWN; + mixedModeActive = CONTENT_UNKNOWN; + securityMode = SECURITY_MODE_UNKNOWN; + isSecure = false; + isException = false; + origin = ""; + host = ""; + certificate = null; + } + } + + /** + * A View has started loading content from the network. + * + * @param session GeckoSession that initiated the callback. + * @param url The resource being loaded. + */ + @UiThread + default void onPageStart(@NonNull final GeckoSession session, @NonNull final String url) {} + + /** + * A View has finished loading content from the network. + * + * @param session GeckoSession that initiated the callback. + * @param success Whether the page loaded successfully or an error occurred. + */ + @UiThread + default void onPageStop(@NonNull final GeckoSession session, final boolean success) {} + + /** + * Page loading has progressed. + * + * @param session GeckoSession that initiated the callback. + * @param progress Current page load progress value [0, 100]. + */ + @UiThread + default void onProgressChange(@NonNull final GeckoSession session, final int progress) {} + + /** + * The security status has been updated. + * + * @param session GeckoSession that initiated the callback. + * @param securityInfo The new security information. + */ + @UiThread + default void onSecurityChange( + @NonNull final GeckoSession session, @NonNull final SecurityInformation securityInfo) {} + + /** + * The browser session state has changed. This can happen in response to navigation, scrolling, + * or form data changes; the session state passed includes the most up to date information on + * all of these. + * + * @param session GeckoSession that initiated the callback. + * @param sessionState SessionState representing the latest browser state. + */ + @UiThread + default void onSessionStateChange( + @NonNull final GeckoSession session, @NonNull final SessionState sessionState) {} + } + + /** WebResponseInfo contains information about a single web response. */ + @AnyThread + public static class WebResponseInfo { + /** The URI of the response. Cannot be null. */ + @NonNull public final String uri; + + /** The content type (mime type) of the response. May be null. */ + @Nullable public final String contentType; + + /** The content length of the response. May be 0 if unknokwn. */ + @Nullable public final long contentLength; + + /** The filename obtained from the content disposition, if any. May be null. */ + @Nullable public final String filename; + + /* package */ WebResponseInfo(final GeckoBundle message) { + uri = message.getString("uri"); + if (uri == null) { + throw new IllegalArgumentException("URI cannot be null"); + } + + contentType = message.getString("contentType"); + contentLength = message.getLong("contentLength"); + filename = message.getString("filename"); + } + + /** Empty constructor for tests. */ + protected WebResponseInfo() { + uri = ""; + contentType = ""; + contentLength = 0; + filename = ""; + } + } + + public interface ContentDelegate { + /** + * A page title was discovered in the content or updated after the content loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param title The title sent from the content. + */ + @UiThread + default void onTitleChange(@NonNull final GeckoSession session, @Nullable final String title) {} + + /** + * A preview image was discovered in the content after the content loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param previewImageUrl The preview image URL sent from the content. + */ + @UiThread + default void onPreviewImage( + @NonNull final GeckoSession session, @NonNull final String previewImageUrl) {} + + /** + * A page has requested focus. Note that window.focus() in content will not result in this being + * called. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onFocusRequest(@NonNull final GeckoSession session) {} + + /** + * A page has requested to close + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onCloseRequest(@NonNull final GeckoSession session) {} + + /** + * A page has entered or exited full screen mode. Typically, the implementation would set the + * Activity containing the GeckoSession to full screen when the page is in full screen mode. + * + * @param session The GeckoSession that initiated the callback. + * @param fullScreen True if the page is in full screen mode. + */ + @UiThread + default void onFullScreen(@NonNull final GeckoSession session, final boolean fullScreen) {} + + /** + * A viewport-fit was discovered in the content or updated after the content. + * + * @param session The GeckoSession that initiated the callback. + * @param viewportFit The value of viewport-fit of meta element in content. + * @see 4.1. The + * viewport-fit descriptor + */ + @UiThread + default void onMetaViewportFitChange( + @NonNull final GeckoSession session, @NonNull final String viewportFit) {} + + /** Element details for onContextMenu callbacks. */ + public static class ContextElement { + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO}) + public @interface Type {} + + public static final int TYPE_NONE = 0; + public static final int TYPE_IMAGE = 1; + public static final int TYPE_VIDEO = 2; + public static final int TYPE_AUDIO = 3; + + /** The base URI of the element's document. */ + public final @Nullable String baseUri; + + /** The absolute link URI (href) of the element. */ + public final @Nullable String linkUri; + + /** The title text of the element. */ + public final @Nullable String title; + + /** The alternative text (alt) for the element. */ + public final @Nullable String altText; + + /** The type of the element. One of the {@link ContextElement#TYPE_NONE} flags. */ + public final @Type int type; + + /** The source URI (src) of the element. Set for (nested) media elements. */ + public final @Nullable String srcUri; + + /** The text content of the element */ + public final @Nullable String textContent; + + // TODO: Bug 1595822 make public + final List extensionMenus; + + /** + * ContextElement constructor. + * + * @param baseUri The base URI. + * @param linkUri The absolute link URI (href). + * @param title The title text. + * @param altText The alternative text (alt). + * @param typeStr The type of the element. + * @param srcUri The source URI (src). + * @param textContent The text content. + */ + 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, + final @Nullable String textContent) { + this.baseUri = baseUri; + this.linkUri = linkUri; + this.title = title; + this.altText = altText; + this.type = getType(typeStr); + this.srcUri = srcUri; + this.textContent = textContent; + this.extensionMenus = null; + } + + 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, linkUri, title, altText, typeStr, srcUri, null); + } + + private static int getType(final String name) { + if ("HTMLImageElement".equals(name)) { + return TYPE_IMAGE; + } else if ("HTMLVideoElement".equals(name)) { + return TYPE_VIDEO; + } else if ("HTMLAudioElement".equals(name)) { + return TYPE_AUDIO; + } + return TYPE_NONE; + } + } + + /** + * A user has initiated the context menu via long-press. This event is fired on links, (nested) + * images and (nested) media elements. + * + * @param session The GeckoSession that initiated the callback. + * @param screenX The screen coordinates of the press. + * @param screenY The screen coordinates of the press. + * @param element The details for the pressed element. + */ + @UiThread + default void onContextMenu( + @NonNull final GeckoSession session, + final int screenX, + final int screenY, + @NonNull final ContextElement element) {} + + /** + * This is fired when there is a response that cannot be handled by Gecko (e.g., a download). + * + * @param session the GeckoSession that received the external response. + * @param response the external WebResponse. + */ + @UiThread + default void onExternalResponse( + @NonNull final GeckoSession session, @NonNull final WebResponse response) {} + + /** + * The content process hosting this GeckoSession has crashed. The GeckoSession is now closed and + * unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state is + * preserved. Most applications will want to call {@link #load} or {@link + * #restoreState(SessionState)} at this point. + * + * @param session The GeckoSession for which the content process has crashed. + */ + @UiThread + default void onCrash(@NonNull final GeckoSession session) {} + + /** + * The content process hosting this GeckoSession has been killed. The GeckoSession is now closed + * and unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state + * is preserved. Most applications will want to call {@link #load} or {@link + * #restoreState(SessionState)} at this point. + * + * @param session The GeckoSession for which the content process has been killed. + */ + @UiThread + default void onKill(@NonNull final GeckoSession session) {} + + /** + * Notification that the first content composition has occurred. This callback is invoked for + * the first content composite after either a start or a restart of the compositor. + * + * @param session The GeckoSession that had a first paint event. + */ + @UiThread + default void onFirstComposite(@NonNull final GeckoSession session) {} + + /** + * Notification that the first content paint has occurred. This callback is invoked for the + * first content paint after a page has been loaded, or after a {@link + * #onPaintStatusReset(GeckoSession)} event. The function {@link + * #onFirstComposite(GeckoSession)} will be called once the compositor has started rendering. + * However, it is possible for the compositor to start rendering before there is any content to + * render. onFirstContentfulPaint() is called once some content has been rendered. It may be + * nothing more than the page background color. It is not an indication that the whole page has + * been rendered. + * + * @param session The GeckoSession that had a first paint event. + */ + @UiThread + default void onFirstContentfulPaint(@NonNull final GeckoSession session) {} + + /** + * Notification that the paint status has been reset. + * + *

    This callback is invoked whenever the painted content is no longer being displayed. This + * can occur in response to the session being paused. After this has fired the compositor may + * continue rendering, but may not render the page content. This callback can therefore be used + * in conjunction with {@link #onFirstContentfulPaint(GeckoSession)} to determine when there is + * valid content being rendered. + * + * @param session The GeckoSession that had the paint status reset event. + */ + @UiThread + default void onPaintStatusReset(@NonNull final GeckoSession session) {} + + /** + * A page has requested to change pointer icon. + * + *

    If the application wants to control pointer icon, it should override this, then handle it. + * + * @param session The GeckoSession that initiated the callback. + * @param icon The pointer icon sent from the content. + */ + @TargetApi(Build.VERSION_CODES.N) + @UiThread + default void onPointerIconChange( + @NonNull final GeckoSession session, @NonNull final PointerIcon icon) { + final View view = session.getTextInput().getView(); + if (view != null) { + view.setPointerIcon(icon); + } + } + + /** + * This is fired when the loaded document has a valid Web App Manifest present. + * + *

    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 Web App Manifest specification + */ + @UiThread + default void onWebAppManifest( + @NonNull final GeckoSession session, @NonNull final JSONObject manifest) {} + + /** + * A script has exceeded its execution timeout value + * + * @param geckoSession GeckoSession that initiated the callback. + * @param scriptFileName Filename of the slow script + * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to + * allow the Slow Script to continue processing. Stop will halt the slow script. Continue + * will pause notifications for a period of time before resuming. + */ + @UiThread + default @Nullable GeckoResult onSlowScript( + @NonNull final GeckoSession geckoSession, @NonNull final String scriptFileName) { + return null; + } + + /** + * The app should display its dynamic toolbar, fully expanded to the height that was previously + * specified via {@link GeckoView#setDynamicToolbarMaxHeight}. + * + * @param geckoSession GeckoSession that initiated the callback. + */ + @UiThread + default void onShowDynamicToolbar(@NonNull final GeckoSession geckoSession) {} + + /** + * This method is called when a cookie banner was detected. + * + *

    Note: this method is called only if the cookie banner setting is such that allows to + * handle the banner. For example, if cookiebanners.service.mode=1 (Reject only) but a cookie + * banner can only be accepted on the website - the detection in that case won't be reported. + * The exception is MODE_DETECT_ONLY mode, when only the detection event is emitted. + * + * @param session GeckoSession that initiated the callback. + */ + @AnyThread + default void onCookieBannerDetected(@NonNull final GeckoSession session) {} + + /** + * This method is called when a cookie banner was handled. + * + * @param session GeckoSession that initiated the callback. + */ + @AnyThread + default void onCookieBannerHandled(@NonNull final GeckoSession session) {} + + /** + * This method is called when GeckoView is requesting a specific Nimbus feature in using message + * `GeckoView:GetNimbusFeature`. + * + * @param session GeckoSession that initiated the callback. + * @param featureId Nimbus feature id of the collected data. + * @return A {@link JSONObject} with the feature. + */ + @AnyThread + default @Nullable JSONObject onGetNimbusFeature( + @NonNull final GeckoSession session, @NonNull final String featureId) { + return null; + } + } + + public interface SelectionActionDelegate { + /** The selection is collapsed at a single position. */ + final int FLAG_IS_COLLAPSED = 1 << 0; + + /** + * The selection is inside editable content such as an input element or contentEditable node. + */ + final int FLAG_IS_EDITABLE = 1 << 1; + + /** The selection is inside a password field. */ + final int FLAG_IS_PASSWORD = 1 << 2; + + /** Hide selection actions and cause {@link #onHideAction} to be called. */ + final String ACTION_HIDE = "org.mozilla.geckoview.HIDE"; + + /** Copy onto the clipboard then delete the selected content. Selection must be editable. */ + final String ACTION_CUT = "org.mozilla.geckoview.CUT"; + + /** Copy the selected content onto the clipboard. */ + final String ACTION_COPY = "org.mozilla.geckoview.COPY"; + + /** Delete the selected content. Selection must be editable. */ + final String ACTION_DELETE = "org.mozilla.geckoview.DELETE"; + + /** Replace the selected content with the clipboard content. Selection must be editable. */ + final String ACTION_PASTE = "org.mozilla.geckoview.PASTE"; + + /** + * Replace the selected content with the clipboard content as plain text. Selection must be + * editable. + */ + final String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT"; + + /** Select the entire content of the document or editor. */ + final String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL"; + + /** Clear the current selection. Selection must not be editable. */ + final String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT"; + + /** Collapse the current selection to its start position. Selection must be editable. */ + final String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START"; + + /** Collapse the current selection to its end position. Selection must be editable. */ + final String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END"; + + /** Represents attributes of a selection. */ + class Selection { + /** + * Flags describing the current selection, as a bitwise combination of the {@link + * #FLAG_IS_COLLAPSED FLAG_*} constants. + */ + public final @SelectionActionDelegateFlag int flags; + + /** + * Text content of the current selection. An empty string indicates the selection is collapsed + * or the selection cannot be represented as plain text. + */ + public final @NonNull String text; + + /** The bounds of the current selection in screen coordinates. */ + public final @Nullable RectF screenRect; + + /** Set of valid actions available through {@link Selection#execute(String)} */ + public final @NonNull @SelectionActionDelegateAction Collection availableActions; + + private final String mActionId; + + private final WeakReference mEventDispatcher; + + /* package */ Selection( + final GeckoBundle bundle, + final @NonNull @SelectionActionDelegateAction Set actions, + final EventDispatcher eventDispatcher) { + flags = + (bundle.getBoolean("collapsed") ? SelectionActionDelegate.FLAG_IS_COLLAPSED : 0) + | (bundle.getBoolean("editable") ? SelectionActionDelegate.FLAG_IS_EDITABLE : 0) + | (bundle.getBoolean("password") ? SelectionActionDelegate.FLAG_IS_PASSWORD : 0); + text = bundle.getString("selection"); + screenRect = bundle.getRectF("screenRect"); + availableActions = actions; + mActionId = bundle.getString("actionId"); + mEventDispatcher = new WeakReference<>(eventDispatcher); + } + + /** Empty constructor for tests. */ + protected Selection() { + flags = 0; + text = ""; + screenRect = null; + availableActions = new HashSet<>(); + mActionId = null; + mEventDispatcher = null; + } + + /** + * Checks if the passed action is available + * + * @param action An {@link SelectionActionDelegate} to perform + * @return True if the action is available. + */ + @AnyThread + public boolean isActionAvailable( + @NonNull @SelectionActionDelegateAction final String action) { + return availableActions.contains(action); + } + + /** + * Execute an {@link SelectionActionDelegate} action. + * + * @throws IllegalStateException If the action was not available. + * @param action A {@link SelectionActionDelegate} action. + */ + @AnyThread + public void execute(@NonNull @SelectionActionDelegateAction final String action) { + if (!isActionAvailable(action)) { + throw new IllegalStateException("Action not available"); + } + final EventDispatcher eventDispatcher = mEventDispatcher.get(); + if (eventDispatcher == null) { + // The session is not available anymore, nothing really to do + Log.w(LOGTAG, "Calling execute on a stale Selection."); + return; + } + final GeckoBundle response = new GeckoBundle(2); + response.putString("id", action); + response.putString("actionId", mActionId); + eventDispatcher.dispatch("GeckoView:ExecuteSelectionAction", response); + } + + /** + * Hide selection actions and cause {@link #onHideAction} to be called. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void hide() { + execute(ACTION_HIDE); + } + + /** + * Copy onto the clipboard then delete the selected content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void cut() { + execute(ACTION_CUT); + } + + /** + * Copy the selected content onto the clipboard. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void copy() { + execute(ACTION_COPY); + } + + /** + * Delete the selected content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void delete() { + execute(ACTION_DELETE); + } + + /** + * Replace the selected content with the clipboard content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void paste() { + execute(ACTION_PASTE); + } + + /** + * Replace the selected content with the clipboard content as plain text. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void pasteAsPlainText() { + execute(ACTION_PASTE_AS_PLAIN_TEXT); + } + + /** + * Select the entire content of the document or editor. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void selectAll() { + execute(ACTION_SELECT_ALL); + } + + /** + * Clear the current selection. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void unselect() { + execute(ACTION_UNSELECT); + } + + /** + * Collapse the current selection to its start position. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void collapseToStart() { + execute(ACTION_COLLAPSE_TO_START); + } + + /** + * Collapse the current selection to its end position. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void collapseToEnd() { + execute(ACTION_COLLAPSE_TO_END); + } + } + + /** + * Selection actions are available. Selection actions become available when the user selects + * some content in the document or editor. Inside an editor, selection actions can also become + * available when the user explicitly requests editor action UI, for example by tapping on the + * caret handle. + * + *

    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 final GeckoSession session, @NonNull final Selection selection) {} + + /** Actions are no longer available due to the user clearing the selection. */ + final int HIDE_REASON_NO_SELECTION = 0; + + /** + * Actions are no longer available due to the user moving the selection out of view. Previous + * actions are still available after a callback with this reason. + */ + final int HIDE_REASON_INVISIBLE_SELECTION = 1; + + /** + * Actions are no longer available due to the user actively changing the selection. {@link + * #onShowActionRequest} may be called again once the user has set a selection, if the new + * selection has available actions. + */ + final int HIDE_REASON_ACTIVE_SELECTION = 2; + + /** + * Actions are no longer available due to the user actively scrolling the page. {@link + * #onShowActionRequest} may be called again once the user has stopped scrolling the page, if + * the selection is still visible. Until then, previous actions are still available after a + * callback with this reason. + */ + final int HIDE_REASON_ACTIVE_SCROLL = 3; + + /** + * Previous actions are no longer available due to the user interacting with the page. + * Applications typically hide the action toolbar in response. + * + * @param session The GeckoSession that initiated the callback. + * @param reason The reason that actions are no longer available, as one of the {@link + * #HIDE_REASON_NO_SELECTION HIDE_REASON_*} constants. + */ + @UiThread + default void onHideAction( + @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {} + + /** + * Permission for reading clipboard data. See: Clipboard.readText() + */ + int PERMISSION_CLIPBOARD_READ = 1; + + /** Represents attributes of a clipboard permission. */ + public class ClipboardPermission { + /** The URI associated with this content permission. */ + public final @NonNull String uri; + + /** + * The type of this permission; one of {@link #PERMISSION_CLIPBOARD_READ + * PERMISSION_CLIPBOARD_*}. + */ + public final @ClipboardPermissionType int type; + + /** + * The last mouse or touch location in screen coordinates when the permission is requested. + */ + public final @Nullable Point screenPoint; + + /** Empty constructor for tests */ + protected ClipboardPermission() { + this.uri = ""; + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = null; + } + + private ClipboardPermission(final @NonNull GeckoBundle bundle) { + this.uri = bundle.getString("uri"); + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = bundle.getPoint("screenPoint"); + } + } + + /** + * Request clipboard permission. + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @UiThread + default @Nullable GeckoResult onShowClipboardPermissionRequest( + @NonNull final GeckoSession session, @NonNull ClipboardPermission permission) { + return GeckoResult.deny(); + } + + /** + * Dismiss requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onDismissClipboardPermissionRequest(@NonNull final GeckoSession session) {} + } + + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SelectionActionDelegate.ACTION_HIDE, + SelectionActionDelegate.ACTION_CUT, + SelectionActionDelegate.ACTION_COPY, + SelectionActionDelegate.ACTION_DELETE, + SelectionActionDelegate.ACTION_PASTE, + SelectionActionDelegate.ACTION_PASTE_AS_PLAIN_TEXT, + SelectionActionDelegate.ACTION_SELECT_ALL, + SelectionActionDelegate.ACTION_UNSELECT, + SelectionActionDelegate.ACTION_COLLAPSE_TO_START, + SelectionActionDelegate.ACTION_COLLAPSE_TO_END + }) + public @interface SelectionActionDelegateAction {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SelectionActionDelegate.FLAG_IS_COLLAPSED, + SelectionActionDelegate.FLAG_IS_EDITABLE, + SelectionActionDelegate.FLAG_IS_PASSWORD + }) + public @interface SelectionActionDelegateFlag {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SelectionActionDelegate.HIDE_REASON_NO_SELECTION, + SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION, + SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION, + SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL + }) + public @interface SelectionActionDelegateHideReason {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SelectionActionDelegate.PERMISSION_CLIPBOARD_READ, + }) + public @interface ClipboardPermissionType {} + + public interface NavigationDelegate { + /** + * A view has started loading content from the network. + * + * @param session The GeckoSession that initiated the callback. + * @param url The resource being loaded. + * @param perms The permissions currently associated with this url. + */ + @UiThread + default void onLocationChange( + @NonNull GeckoSession session, + @Nullable String url, + final @NonNull List perms) {} + + /** + * The view's ability to go back has changed. + * + * @param session The GeckoSession that initiated the callback. + * @param canGoBack The new value for the ability. + */ + @UiThread + default void onCanGoBack(@NonNull final GeckoSession session, final boolean canGoBack) {} + + /** + * The view's ability to go forward has changed. + * + * @param session The GeckoSession that initiated the callback. + * @param canGoForward The new value for the ability. + */ + @UiThread + default void onCanGoForward(@NonNull final GeckoSession session, final boolean canGoForward) {} + + public static final int TARGET_WINDOW_NONE = 0; + public static final int TARGET_WINDOW_CURRENT = 1; + public static final int TARGET_WINDOW_NEW = 2; + + // Match with nsIWebNavigation.idl. + /** The load request was triggered by an HTTP redirect. */ + static final int LOAD_REQUEST_IS_REDIRECT = 0x800000; + + /** Load request details. */ + public static class LoadRequest { + /* package */ LoadRequest( + @NonNull final String uri, + @Nullable final String triggerUri, + final int geckoTarget, + final int flags, + final boolean hasUserGesture, + final boolean isDirectNavigation) { + this.uri = uri; + this.triggerUri = triggerUri; + this.target = convertGeckoTarget(geckoTarget); + this.isRedirect = (flags & LOAD_REQUEST_IS_REDIRECT) != 0; + this.hasUserGesture = hasUserGesture; + this.isDirectNavigation = isDirectNavigation; + } + + /** Empty constructor for tests. */ + protected LoadRequest() { + uri = ""; + triggerUri = null; + target = TARGET_WINDOW_NONE; + isRedirect = false; + hasUserGesture = false; + isDirectNavigation = false; + } + + // This needs to match nsIBrowserDOMWindow.idl + private @TargetWindow int convertGeckoTarget(final int geckoTarget) { + switch (geckoTarget) { + case 0: // OPEN_DEFAULTWINDOW + case 1: // OPEN_CURRENTWINDOW + return TARGET_WINDOW_CURRENT; + default: // OPEN_NEWWINDOW, OPEN_NEWTAB + return TARGET_WINDOW_NEW; + } + } + + /** The URI to be loaded. */ + public final @NonNull String uri; + + /** + * The URI of the origin page that triggered the load request. null for initial loads and + * loads originating from data: URIs. + */ + public final @Nullable String triggerUri; + + /** + * The target where the window has requested to open. One of {@link #TARGET_WINDOW_NONE + * TARGET_WINDOW_*}. + */ + public final @TargetWindow int target; + + /** + * True if and only if the request was triggered by an HTTP redirect. + * + *

    If the user loads URI "a", which redirects to URI "b", then onLoadRequest + * will be called twice, first with uri "a" and isRedirect = false, then with uri + * "b" and isRedirect = true. + */ + 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 onLoadRequest( + @NonNull final GeckoSession session, @NonNull final LoadRequest request) { + return null; + } + + /** + * A request to load a URI in a non-top-level context. + * + * @param session The GeckoSession that initiated the callback. + * @param request The {@link LoadRequest} containing the request details. + * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not + * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a + * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is + * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled). + */ + @UiThread + default @Nullable GeckoResult onSubframeLoadRequest( + @NonNull final GeckoSession session, @NonNull final LoadRequest request) { + return null; + } + + /** + * A request has been made to open a new session. The URI is provided only for informational + * purposes. Do not call GeckoSession.load here. Additionally, the returned GeckoSession must be + * a newly-created one. + * + * @param session The GeckoSession that initiated the callback. + * @param uri The URI to be loaded. + * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which + * case the request for a new window by web content will fail. e.g., window.open() + * 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 onNewSession( + @NonNull final GeckoSession session, @NonNull final String uri) { + return null; + } + + /** + * @param session The GeckoSession that initiated the callback. + * @param uri The URI that failed to load. + * @param error A WebRequestError containing details about the error + * @return A URI to display as an error. Returning null will halt the load entirely. The + * following special methods are made available to the URI: - + * document.addCertException(isTemporary), returns Promise - + * document.getFailedCertSecurityInfo(), returns FailedCertSecurityInfo - + * document.getNetErrorInfo(), returns NetErrorInfo document.reloadWithHttpsOnlyException() + * @see FailedCertSecurityInfo + * IDL + * @see NetErrorInfo + * IDL + */ + @UiThread + default @Nullable GeckoResult onLoadError( + @NonNull final GeckoSession session, + @Nullable final String uri, + @NonNull final WebRequestError error) { + return null; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + NavigationDelegate.TARGET_WINDOW_NONE, + NavigationDelegate.TARGET_WINDOW_CURRENT, + NavigationDelegate.TARGET_WINDOW_NEW + }) + public @interface TargetWindow {} + + /** + * GeckoSession applications implement this interface to handle prompts triggered by content in + * the GeckoSession, such as alerts, authentication dialogs, and select list pickers. + */ + public interface PromptDelegate { + /** PromptResponse is an opaque class created upon confirming or dismissing a prompt. */ + public class PromptResponse { + private final BasePrompt mPrompt; + + /* package */ PromptResponse(@NonNull final BasePrompt prompt) { + mPrompt = prompt; + } + + /* package */ void dispatch(@NonNull final EventCallback callback) { + if (mPrompt == null) { + throw new RuntimeException("Trying to confirm/dismiss a null prompt."); + } + mPrompt.dispatch(callback); + } + } + + interface PromptInstanceDelegate { + /** + * Called when this prompt has been dismissed by the system. + * + *

    This can happen e.g. when the page navigates away and the content of the prompt is not + * relevant anymore. + * + *

    When this method is called, you should hide the prompt UI elements. + * + * @param prompt the prompt that should be dismissed. + */ + @UiThread + default void onPromptDismiss(final @NonNull BasePrompt prompt) {} + + /** + * Called when this prompt has been updated. + * + *

    This is called if inner <option> elements are updated when using <select> + * element. + * + *

    When this method is called, you should update the prompt UI elements. + * + * @param prompt the new prompt that should be updated. + */ + @UiThread + default void onPromptUpdate(final @NonNull BasePrompt prompt) {} + } + + // Prompt classes. + public class BasePrompt { + private boolean mIsCompleted; + private boolean mIsConfirmed; + private GeckoBundle mResult; + private final WeakReference mObserver; + private PromptInstanceDelegate mDelegate; + + protected interface Observer { + @AnyThread + default void onPromptCompleted(@NonNull BasePrompt prompt) {} + } + + private void complete() { + mIsCompleted = true; + final Observer observer = mObserver.get(); + if (observer != null) { + observer.onPromptCompleted(this); + } + } + + /** The title of this prompt; may be null. */ + public final @Nullable String title; + + /* package */ String id; + + private BasePrompt( + @NonNull final String id, @Nullable final String title, final Observer observer) { + this.title = title; + this.id = id; + mIsConfirmed = false; + mIsCompleted = false; + mObserver = new WeakReference<>(observer); + } + + @UiThread + protected @NonNull PromptResponse confirm() { + if (mIsCompleted) { + throw new RuntimeException("Cannot confirm/dismiss a Prompt twice."); + } + + mIsConfirmed = true; + complete(); + return new PromptResponse(this); + } + + /** + * This dismisses the prompt without sending any meaningful information back to content. + * + * @return A {@link PromptResponse} with which you can complete the {@link GeckoResult} that + * corresponds to this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + if (mIsCompleted) { + throw new RuntimeException("Cannot confirm/dismiss a Prompt twice."); + } + + complete(); + return new PromptResponse(this); + } + + /** + * Set the delegate for this prompt. + * + * @param delegate the {@link PromptInstanceDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable PromptInstanceDelegate delegate) { + mDelegate = delegate; + } + + /** + * Get the delegate for this prompt. + * + * @return the {@link PromptInstanceDelegate} instance. + */ + @UiThread + @Nullable + public PromptInstanceDelegate getDelegate() { + return mDelegate; + } + + /* package */ GeckoBundle ensureResult() { + if (mResult == null) { + // Usually result object contains two items. + mResult = new GeckoBundle(2); + } + return mResult; + } + + /** + * This returns true if the prompt has already been confirmed or dismissed. + * + * @return A boolean which is true if the prompt has been confirmed or dismissed, and false + * otherwise. + */ + @UiThread + public boolean isComplete() { + return mIsCompleted; + } + + /* package */ void dispatch(@NonNull final EventCallback callback) { + if (!mIsCompleted) { + throw new RuntimeException("Trying to dispatch an incomplete prompt."); + } + + if (!mIsConfirmed) { + callback.sendSuccess(null); + } else { + callback.sendSuccess(mResult); + } + } + } + + /** + * BeforeUnloadPrompt represents the onbeforeunload prompt. See + * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload + */ + class BeforeUnloadPrompt extends BasePrompt { + protected BeforeUnloadPrompt(@NonNull final String id, @NonNull final Observer observer) { + super(id, null, observer); + } + + /** + * Confirms the prompt. + * + * @param allowOrDeny whether the navigation should be allowed to continue or not. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) { + ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY); + return super.confirm(); + } + } + + /** + * RepostConfirmPrompt represents a prompt shown whenever the browser needs to resubmit POST + * data (e.g. due to page refresh). + */ + class RepostConfirmPrompt extends BasePrompt { + protected RepostConfirmPrompt(@NonNull final String id, @NonNull final Observer observer) { + super(id, null, observer); + } + + /** + * Confirms the prompt. + * + * @param allowOrDeny whether the browser should allow resubmitting data. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) { + ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY); + return super.confirm(); + } + } + + /** + * AlertPrompt contains the information necessary to represent a JavaScript alert() call from + * content; it can only be dismissed, not confirmed. + */ + public class AlertPrompt extends BasePrompt { + /** The message to be displayed with this alert; may be null. */ + public final @Nullable String message; + + protected AlertPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + } + } + + /** + * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from + * content. + */ + public class ButtonPrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.POSITIVE, Type.NEGATIVE}) + public @interface ButtonType {} + + public static class Type { + /** Index of positive response button (eg, "Yes", "OK") */ + public static final int POSITIVE = 0; + + /** Index of negative response button (eg, "No", "Cancel") */ + public static final int NEGATIVE = 2; + + protected Type() {} + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + protected ButtonPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + } + + /** + * Confirms this prompt, returning the selected button to content. + * + * @param selection An int representing the selected button, must be one of {@link Type}. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@ButtonType final int selection) { + ensureResult().putInt("button", selection); + return super.confirm(); + } + } + + /** + * TextPrompt contains the information necessary to represent a Javascript prompt() call from + * content. + */ + public class TextPrompt extends BasePrompt { + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** The default value for the text field; may be null. */ + public final @Nullable String defaultValue; + + protected TextPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @Nullable final String defaultValue, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.defaultValue = defaultValue; + } + + /** + * Confirms this prompt, returning the input text to content. + * + * @param text A String containing the text input given by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String text) { + ensureResult().putString("text", text); + return super.confirm(); + } + } + + /** + * AuthPrompt contains the information necessary to represent an HTML authorization prompt + * generated by content. + */ + public class AuthPrompt extends BasePrompt { + public static class AuthOptions { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Flags.HOST, + Flags.PROXY, + Flags.ONLY_PASSWORD, + Flags.PREVIOUS_FAILED, + Flags.CROSS_ORIGIN_SUB_RESOURCE + }) + public @interface AuthFlag {} + + /** Auth prompt flags. */ + public static class Flags { + /** The auth prompt is for a network host. */ + public static final int HOST = 1 << 0; + + /** The auth prompt is for a proxy. */ + public static final int PROXY = 1 << 1; + + /** The auth prompt should only request a password. */ + public static final int ONLY_PASSWORD = 1 << 3; + + /** The auth prompt is the result of a previous failed login. */ + public static final int PREVIOUS_FAILED = 1 << 4; + + /** The auth prompt is for a cross-origin sub-resource. */ + public static final int CROSS_ORIGIN_SUB_RESOURCE = 1 << 5; + + protected Flags() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE}) + public @interface AuthLevel {} + + /** Auth prompt levels. */ + public static class Level { + /** The auth request is unencrypted or the encryption status is unknown. */ + public static final int NONE = 0; + + /** The auth request only encrypts password but not data. */ + public static final int PW_ENCRYPTED = 1; + + /** The auth request encrypts both password and data. */ + public static final int SECURE = 2; + + protected Level() {} + } + + /** An int bit-field of {@link Flags}. */ + public @AuthFlag final int flags; + + /** A string containing the URI for the auth request or null if unknown. */ + public @Nullable final String uri; + + /** An int, one of {@link Level}, indicating level of encryption. */ + public @AuthLevel final int level; + + /** A string containing the initial username or null if password-only. */ + public @Nullable final String username; + + /** A string containing the initial password. */ + public @Nullable final String password; + + /* package */ AuthOptions(final GeckoBundle options) { + flags = options.getInt("flags"); + uri = options.getString("uri"); + level = options.getInt("level"); + username = options.getString("username"); + password = options.getString("password"); + } + + /** Empty constructor for tests */ + protected AuthOptions() { + flags = 0; + uri = ""; + level = Level.NONE; + username = ""; + password = ""; + } + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** The {@link AuthOptions} that describe the type of authorization prompt. */ + public final @NonNull AuthOptions authOptions; + + protected AuthPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final AuthOptions authOptions, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.authOptions = authOptions; + } + + /** + * Confirms this prompt with just a password, returning the password to content. + * + * @param password A String containing the password input by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String password) { + ensureResult().putString("password", password); + return super.confirm(); + } + + /** + * Confirms this prompt with a username and password, returning both to content. + * + * @param username A String containing the username input by the user. + * @param password A String containing the password input by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final String username, @NonNull final String password) { + ensureResult().putString("username", username); + ensureResult().putString("password", password); + return super.confirm(); + } + } + + /** + * ChoicePrompt contains the information necessary to display a menu or list prompt generated by + * content. + */ + public class ChoicePrompt extends BasePrompt { + public static class Choice { + /** + * A boolean indicating if the item is disabled. Item should not be selectable if this is + * true. + */ + public final boolean disabled; + + /** + * A String giving the URI of the item icon, or null if none exists (only valid for menus) + */ + public final @Nullable String icon; + + /** A String giving the ID of the item or group */ + public final @NonNull String id; + + /** A Choice array of sub-items in a group, or null if not a group */ + public final @Nullable Choice[] items; + + /** A string giving the label for displaying the item or group */ + public final @NonNull String label; + + /** A boolean indicating if the item should be pre-selected (pre-checked for menu items) */ + public final boolean selected; + + /** A boolean indicating if the item should be a menu separator (only valid for menus) */ + public final boolean separator; + + /* package */ Choice(final GeckoBundle choice) { + disabled = choice.getBoolean("disabled"); + icon = choice.getString("icon"); + id = choice.getString("id"); + label = choice.getString("label"); + selected = choice.getBoolean("selected"); + separator = choice.getBoolean("separator"); + + final GeckoBundle[] choices = choice.getBundleArray("items"); + if (choices == null) { + items = null; + } else { + items = new Choice[choices.length]; + for (int i = 0; i < choices.length; i++) { + items[i] = new Choice(choices[i]); + } + } + } + + /** Empty constructor for tests. */ + protected Choice() { + disabled = false; + icon = ""; + id = ""; + label = ""; + selected = false; + separator = false; + items = null; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.MENU, Type.SINGLE, Type.MULTIPLE}) + public @interface ChoiceType {} + + public static class Type { + /** Display choices in a menu that dismisses as soon as an item is chosen. */ + public static final int MENU = 1; + + /** Display choices in a list that allows a single selection. */ + public static final int SINGLE = 2; + + /** Display choices in a list that allows multiple selections. */ + public static final int MULTIPLE = 3; + + protected Type() {} + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** One of {@link Type}. */ + public final @ChoiceType int type; + + /** An array of {@link Choice} representing possible choices. */ + public final @NonNull Choice[] choices; + + protected ChoicePrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @ChoiceType final int type, + @NonNull final Choice[] choices, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.type = type; + this.choices = choices; + } + + /** + * Confirms this prompt with the string id of a single choice. + * + * @param selectedId The string ID of the selected choice. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String selectedId) { + return confirm(new String[] {selectedId}); + } + + /** + * Confirms this prompt with the string ids of multiple choices + * + * @param selectedIds The string IDs of the selected choices. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String[] selectedIds) { + if ((Type.MENU == type || Type.SINGLE == type) + && (selectedIds == null || selectedIds.length != 1)) { + throw new IllegalArgumentException(); + } + ensureResult().putStringArray("choices", selectedIds); + return super.confirm(); + } + + /** + * Confirms this prompt with a single choice. + * + * @param selectedChoice The selected choice. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final Choice selectedChoice) { + return confirm(selectedChoice == null ? null : selectedChoice.id); + } + + /** + * Confirms this prompt with multiple choices. + * + * @param selectedChoices The selected choices. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final Choice[] selectedChoices) { + if ((Type.MENU == type || Type.SINGLE == type) + && (selectedChoices == null || selectedChoices.length != 1)) { + throw new IllegalArgumentException(); + } + + if (selectedChoices == null) { + return confirm((String[]) null); + } + + final String[] ids = new String[selectedChoices.length]; + for (int i = 0; i < ids.length; i++) { + ids[i] = (selectedChoices[i] == null) ? null : selectedChoices[i].id; + } + + return confirm(ids); + } + } + + /** + * ColorPrompt contains the information necessary to represent a prompt for color input + * generated by content. + */ + public class ColorPrompt extends BasePrompt { + /** The default value supplied by content. */ + public final @Nullable String defaultValue; + + /** The predefined values by <datalist> element */ + public final @Nullable String[] predefinedValues; + + protected ColorPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String defaultValue, + @Nullable final String[] predefinedValues, + @NonNull final Observer observer) { + super(id, title, observer); + this.defaultValue = defaultValue; + this.predefinedValues = predefinedValues; + } + + /** + * Confirms the prompt and passes the color value back to content. + * + * @param color A String representing the color to be returned to content. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String color) { + ensureResult().putString("color", color); + return super.confirm(); + } + } + + /** + * DateTimePrompt contains the information necessary to represent a prompt for date and/or time + * input generated by content. + */ + public class DateTimePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.DATE, Type.MONTH, Type.WEEK, Type.TIME, Type.DATETIME_LOCAL}) + public @interface DatetimeType {} + + public static class Type { + /** Prompt for year, month, and day. */ + public static final int DATE = 1; + + /** Prompt for year and month. */ + public static final int MONTH = 2; + + /** Prompt for year and week. */ + public static final int WEEK = 3; + + /** Prompt for hour and minute. */ + public static final int TIME = 4; + + /** Prompt for year, month, day, hour, and minute, without timezone. */ + public static final int DATETIME_LOCAL = 5; + + protected Type() {} + } + + /** One of {@link Type} indicating the type of prompt. */ + public final @DatetimeType int type; + + /** A String representing the default value supplied by content. */ + public final @Nullable String defaultValue; + + /** A String representing the minimum value allowed by content. */ + public final @Nullable String minValue; + + /** A String representing the maximum value allowed by content. */ + public final @Nullable String maxValue; + + /** A String representing the step value allowed by content. */ + public final @Nullable String stepValue; + + /** For testing. */ + private DateTimePrompt() { + // Initialize final members + super("", null, null); + this.type = Type.DATE; + this.defaultValue = null; + this.minValue = null; + this.maxValue = null; + this.stepValue = null; + } + + /* package */ DateTimePrompt( + @NonNull final String id, + @Nullable final String title, + @DatetimeType final int type, + @Nullable final String defaultValue, + @Nullable final String minValue, + @Nullable final String maxValue, + @Nullable final String stepValue, + @NonNull final Observer observer) { + super(id, title, observer); + this.type = type; + this.defaultValue = defaultValue; + this.minValue = minValue; + this.maxValue = maxValue; + this.stepValue = stepValue; + } + + /** + * Confirms the prompt and passes the date and/or time value back to content. + * + * @param datetime A String representing the date and time to be returned to content. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String datetime) { + ensureResult().putString("datetime", datetime); + return super.confirm(); + } + } + + /** + * FilePrompt contains the information necessary to represent a prompt for a file or files + * generated by content. + */ + public class FilePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.SINGLE, Type.MULTIPLE}) + public @interface FileType {} + + /** Types of file prompts. */ + public static class Type { + /** Prompt for a single file. */ + public static final int SINGLE = 1; + + /** Prompt for multiple files. */ + public static final int MULTIPLE = 2; + + protected Type() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Capture.NONE, Capture.ANY, Capture.USER, Capture.ENVIRONMENT}) + public @interface CaptureType {} + + /** Possible capture attribute values. */ + public static class Capture { + // These values should match the corresponding values in nsIFilePicker.idl + /** No capture attribute has been supplied by content. */ + public static final int NONE = 0; + + /** The capture attribute was supplied with a missing or invalid value. */ + public static final int ANY = 1; + + /** The "user" capture attribute has been supplied by content. */ + public static final int USER = 2; + + /** The "environment" capture attribute has been supplied by content. */ + public static final int ENVIRONMENT = 3; + + protected Capture() {} + } + + /** One of {@link Type} indicating the prompt type. */ + public final @FileType int type; + + /** + * An array of Strings giving the MIME types specified by the "accept" attribute, if any are + * specified. + */ + public final @Nullable String[] mimeTypes; + + /** One of {@link Capture} indicating the capture attribute supplied by content. */ + public final @CaptureType int capture; + + protected FilePrompt( + @NonNull final String id, + @Nullable final String title, + @FileType final int type, + @CaptureType final int capture, + @Nullable final String[] mimeTypes, + @NonNull final Observer observer) { + super(id, title, observer); + this.type = type; + this.capture = capture; + this.mimeTypes = mimeTypes; + } + + /** + * Confirms the prompt and passes the file URI back to content. + * + * @param context An Application context for parsing URIs. + * @param uri The URI of the file chosen by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final Context context, @NonNull final Uri uri) { + return confirm(context, new Uri[] {uri}); + } + + /** + * Confirms the prompt and passes the file URIs back to content. + * + * @param context An Application context for parsing URIs. + * @param uris The URIs of the files chosen by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final Context context, @NonNull final Uri[] uris) { + if (Type.SINGLE == type && (uris == null || uris.length != 1)) { + throw new IllegalArgumentException(); + } + + final String[] paths = new String[uris != null ? uris.length : 0]; + for (int i = 0; i < paths.length; i++) { + paths[i] = getFile(context, uris[i]); + if (paths[i] == null) { + Log.e(LOGTAG, "Only file URIs are supported: " + uris[i]); + } + } + ensureResult().putStringArray("files", paths); + + return super.confirm(); + } + + private static String getFile(final @NonNull Context context, final @NonNull Uri uri) { + if (uri == null) { + return null; + } + if ("file".equals(uri.getScheme())) { + return uri.getPath(); + } + final ContentResolver cr = context.getContentResolver(); + final Cursor cur = + cr.query( + uri, + new String[] {"_data"}, /* selection */ + null, + /* args */ null, /* sort */ + null); + if (cur == null) { + return null; + } + try { + final int idx = cur.getColumnIndex("_data"); + if (idx < 0 || !cur.moveToFirst()) { + return null; + } + do { + try { + final String path = cur.getString(idx); + if (path != null && !path.isEmpty()) { + return path; + } + } catch (final Exception e) { + } + } while (cur.moveToNext()); + } finally { + cur.close(); + } + return null; + } + } + + /** PopupPrompt contains the information necessary to represent a popup blocking request. */ + public class PopupPrompt extends BasePrompt { + /** The target URI for the popup; may be null. */ + public final @Nullable String targetUri; + + protected PopupPrompt( + @NonNull final String id, + @Nullable final String targetUri, + @NonNull final Observer observer) { + super(id, null, observer); + this.targetUri = targetUri; + } + + /** + * Confirms the prompt and either allows or blocks the popup. + * + * @param response An {@link AllowOrDeny} specifying whether to allow or deny the popup. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final AllowOrDeny response) { + boolean res = false; + if (AllowOrDeny.ALLOW == response) { + res = true; + } + ensureResult().putBoolean("response", res); + return super.confirm(); + } + } + + /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */ + public class SharePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Result.SUCCESS, Result.FAILURE, Result.ABORT}) + public @interface ShareResult {} + + /** Possible results to a {@link SharePrompt}. */ + public static class Result { + /** The user shared with another app successfully. */ + public static final int SUCCESS = 0; + + /** The user attempted to share with another app, but it failed. */ + public static final int FAILURE = 1; + + /** The user aborted the share. */ + public static final int ABORT = 2; + + protected Result() {} + } + + /** The text for the share request. */ + public final @Nullable String text; + + /** The uri for the share request. */ + public final @Nullable String uri; + + protected SharePrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String text, + @Nullable final String uri, + @NonNull final Observer observer) { + super(id, title, observer); + this.text = text; + this.uri = uri; + } + + /** + * Confirms the prompt and either blocks or allows the share request. + * + * @param response One of {@link Result} specifying the outcome of the share attempt. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@ShareResult final int response) { + ensureResult().putInt("response", response); + return super.confirm(); + } + + /** + * Dismisses the prompt and returns {@link Result#ABORT} to web content. + * + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + ensureResult().putInt("response", Result.ABORT); + return super.dismiss(); + } + } + + /** Request containing information required to resolve Autocomplete prompt requests. */ + public class AutocompleteRequest> extends BasePrompt { + /** + * The Autocomplete options for this request. This can contain a single or multiple entries. + */ + public final @NonNull T[] options; + + protected AutocompleteRequest( + final @NonNull String id, final @NonNull T[] options, final Observer observer) { + super(id, null, observer); + this.options = options; + } + + /** + * Confirm the request by responding with a selection. See the PromptDelegate callbacks for + * specifics. + * + * @param selection The {@link Autocomplete.Option} used to confirm the request. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @NonNull Autocomplete.Option selection) { + ensureResult().putBundle("selection", selection.toBundle()); + return super.confirm(); + } + + /** + * Dismiss the request. See the PromptDelegate callbacks for specifics. + * + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + return super.dismiss(); + } + } + + // Delegate functions. + /** + * Display an alert prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link AlertPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult 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 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 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 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 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 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 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 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 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 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 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 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.StorageDelegate#onLoginSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created login entry. + *

    Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult onLoginSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest request) { + return null; + } + + /** + * Handle a address save prompt request. This is triggered by the user entering new or modified + * address credentials into a address form. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse}. + *

    Confirm the request with an {@link Autocomplete.Option} to trigger a {@link + * Autocomplete.StorageDelegate#onAddressSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created address entry. + *

    Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult onAddressSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest request) { + return null; + } + + /** + * Handle a credit card save prompt request. This is triggered by the user entering new or + * modified credit card credentials into a form. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse}. + *

    Confirm the request with an {@link Autocomplete.Option} to trigger a {@link + * Autocomplete.StorageDelegate#onCreditCardSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created credit card entry. + *

    Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult onCreditCardSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest 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 onLoginSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest request) { + return null; + } + + /** + * Handle a credit card selection prompt request. This is triggered by the user focusing on a + * credit card input field. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} + *

    Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the + * credit card forms with the given selection details. The confirmed selection may be an + * entry out of the request's options, a modified option, or a freshly created credit card + * entry. + *

    Dismiss the request to deny autocompletion for the detected form. + */ + @UiThread + default @Nullable GeckoResult onCreditCardSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest request) { + return null; + } + + /** + * Handle a address selection prompt request. This is triggered by the user focusing on a + * address field. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} + *

    Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the + * address forms with the given selection details. The confirmed selection may be an entry + * out of the request's options, a modified option, or a freshly created address entry. + *

    Dismiss the request to deny autocompletion for the detected form. + */ + @UiThread + default @Nullable GeckoResult onAddressSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest request) { + return null; + } + } + + /** GeckoSession applications implement this interface to handle content scroll events. */ + public interface ScrollDelegate { + /** + * The scroll position of the content has changed. + * + * @param session GeckoSession that initiated the callback. + * @param scrollX The new horizontal scroll position in pixels. + * @param scrollY The new vertical scroll position in pixels. + */ + @UiThread + default void onScrollChanged( + @NonNull final GeckoSession session, final int scrollX, final int scrollY) {} + } + + /** + * Get the PanZoomController instance for this session. + * + * @return PanZoomController instance. + */ + @UiThread + public @NonNull PanZoomController getPanZoomController() { + ThreadUtils.assertOnUiThread(); + + return mPanZoomController; + } + + /** + * Get the OverscrollEdgeEffect instance for this session. + * + * @return OverscrollEdgeEffect instance. + */ + @UiThread + public @NonNull OverscrollEdgeEffect getOverscrollEdgeEffect() { + ThreadUtils.assertOnUiThread(); + + if (mOverscroll == null) { + mOverscroll = new OverscrollEdgeEffect(); + } + return mOverscroll; + } + + /** + * Get the CompositorController instance for this session. + * + * @return CompositorController instance. + */ + @UiThread + public @NonNull CompositorController getCompositorController() { + ThreadUtils.assertOnUiThread(); + + if (mController == null) { + mController = new CompositorController(this); + if (mCompositorReady) { + mController.onCompositorReady(); + } + } + return mController; + } + + /** + * Get a matrix for transforming from client coordinates to surface coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToScreenMatrix(Matrix) + * @see #getPageToSurfaceMatrix(Matrix) + */ + @UiThread + public void getClientToSurfaceMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + matrix.setScale(mViewportZoom, mViewportZoom); + if (mClientTop != mTop) { + matrix.postTranslate(0, mClientTop - mTop); + } + } + + /** + * Get a matrix for transforming from client coordinates to screen coordinates. The client + * coordinates are in CSS pixels and are relative to the viewport origin; their relation to screen + * coordinates does not depend on the current scroll position. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToSurfaceMatrix(Matrix) + * @see #getPageToScreenMatrix(Matrix) + */ + @UiThread + public void getClientToScreenMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getClientToSurfaceMatrix(matrix); + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get a matrix for transforming from page coordinates to screen coordinates. The page coordinates + * are in CSS pixels and are relative to the page origin; their relation to screen coordinates + * depends on the current scroll position of the outermost frame. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getPageToSurfaceMatrix(Matrix) + * @see #getClientToScreenMatrix(Matrix) + */ + @UiThread + public void getPageToScreenMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getPageToSurfaceMatrix(matrix); + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get a matrix for transforming from page coordinates to surface coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getPageToScreenMatrix(Matrix) + * @see #getClientToSurfaceMatrix(Matrix) + */ + @UiThread + public void getPageToSurfaceMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getClientToSurfaceMatrix(matrix); + matrix.postTranslate(-mViewportLeft, -mViewportTop); + } + + /** + * Get a matrix for transforming from layout device client coordinates to screen coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToScreenMatrix(Matrix) + * @see #getPageToSurfaceMatrix(Matrix) + */ + @UiThread + /* package */ void getClientToScreenOffsetMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get a matrix for transforming from screen coordinates to Android's current window coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see + * https://developer.android.com/guide/topics/large-screens/multi-window-support#window_metrics + */ + @UiThread + /* package */ void getScreenToWindowManagerOffsetMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final WindowManager wm = + (WindowManager) + GeckoAppShell.getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Rect currentWindowRect = wm.getCurrentWindowMetrics().getBounds(); + matrix.postTranslate(-currentWindowRect.left, -currentWindowRect.top); + return; + } + + // TODO(m_kato): Bug 1678531 + // How to get window coordinate on Android 7-10 that supports split window? + } + + /** + * 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; + + /** + * Permission for trackers to operate on the page -- disables all tracking protection features + * for a given site. + */ + int PERMISSION_TRACKING = 7; + + /** + * Permission for third party frames to access first party cookies and storage. May be granted + * heuristically in some cases. + */ + int PERMISSION_STORAGE_ACCESS = 8; + + /** + * Represents a content permission -- including the type of permission, the present value of the + * permission, the URL the permission pertains to, and other information. + */ + class ContentPermission { + @Retention(RetentionPolicy.SOURCE) + @IntDef({VALUE_PROMPT, VALUE_DENY, VALUE_ALLOW}) + public @interface Value {} + + /** The corresponding permission is currently set to default/prompt behavior. */ + public static final int VALUE_PROMPT = 3; + + /** The corresponding permission is currently set to deny. */ + public static final int VALUE_DENY = 2; + + /** The corresponding permission is currently set to allow. */ + public static final int VALUE_ALLOW = 1; + + /** The URI associated with this content permission. */ + public final @NonNull String uri; + + /** + * The third party origin associated with the request; currently only used for storage access + * permission. + */ + public final @Nullable String thirdPartyOrigin; + + /** + * A boolean indicating whether this content permission is associated with private browsing. + */ + public final boolean privateMode; + + /** The type of this permission; one of {@link #PERMISSION_GEOLOCATION PERMISSION_*}. */ + public final int permission; + + /** The value of the permission; one of {@link #VALUE_PROMPT VALUE_}. */ + public final @Value int value; + + /** + * The context ID associated with the permission if any. + * + * @see GeckoSessionSettings.Builder#contextId + */ + public final @Nullable String contextId; + + private final String mPrincipal; + + protected ContentPermission() { + this.uri = ""; + this.thirdPartyOrigin = null; + this.privateMode = false; + this.permission = PERMISSION_GEOLOCATION; + this.value = VALUE_ALLOW; + this.mPrincipal = ""; + this.contextId = null; + } + + private ContentPermission(final @NonNull GeckoBundle bundle) { + this.uri = bundle.getString("uri"); + this.mPrincipal = bundle.getString("principal"); + this.privateMode = bundle.getBoolean("privateMode"); + + final String permission = bundle.getString("perm"); + this.permission = convertType(permission); + if (permission.startsWith("3rdPartyStorage^")) { + // Storage access permissions are stored with the key "3rdPartyStorage^https://foo.com" + // where the third party origin is "https://foo.com". + this.thirdPartyOrigin = permission.substring(16); + } else { + this.thirdPartyOrigin = bundle.getString("thirdPartyOrigin"); + } + + this.value = bundle.getInt("value"); + this.contextId = + StorageController.retrieveUnsafeSessionContextId(bundle.getString("contextId")); + } + + /** + * Converts a JSONObject to a ContentPermission -- should only be used on the output of {@link + * #toJson()}. + * + * @param perm A JSONObject representing a ContentPermission, output by {@link #toJson()}. + * @return The corresponding ContentPermission. + */ + @AnyThread + public static @Nullable ContentPermission fromJson(final @NonNull JSONObject perm) { + ContentPermission res = null; + try { + res = new ContentPermission(GeckoBundle.fromJSONObject(perm)); + } catch (final JSONException e) { + Log.w(LOGTAG, "Failed to create ContentPermission; invalid JSONObject.", e); + } + return res; + } + + /** + * Converts a ContentPermission to a JSONObject that can be converted back to a + * ContentPermission by {@link #fromJson(JSONObject)}. + * + * @return A JSONObject representing this ContentPermission. Modifying any of the fields may + * result in undefined behavior when converted back to a ContentPermission and used. + * @throws JSONException if the conversion fails for any reason. + */ + @AnyThread + public @NonNull JSONObject toJson() throws JSONException { + return toGeckoBundle().toJSONObject(); + } + + private static int convertType(final @NonNull String type) { + if ("geolocation".equals(type)) { + return PERMISSION_GEOLOCATION; + } else if ("desktop-notification".equals(type)) { + return PERMISSION_DESKTOP_NOTIFICATION; + } else if ("persistent-storage".equals(type)) { + return PERMISSION_PERSISTENT_STORAGE; + } else if ("xr".equals(type)) { + return PERMISSION_XR; + } else if ("autoplay-media-inaudible".equals(type)) { + return PERMISSION_AUTOPLAY_INAUDIBLE; + } else if ("autoplay-media-audible".equals(type)) { + return PERMISSION_AUTOPLAY_AUDIBLE; + } else if ("media-key-system-access".equals(type)) { + return PERMISSION_MEDIA_KEY_SYSTEM_ACCESS; + } else if ("trackingprotection".equals(type) || "trackingprotection-pb".equals(type)) { + return PERMISSION_TRACKING; + } else if ("storage-access".equals(type) || type.startsWith("3rdPartyStorage^")) { + return PERMISSION_STORAGE_ACCESS; + } else { + return -1; + } + } + + // This also gets used in StorageController, so it's package rather than private. + /* package */ static String convertType(final int type, final boolean privateMode) { + switch (type) { + case PERMISSION_GEOLOCATION: + return "geolocation"; + case PERMISSION_DESKTOP_NOTIFICATION: + return "desktop-notification"; + case PERMISSION_PERSISTENT_STORAGE: + return "persistent-storage"; + case PERMISSION_XR: + return "xr"; + case PERMISSION_AUTOPLAY_INAUDIBLE: + return "autoplay-media-inaudible"; + case PERMISSION_AUTOPLAY_AUDIBLE: + return "autoplay-media-audible"; + case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS: + return "media-key-system-access"; + case PERMISSION_TRACKING: + return privateMode ? "trackingprotection-pb" : "trackingprotection"; + case PERMISSION_STORAGE_ACCESS: + return "storage-access"; + default: + return ""; + } + } + + /* package */ static @NonNull ArrayList fromBundleArray( + final @NonNull GeckoBundle[] bundleArray) { + final ArrayList res = new ArrayList(); + if (bundleArray == null) { + return res; + } + + for (final GeckoBundle bundle : bundleArray) { + final ContentPermission temp = new ContentPermission(bundle); + if (temp.permission == -1 || temp.value < 1 || temp.value > 3) { + continue; + } + res.add(temp); + } + return res; + } + + /* package */ @NonNull + GeckoBundle toGeckoBundle() { + final GeckoBundle res = new GeckoBundle(7); + res.putString("uri", uri); + res.putString("thirdPartyOrigin", thirdPartyOrigin); + res.putString("principal", mPrincipal); + res.putBoolean("privateMode", privateMode); + res.putString("perm", convertType(permission, privateMode)); + res.putInt("value", value); + res.putString("contextId", contextId); + return res; + } + } + + /** Callback interface for notifying the result of a permission request. */ + interface Callback { + /** + * Called by the implementation after permissions are granted; the implementation must call + * either grant() or reject() for every request. + */ + @UiThread + default void grant() {} + + /** + * Called by the implementation when permissions are not granted; the implementation must call + * either grant() or reject() for every request. + */ + @UiThread + default void reject() {} + } + + /** + * Request Android app permissions. + * + * @param session GeckoSession instance requesting the permissions. + * @param permissions List of permissions to request; possible values are, + * android.Manifest.permission.ACCESS_COARSE_LOCATION + * android.Manifest.permission.ACCESS_FINE_LOCATION android.Manifest.permission.CAMERA + * android.Manifest.permission.RECORD_AUDIO + * @param callback Callback interface. + */ + @UiThread + default void onAndroidPermissionsRequest( + @NonNull final GeckoSession session, + @Nullable final String[] permissions, + @NonNull final Callback callback) { + callback.reject(); + } + + /** + * Request content permission. + * + *

    Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted + * for a site, it cannot be revoked. If the permission has previously been granted, it is the + * responsibility of the consuming app to remember the permission and prevent the prompt from + * being redisplayed to the user. + * + * @param session GeckoSession instance requesting the permission. + * @param perm An {@link ContentPermission} describing the permission being requested and its + * current status. + * @return A {@link GeckoResult} resolving to one of {@link ContentPermission#VALUE_PROMPT + * VALUE_*}, determining the response to the permission request and updating the permissions + * for this site. + */ + @UiThread + default @Nullable GeckoResult onContentPermissionRequest( + @NonNull final GeckoSession session, @NonNull ContentPermission perm) { + return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT); + } + + class MediaSource { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SOURCE_CAMERA, SOURCE_SCREEN, + SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, + SOURCE_OTHER + }) + public @interface Source {} + + /** Constant to indicate that camera will be recorded. */ + public static final int SOURCE_CAMERA = 0; + + /** Constant to indicate that screen will be recorded. */ + public static final int SOURCE_SCREEN = 1; + + /** Constant to indicate that microphone will be recorded. */ + public static final int SOURCE_MICROPHONE = 2; + + /** Constant to indicate that device audio playback will be recorded. */ + public static final int SOURCE_AUDIOCAPTURE = 3; + + /** Constant to indicate a media source that does not fall under the other categories. */ + public static final int SOURCE_OTHER = 4; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_VIDEO, TYPE_AUDIO}) + public @interface Type {} + + /** The media type is video. */ + public static final int TYPE_VIDEO = 0; + + /** The media type is audio. */ + public static final int TYPE_AUDIO = 1; + + /** A string giving a unique source identifier. */ + public final @NonNull String id; + + /** + * A string giving the name of the video source from the system (for example, "Camera 0, + * Facing back, Orientation 90"). May be empty. + */ + public final @Nullable String name; + + /** + * An int indicating the media source type. Possible values for a video source are: + * SOURCE_CAMERA, SOURCE_SCREEN, and SOURCE_OTHER. Possible values for an audio source are: + * SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, and SOURCE_OTHER. + */ + public final @Source int source; + + /** An int giving the type of media, must be either TYPE_VIDEO or TYPE_AUDIO. */ + public final @Type int type; + + private static @Source int getSourceFromString(final String src) { + // The strings here should match those in MediaSourceEnum in MediaStreamTrack.webidl + if ("camera".equals(src)) { + return SOURCE_CAMERA; + } else if ("screen".equals(src) || "window".equals(src) || "browser".equals(src)) { + return SOURCE_SCREEN; + } else if ("microphone".equals(src)) { + return SOURCE_MICROPHONE; + } else if ("audioCapture".equals(src)) { + return SOURCE_AUDIOCAPTURE; + } else if ("other".equals(src) || "application".equals(src)) { + return SOURCE_OTHER; + } else { + throw new IllegalArgumentException( + "String: " + src + " is not a valid media source string"); + } + } + + private static @Type int getTypeFromString(final String type) { + // The strings here should match the possible types in MediaDevice::MediaDevice in + // MediaManager.cpp + if ("videoinput".equals(type)) { + return TYPE_VIDEO; + } else if ("audioinput".equals(type)) { + return TYPE_AUDIO; + } else { + throw new IllegalArgumentException( + "String: " + type + " is not a valid media type string"); + } + } + + /* package */ MediaSource(final GeckoBundle media) { + id = media.getString("id"); + name = media.getString("name"); + source = getSourceFromString(media.getString("mediaSource")); + type = getTypeFromString(media.getString("type")); + } + + /** Empty constructor for tests. */ + protected MediaSource() { + id = null; + name = null; + source = SOURCE_CAMERA; + type = TYPE_VIDEO; + } + } + + /** + * Callback interface for notifying the result of a media permission request, including which + * media source(s) to use. + */ + interface MediaCallback { + /** + * Called by the implementation after permissions are granted; the implementation must call + * one of grant() or reject() for every request. + * + * @param video "id" value from the bundle for the video source to use, or null when video is + * not requested. + * @param audio "id" value from the bundle for the audio source to use, or null when audio is + * not requested. + */ + @UiThread + default void grant(final @Nullable String video, final @Nullable String audio) {} + + /** + * Called by the implementation after permissions are granted; the implementation must call + * one of grant() or reject() for every request. + * + * @param video MediaSource for the video source to use (must be an original MediaSource + * object that was passed to the implementation); or null when video is not requested. + * @param audio MediaSource for the audio source to use (must be an original MediaSource + * object that was passed to the implementation); or null when audio is not requested. + */ + @UiThread + default void grant(final @Nullable MediaSource video, final @Nullable MediaSource audio) {} + + /** + * Called by the implementation when permissions are not granted; the implementation must call + * one of grant() or reject() for every request. + */ + @UiThread + default void reject() {} + } + + /** + * Request content media permissions, including request for which video and/or audio source to + * use. + * + *

    Media permissions will still be requested if the associated device permissions have been + * denied if there are video or audio sources in that category that can still be accessed. It is + * the responsibility of consumers to ensure that media permission requests are not displayed in + * this case. + * + * @param session GeckoSession instance requesting the permission. + * @param uri The URI of the content requesting the permission. + * @param video List of video sources, or null if not requesting video. + * @param audio List of audio sources, or null if not requesting audio. + * @param callback Callback interface. + */ + @UiThread + default void onMediaPermissionRequest( + @NonNull final GeckoSession session, + @NonNull final String uri, + @Nullable final MediaSource[] video, + @Nullable final MediaSource[] audio, + @NonNull final MediaCallback callback) { + callback.reject(); + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + PermissionDelegate.PERMISSION_GEOLOCATION, + PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION, + PermissionDelegate.PERMISSION_PERSISTENT_STORAGE, + PermissionDelegate.PERMISSION_XR, + PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE, + PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE, + PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, + PermissionDelegate.PERMISSION_TRACKING, + PermissionDelegate.PERMISSION_STORAGE_ACCESS + }) + public @interface Permission {} + + /** + * Interface that SessionTextInput uses for performing operations such as opening and closing the + * software keyboard. If the delegate is not set, these operations are forwarded to the system + * {@link android.view.inputmethod.InputMethodManager} automatically. + */ + public interface TextInputDelegate { + /** Restarting input due to an input field gaining focus. */ + int RESTART_REASON_FOCUS = 0; + + /** Restarting input due to an input field losing focus. */ + int RESTART_REASON_BLUR = 1; + + /** + * Restarting input due to the content of the input field changing. For example, the input field + * type may have changed, or the current composition may have been committed outside of the + * input method. + */ + int RESTART_REASON_CONTENT_CHANGE = 2; + + /** + * Reset the input method, and discard any existing states such as the current composition or + * current autocompletion. Because the current focused editor may have changed, as part of the + * reset, a custom input method would normally call {@link + * SessionTextInput#onCreateInputConnection} to update its knowledge of the focused editor. Note + * that {@code restartInput} should be used to detect changes in focus, rather than {@link + * #showSoftInput} or {@link #hideSoftInput}, because focus changes are not always accompanied + * by requests to show or hide the soft input. This method is always called, even in viewless + * mode. + * + * @param session Session instance. + * @param reason Reason for the reset. + */ + @UiThread + default void restartInput( + @NonNull final GeckoSession session, @RestartReason final int reason) {} + + /** + * Display the soft input. May be called consecutively, even if the soft input is already shown. + * This method is always called, even in viewless mode. + * + * @param session Session instance. + * @see #hideSoftInput + */ + @UiThread + default void showSoftInput(@NonNull final GeckoSession session) {} + + /** + * Hide the soft input. May be called consecutively, even if the soft input is already hidden. + * This method is always called, even in viewless mode. + * + * @param session Session instance. + * @see #showSoftInput + */ + @UiThread + default void hideSoftInput(@NonNull final GeckoSession session) {} + + /** + * Update the soft input on the current selection. This method is not called in viewless + * mode. + * + * @param session Session instance. + * @param selStart Start offset of the selection. + * @param selEnd End offset of the selection. + * @param compositionStart Composition start offset, or -1 if there is no composition. + * @param compositionEnd Composition end offset, or -1 if there is no composition. + */ + @UiThread + default void updateSelection( + @NonNull final GeckoSession session, + final int selStart, + final int selEnd, + final int compositionStart, + final int compositionEnd) {} + + /** + * Update the soft input on the current extracted text, as requested through {@link + * android.view.inputmethod.InputConnection#getExtractedText}. Consequently, this method is + * not called in viewless mode. + * + * @param session Session instance. + * @param request The extract text request. + * @param text The extracted text. + */ + @UiThread + default void updateExtractedText( + @NonNull final GeckoSession session, + @NonNull final ExtractedTextRequest request, + @NonNull final ExtractedText text) {} + + /** + * Update the cursor-anchor information as requested through {@link + * android.view.inputmethod.InputConnection#requestCursorUpdates}. Consequently, this method is + * not called in viewless mode. + * + * @param session Session instance. + * @param info Cursor-anchor information. + */ + @UiThread + default void updateCursorAnchorInfo( + @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TextInputDelegate.RESTART_REASON_FOCUS, + TextInputDelegate.RESTART_REASON_BLUR, + TextInputDelegate.RESTART_REASON_CONTENT_CHANGE + }) + public @interface RestartReason {} + + /* package */ void onSurfaceChanged(final @NonNull SurfaceInfo surfaceInfo) { + ThreadUtils.assertOnUiThread(); + + mWidth = surfaceInfo.mWidth; + mHeight = surfaceInfo.mHeight; + mNewSurfaceProvider = surfaceInfo.mNewSurfaceProvider; + + if (mCompositorReady) { + mCompositor.syncResumeResizeCompositor( + surfaceInfo.mLeft, + surfaceInfo.mTop, + surfaceInfo.mWidth, + surfaceInfo.mHeight, + surfaceInfo.mSurface, + surfaceInfo.mSurfaceControl); + onWindowBoundsChanged(); + return; + } + + // We have a valid surface but we're not attached or the compositor + // is not ready; save the surface for later when we're ready. + mSurfaceInfo = surfaceInfo; + + // Adjust bounds as the last step. + onWindowBoundsChanged(); + } + + /* package */ void onSurfaceDestroyed() { + ThreadUtils.assertOnUiThread(); + + mNewSurfaceProvider = null; + + if (mCompositorReady) { + mCompositor.syncPauseCompositor(); + return; + } + + // While the surface was valid, we never became attached or the + // compositor never became ready; clear the saved surface. + mSurfaceInfo = null; + } + + /* package */ void onScreenOriginChanged(final int left, final int top) { + ThreadUtils.assertOnUiThread(); + + if (mLeft == left && mTop == top) { + return; + } + + mLeft = left; + mTop = top; + onWindowBoundsChanged(); + } + + /* package */ void setDynamicToolbarMaxHeight(final int height) { + if (mDynamicToolbarMaxHeight == height) { + return; + } + + if (mHeight != 0 && height != 0 && mHeight < height) { + Log.w( + LOGTAG, + new AssertionError( + "The maximum height of the dynamic toolbar (" + + height + + ") should be smaller than GeckoView height (" + + mHeight + + ")")); + } + + mDynamicToolbarMaxHeight = height; + + if (mAttachedCompositor) { + mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + } + } + + /* package */ void setFixedBottomOffset(final int offset) { + if (mFixedBottomOffset == offset) { + return; + } + + mFixedBottomOffset = offset; + + if (mCompositorReady) { + mCompositor.setFixedBottomOffset(mFixedBottomOffset); + } + } + + /* package */ void onCompositorAttached() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mAttachedCompositor = true; + mCompositor.attachNPZC(mPanZoomController.mNative); + + if (mSurfaceInfo != null) { + // If we have a valid surface, create the compositor now that we're attached. + // Leave mSurface alone because we'll need it later for onCompositorReady. + onSurfaceChanged(mSurfaceInfo); + } + + mCompositor.sendToolbarAnimatorMessage(IS_COMPOSITOR_CONTROLLER_OPEN); + mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + } + + /* package */ void onCompositorDetached() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mController != null) { + mController.onCompositorDetached(); + } + + mAttachedCompositor = false; + mCompositorReady = false; + } + + /* package */ void handleCompositorMessage(final int message) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + switch (message) { + case COMPOSITOR_CONTROLLER_OPEN: + { + if (isCompositorReady()) { + return; + } + + // Delay calling onCompositorReady to avoid deadlock due + // to synchronous call to the compositor. + ThreadUtils.postToUiThread(this::onCompositorReady); + break; + } + + case FIRST_PAINT: + { + if (mController != null) { + mController.onFirstPaint(); + } + final ContentDelegate delegate = mContentHandler.getDelegate(); + if (delegate != null) { + delegate.onFirstComposite(this); + } + break; + } + + case LAYERS_UPDATED: + { + if (mController != null) { + mController.notifyDrawCallbacks(); + } + break; + } + + default: + { + Log.w(LOGTAG, "Unexpected message: " + message); + break; + } + } + } + + /* package */ boolean isCompositorReady() { + return mCompositorReady; + } + + /* package */ void onCompositorReady() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (!mAttachedCompositor) { + return; + } + + mCompositorReady = true; + + if (mController != null) { + mController.onCompositorReady(); + } + + if (mSurfaceInfo != null) { + // If we have a valid surface, resume the + // compositor now that the compositor is ready. + onSurfaceChanged(mSurfaceInfo); + mSurfaceInfo = null; + } + + if (mFixedBottomOffset != 0) { + mCompositor.setFixedBottomOffset(mFixedBottomOffset); + } + } + + /* package */ void updateOverscrollVelocity(final float x, final float y) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mOverscroll == null) { + return; + } + + // Multiply the velocity by 1000 to match what was done in JPZ. + mOverscroll.setVelocity(x * 1000.0f, OverscrollEdgeEffect.AXIS_X); + mOverscroll.setVelocity(y * 1000.0f, OverscrollEdgeEffect.AXIS_Y); + } + + /* package */ void updateOverscrollOffset(final float x, final float y) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mOverscroll == null) { + return; + } + + mOverscroll.setDistance(x, OverscrollEdgeEffect.AXIS_X); + mOverscroll.setDistance(y, OverscrollEdgeEffect.AXIS_Y); + } + + /* package */ void onMetricsChanged(final float scrollX, final float scrollY, final float zoom) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mViewportLeft = scrollX; + mViewportTop = scrollY; + mViewportZoom = zoom; + } + + /* protected */ void onWindowBoundsChanged() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mHeight != 0 && mDynamicToolbarMaxHeight != 0 && mHeight < mDynamicToolbarMaxHeight) { + Log.w( + LOGTAG, + new AssertionError( + "The maximum height of the dynamic toolbar (" + + mDynamicToolbarMaxHeight + + ") should be smaller than GeckoView height (" + + mHeight + + ")")); + } + + final int toolbarHeight = 0; + + mClientTop = mTop + toolbarHeight; + // If the view is not tall enough to even fix the toolbar we just + // default the client height to 0 + mClientHeight = Math.max(mHeight - toolbarHeight, 0); + + if (mAttachedCompositor) { + mCompositor.onBoundsChanged(mLeft, mClientTop, mWidth, mClientHeight); + } + + if (mOverscroll != null) { + mOverscroll.setSize(mWidth, mClientHeight); + } + } + + /* pacakge */ void onSafeAreaInsetsChanged( + final int top, final int right, final int bottom, final int left) { + ThreadUtils.assertOnUiThread(); + + if (mAttachedCompositor) { + mCompositor.onSafeAreaInsetsChanged(top, right, bottom, left); + } + } + + /* package */ void setPointerIcon( + final int defaultCursor, final @Nullable Bitmap customCursor, final float x, final float y) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return; + } + + final PointerIcon icon; + if (customCursor != null) { + try { + icon = PointerIcon.create(customCursor, x, y); + } catch (final IllegalArgumentException e) { + // x/y hotspot might be invalid + return; + } + } else { + final Context context = GeckoAppShell.getApplicationContext(); + icon = PointerIcon.getSystemIcon(context, defaultCursor); + } + + final ContentDelegate delegate = getContentDelegate(); + if (delegate != null) { + delegate.onPointerIconChange(this, icon); + } + } + + /** GeckoSession applications implement this interface to handle media events. */ + public interface MediaDelegate { + + class RecordingDevice { + + /* + * Default status flags for this RecordingDevice. + */ + public static class Status { + public static final long RECORDING = 0; + public static final long INACTIVE = 1 << 0; + + // Do not instantiate this class. + protected Status() {} + } + + /* + * Default device types for this RecordingDevice. + */ + public static class Type { + public static final long CAMERA = 0; + public static final long MICROPHONE = 1 << 0; + + // Do not instantiate this class. + protected Type() {} + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Status.RECORDING, Status.INACTIVE}) + public @interface RecordingStatus {} + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Type.CAMERA, Type.MICROPHONE}) + public @interface DeviceType {} + + /** + * A long giving the current recording status, must be either Status.RECORDING, Status.PAUSED + * or Status.INACTIVE. + */ + public final @RecordingStatus long status; + + /** + * A long giving the type of the recording device, must be either Type.CAMERA or + * Type.MICROPHONE. + */ + public final @DeviceType long type; + + private static @DeviceType long getTypeFromString(final String type) { + if ("microphone".equals(type)) { + return Type.MICROPHONE; + } else if ("camera".equals(type)) { + return Type.CAMERA; + } else { + throw new IllegalArgumentException( + "String: " + type + " is not a valid recording device string"); + } + } + + private static @RecordingStatus long getStatusFromString(final String type) { + if ("recording".equals(type)) { + return Status.RECORDING; + } else { + return Status.INACTIVE; + } + } + + /* package */ RecordingDevice(final GeckoBundle media) { + status = getStatusFromString(media.getString("status")); + type = getTypeFromString(media.getString("type")); + } + + /** Empty constructor for tests. */ + protected RecordingDevice() { + status = Status.INACTIVE; + type = Type.CAMERA; + } + } + + /** + * A recording device has changed state. Any change to the recording state of the devices + * microphone or camera will call this delegate method. The argument provides details of the + * active recording devices. + * + * @param session The session that the event has originated from. + * @param devices The list of active devices and their recording state. + */ + @UiThread + default void onRecordingStatusChanged( + @NonNull final GeckoSession session, @NonNull final RecordingDevice[] devices) {} + } + + /** An interface for recording new history visits and fetching the visited status for links. */ + public interface HistoryDelegate { + /** A representation of an entry in browser history. */ + public interface HistoryItem { + /** + * Get the URI of this history element. + * + * @return A String representing the URI of this history element. + */ + @AnyThread + default @NonNull String getUri() { + throw new UnsupportedOperationException("HistoryItem.getUri() called on invalid object."); + } + + /** + * Get the title of this history element. + * + * @return A String representing the title of this history element. + */ + @AnyThread + default @NonNull String getTitle() { + throw new UnsupportedOperationException( + "HistoryItem.getString() called on invalid object."); + } + } + + /** + * A representation of browser history, accessible as a `List`. The list itself and its entries + * are immutable; any attempt to mutate will result in an `UnsupportedOperationException`. + */ + public interface HistoryList extends List { + /** + * 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 onVisited( + @NonNull final GeckoSession session, + @NonNull final String url, + @Nullable final String lastVisitedURL, + @VisitFlags final int flags) { + return null; + } + + /** + * Returns the visited statuses for links on a page. This is used to highlight links as visited + * or unvisited, for example. + * + * @param session The session requesting the visited statuses. + * @param urls A list of URLs to check. + * @return A {@link GeckoResult} completed with a list of booleans corresponding to the URLs in + * {@code urls}, and indicating whether to highlight links for each URL as visited ({@code + * true}) or unvisited ({@code false}). + */ + @UiThread + default @Nullable GeckoResult getVisited( + @NonNull final GeckoSession session, @NonNull final String[] urls) { + return null; + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + default void onHistoryStateChange( + @NonNull final GeckoSession session, @NonNull final HistoryList historyList) {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + HistoryDelegate.VISIT_TOP_LEVEL, + HistoryDelegate.VISIT_REDIRECT_TEMPORARY, + HistoryDelegate.VISIT_REDIRECT_PERMANENT, + HistoryDelegate.VISIT_REDIRECT_SOURCE, + HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT, + HistoryDelegate.VISIT_UNRECOVERABLE_ERROR + }) + public @interface VisitFlags {} + + private Autofill.Support getAutofillSupport() { + return mAutofillSupport; + } + + /** + * Sets the autofill delegate for this session. + * + * @param delegate An instance of {@link Autofill.Delegate}. + */ + @UiThread + public void setAutofillDelegate(final @Nullable Autofill.Delegate delegate) { + getAutofillSupport().setDelegate(delegate); + } + + /** + * @return The current {@link Autofill.Delegate} for this session, if any. + */ + @UiThread + public @Nullable Autofill.Delegate getAutofillDelegate() { + return getAutofillSupport().getDelegate(); + } + + /** + * Provides an autofill structure similar to {@link + * View#onProvideAutofillVirtualStructure(ViewStructure, int)} , but does not rely on {@link + * ViewStructure} to build the tree. This is useful for apps that want to provide autofill + * functionality without using the Android autofill system or requiring API 26. + * + * @return The elements available for autofill. + */ + @UiThread + public @NonNull Autofill.Session getAutofillSession() { + return getAutofillSupport().getAutofillSession(); + } + + /** + * Saves a PDF of the currently displayed page. + * + * @return A GeckoResult with an InputStream containing the PDF. The result could + * CompleteExceptionally with a {@link GeckoPrintException}s, if there are any issues while + * generating the PDF. + */ + @AnyThread + public @NonNull GeckoResult saveAsPdf() { + return saveAsPdfByBrowsingContext(null); + } + + /** + * Saves a PDF of the specified browsing context. Use null if the browsing context is unknown or + * to print the main page. + * + * @param browsingContextId the browsing context id of the item to print + * @return A GeckoResult with an InputStream containing the PDF. + */ + @AnyThread + private @NonNull GeckoResult saveAsPdfByBrowsingContext( + final @Nullable Long browsingContextId) { + final GeckoResult geckoResult = new GeckoResult<>(); + final GeckoSession self = this; + this.isPdfJs() + .then( + new GeckoResult.OnValueListener() { + @Override + public GeckoResult onValue(final Boolean isPdfJs) { + if (!isPdfJs) { + if (browsingContextId == null) { + self.mWindow.printToPdf(geckoResult); + } else { + self.mWindow.printToPdf(geckoResult, browsingContextId); + } + } else { + geckoResult.completeFrom( + self.getPdfFileSaver().save().map(result -> result.body)); + } + return null; + } + }); + + return geckoResult; + } + + /** Prints the currently displayed page. */ + @AnyThread + public void printPageContent() { + final PrintDelegate delegate = getPrintDelegate(); + if (delegate != null) { + delegate.onPrint(this); + } else { + Log.w(LOGTAG, "Print delegate required for printing."); + } + } + + private static String rgbaToArgb(final String color) { + // We expect #rrggbbaa + if (color.length() != 9 || !color.startsWith("#")) { + throw new IllegalArgumentException("Invalid color format"); + } + + return "#" + color.substring(7) + color.substring(1, 7); + } + + private static void fixupManifestColor(final JSONObject manifest, final String name) + throws JSONException { + if (manifest.isNull(name)) { + return; + } + + manifest.put(name, rgbaToArgb(manifest.getString(name))); + } + + private static JSONObject fixupWebAppManifest(final JSONObject manifest) { + // Colors are #rrggbbaa, but we want them to be #aarrggbb, since that's what + // android.graphics.Color expects. + try { + fixupManifestColor(manifest, "theme_color"); + fixupManifestColor(manifest, "background_color"); + } catch (final JSONException e) { + Log.w(LOGTAG, "Failed to fixup web app manifest", e); + } + + return manifest; + } + + private static boolean maybeCheckDataUriLength(final @NonNull Loader request) { + if (!request.mIsDataUri) { + return true; + } + + return request.mUri.length() <= DATA_URI_MAX_LENGTH; + } + + /** + * Used for printing page content. + * + *

    The provided implementation is in {@link GeckoView}. It uses a PDF of the content and the + * Android print API to print the page. + */ + @AnyThread + public interface PrintDelegate { + /** + * Print the current page content. + * + * @param session to print + */ + default void onPrint(@NonNull final GeckoSession session) {} + + /** + * Print any provided PDF InputStream. + * + * @param pdfInputStream an InputStream containing a PDF + */ + default void onPrint(@NonNull final InputStream pdfInputStream) {} + + /** + * Print any provided PDF InputStream. + * + * @param pdfInputStream an InputStream containing a PDF + * @return A GeckoResult if the print dialog has closed + */ + default @Nullable GeckoResult onPrintWithStatus( + @NonNull final InputStream pdfInputStream) { + return null; + } + } + + /** + * Gets the print delegate for this session. + * + * @return The current {@link PrintDelegate} for this session, if any. + */ + @AnyThread + public @Nullable PrintDelegate getPrintDelegate() { + return mPrintHandler.getDelegate(); + } + + /** + * Sets the print delegate for this session. + * + * @param delegate An instance of {@link PrintDelegate}. + */ + @AnyThread + public void setPrintDelegate(final @Nullable PrintDelegate delegate) { + mPrintHandler.setDelegate(delegate, this); + } + + /** Thrown when failure occurs when printing from a website. */ + @WrapForJNI + public static class GeckoPrintException extends Exception { + /** The print service was not available. */ + public static final int ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE = -1; + + /** The print service was not created due to an initialization error. */ + public static final int ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS = -2; + + /** An error happened while trying to find the canonical browing context */ + public static final int ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT = -3; + + /** An error happened while trying to find the activity context delegate */ + public static final int ERROR_NO_ACTIVITY_CONTEXT_DELEGATE = -4; + + /** An error happened while trying to find the activity context */ + public static final int ERROR_NO_ACTIVITY_CONTEXT = -5; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE, + ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS, + ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT, + ERROR_NO_ACTIVITY_CONTEXT_DELEGATE, + ERROR_NO_ACTIVITY_CONTEXT + }) + public @interface Codes {} + + /** One of {@link Codes} that provides more information about this exception. */ + public final @Codes int code; + + @Override + public String toString() { + return "GeckoPrintException: " + code; + } + + /* package */ GeckoPrintException(final @Codes int code) { + this.code = code; + } + + /** For testing. */ + protected GeckoPrintException() { + code = ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java new file mode 100644 index 0000000000..629211a4a6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java @@ -0,0 +1,106 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/* package */ abstract class GeckoSessionHandler 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..046f7a3072 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java @@ -0,0 +1,732 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Collection; +import org.mozilla.gecko.util.GeckoBundle; + +@AnyThread +public final class GeckoSessionSettings implements Parcelable { + + /** Settings builder used to construct the settings object. */ + @AnyThread + public static final class Builder { + private final GeckoSessionSettings mSettings; + + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mSettings = new GeckoSessionSettings(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public Builder(final GeckoSessionSettings settings) { + mSettings = new GeckoSessionSettings(settings); + } + + /** + * Finalize and return the settings. + * + * @return The constructed settings. + */ + public @NonNull GeckoSessionSettings build() { + return new GeckoSessionSettings(mSettings); + } + + /** + * Set the chrome URI. + * + * @param uri The URI to set the Chrome URI to. + * @return This Builder instance. + */ + public @NonNull Builder chromeUri(final @NonNull String uri) { + mSettings.setChromeUri(uri); + return this; + } + + /** + * Set the screen id. + * + * @param id The screen id. + * @return This Builder instance. + */ + public @NonNull Builder screenId(final int id) { + mSettings.setScreenId(id); + return this; + } + + /** + * Set the privacy mode for this instance. + * + * @param flag A flag determining whether Private Mode should be enabled. Default is false. + * @return This Builder instance. + */ + public @NonNull Builder usePrivateMode(final boolean flag) { + mSettings.setUsePrivateMode(flag); + return this; + } + + /** + * Set the session context ID for this instance. Setting a context ID partitions the cookie jars + * based on the provided IDs. This isolates the browser storage like cookies and localStorage + * between sessions, only sessions that share the same ID share storage data. + * + *

    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(@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(@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(@ViewportMode final int mode) { + mSettings.setViewportMode(mode); + return this; + } + } + + private static final String LOGTAG = "GeckoSessionSettings"; + private static final boolean DEBUG = false; + + /** This value is for the display member of Web App Manifests */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + DISPLAY_MODE_BROWSER, + DISPLAY_MODE_MINIMAL_UI, + DISPLAY_MODE_STANDALONE, + DISPLAY_MODE_FULLSCREEN + }) + public @interface DisplayMode {} + + // This needs to match GeckoViewSettings.jsm + /** "browser" value of the display member in Web App Manifests */ + public static final int DISPLAY_MODE_BROWSER = 0; + + /** "minimal-ui" value of the display member in Web App Manifests */ + public static final int DISPLAY_MODE_MINIMAL_UI = 1; + + /** "standalone" value of the display member in Web App Manifests */ + public static final int DISPLAY_MODE_STANDALONE = 2; + + /** "fullscreen" value of the display member in Web App Manifests */ + public static final int DISPLAY_MODE_FULLSCREEN = 3; + + /** The user agent string mode */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + USER_AGENT_MODE_MOBILE, + USER_AGENT_MODE_DESKTOP, + USER_AGENT_MODE_VR, + }) + public @interface UserAgentMode {} + + // This needs to match GeckoViewSettingsChild.js and GeckoViewSettings.jsm + /** The user agent mode is mobile device */ + public static final int USER_AGENT_MODE_MOBILE = 0; + + /** The user agent mobe is desktop device */ + public static final int USER_AGENT_MODE_DESKTOP = 1; + + /** The user agent mode is VR device */ + public static final int USER_AGENT_MODE_VR = 2; + + /** The view port mode */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP}) + public @interface ViewportMode {} + + // This needs to match GeckoViewSettingsChild.js + /** + * Mobile-friendly pages will be rendered using a viewport based on their <meta> 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 <meta> viewport tag specified or not. + */ + public static final int VIEWPORT_MODE_DESKTOP = 1; + + public static class Key { + /* package */ final String name; + /* package */ final boolean initOnly; + /* package */ final Collection values; + + /* package */ Key(final String name) { + this(name, /* initOnly */ false, /* values */ null); + } + + /* package */ Key(final String name, final boolean initOnly, final Collection 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 CHROME_URI = + new Key("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 SCREEN_ID = + new Key("screenId", /* initOnly */ true, /* values */ null); + + /** Key to enable and disable tracking protection. */ + private static final Key USE_TRACKING_PROTECTION = + new Key("useTrackingProtection"); + + /** Key to enable and disable private mode browsing. Read-only once session is open. */ + private static final Key USE_PRIVATE_MODE = + new Key("usePrivateMode", /* initOnly */ true, /* values */ null); + + /** Key to specify which user agent mode we should use. */ + private static final Key USER_AGENT_MODE = + new Key( + "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 USER_AGENT_OVERRIDE = + new Key("userAgentOverride", /* initOnly */ false, /* values */ null); + + /** Key to specify which viewport mode we should use. */ + private static final Key VIEWPORT_MODE = + new Key( + "viewportMode", /* initOnly */ + false, + Arrays.asList(VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP)); + + /** Key to specify which display-mode we should use. */ + private static final Key DISPLAY_MODE = + new Key( + "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 SUSPEND_MEDIA_WHEN_INACTIVE = + new Key("suspendMediaWhenInactive", /* initOnly */ false, /* values */ null); + + /** Key to specify if JavaScript should be allowed on this session. */ + private static final Key ALLOW_JAVASCRIPT = + new Key("allowJavascript", /* initOnly */ false, /* values */ null); + + /** Key to specify if entire accessible tree should be exposed with no caching. */ + private static final Key FULL_ACCESSIBILITY_TREE = + new Key("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 IS_POPUP = + new Key("isPopup", /* initOnly */ false, /* values */ null); + + /** Internal Gecko key to specify the session context ID. Derived from `UNSAFE_CONTEXT_ID`. */ + private static final Key CONTEXT_ID = + new Key("sessionContextId", /* initOnly */ true, /* values */ null); + + /** User-provided key to specify the session context ID. */ + private static final Key UNSAFE_CONTEXT_ID = + new Key("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 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 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(@UserAgentMode 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(@DisplayMode 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(@ViewportMode final int value) { + setInt(VIEWPORT_MODE, value); + } + + private void setInt(final Key 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 @UserAgentMode 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 @DisplayMode 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 @ViewportMode int getViewportMode() { + return getInt(VIEWPORT_MODE); + } + + private int getInt(final Key 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 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 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 boolean valueChangedLocked(final Key key, final T value) { + if (key.initOnly && mSession != null) { + throw new IllegalStateException("Read-only property"); + } else if (key.values != null && !key.values.contains(value)) { + throw new IllegalArgumentException("Invalid value"); + } + + final Object old = mBundle.get(key.name); + return (old != value) && (old == null || !old.equals(value)); + } + + private void dispatchUpdate() { + if (mSession != null) { + mSession.getEventDispatcher().dispatch("GeckoView:UpdateSettings", toBundle()); + } + } + + @Override // Parcelable + public int describeContents() { + return 0; + } + + @Override // Parcelable + public void writeToParcel(final @NonNull Parcel out, final int flags) { + mBundle.writeToParcel(out, flags); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + mBundle.readFromParcel(source); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public GeckoSessionSettings createFromParcel(final Parcel in) { + final GeckoSessionSettings settings = new GeckoSessionSettings(); + settings.readFromParcel(in); + return settings; + } + + @Override + public GeckoSessionSettings[] newArray(final int size) { + return new GeckoSessionSettings[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java new file mode 100644 index 0000000000..754414a0ea --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java @@ -0,0 +1,42 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * Interface for registering the external VR context with WebVR. The context must be registered + * before Gecko is started. This API is not intended for external consumption. To see an example of + * how it is used please see the Firefox Reality browser. + * + * @see External VR Context + */ +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 here. + * + * @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..8f026f7253 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java @@ -0,0 +1,1248 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import static org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_NO_ACTIVITY_CONTEXT; +import static org.mozilla.geckoview.GeckoSession.GeckoPrintException.ERROR_NO_ACTIVITY_CONTEXT_DELEGATE; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.os.Build; +import android.os.Handler; +import android.print.PrintDocumentAdapter; +import android.print.PrintManager; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.util.TypedValue; +import android.view.DisplayCutout; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.SurfaceControl; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStructure; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.core.view.ViewCompat; +import java.io.InputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.util.Objects; +import org.mozilla.gecko.AndroidGamepadManager; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.SurfaceViewWrapper; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class GeckoView extends FrameLayout implements GeckoDisplay.NewSurfaceProvider { + private static final String LOGTAG = "GeckoView"; + private static final boolean DEBUG = false; + + protected final @NonNull Display mDisplay = new Display(); + + private Integer mLastCoverColor; + protected @Nullable GeckoSession mSession; + WeakReference mAutofillSession = new WeakReference<>(null); + + // Whether this GeckoView instance has a session that is no longer valid, e.g. because the session + // associated to this GeckoView was attached to a different GeckoView instance. + private boolean mIsSessionPoisoned = false; + + private boolean mStateSaved; + + private @Nullable SurfaceViewWrapper mSurfaceWrapper; + + private boolean mIsResettingFocus; + + private boolean mAutofillEnabled = true; + + private GeckoSession.SelectionActionDelegate mSelectionActionDelegate; + private Autofill.Delegate mAutofillDelegate; + private @Nullable ActivityContextDelegate mActivityDelegate; + private GeckoSession.PrintDelegate mPrintDelegate; + + 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( + new GeckoDisplay.SurfaceInfo.Builder(wrapper.getSurface()) + .surfaceControl(wrapper.getSurfaceControl()) + .newSurfaceProvider(GeckoView.this) + .size(wrapper.getWidth(), wrapper.getHeight()) + .build()); + mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + GeckoView.this.setActive(true); + } + } + + public GeckoDisplay release() { + if (mValid) { + if (mDisplay != null) { + mDisplay.surfaceDestroyed(); + } + GeckoView.this.setActive(false); + } + + final GeckoDisplay display = mDisplay; + mDisplay = null; + return display; + } + + @Override // SurfaceListener + public void onSurfaceChanged( + final Surface surface, + @Nullable final SurfaceControl surfaceControl, + final int width, + final int height) { + if (mDisplay != null) { + mDisplay.surfaceChanged( + new GeckoDisplay.SurfaceInfo.Builder(surface) + .surfaceControl(surfaceControl) + .newSurfaceProvider(GeckoView.this) + .size(width, height) + .build()); + mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + if (!mValid) { + GeckoView.this.setActive(true); + } + } + mValid = true; + } + + @Override // SurfaceListener + public void onSurfaceDestroyed() { + if (mDisplay != null) { + mDisplay.surfaceDestroyed(); + GeckoView.this.setActive(false); + } + mValid = false; + } + + public void onGlobalLayout() { + if (mDisplay == null) { + return; + } + if (GeckoView.this.mSurfaceWrapper != null) { + GeckoView.this.mSurfaceWrapper.getView().getLocationOnScreen(mOrigin); + mDisplay.screenOriginChanged(mOrigin[0], mOrigin[1]); + // cutout support + if (Build.VERSION.SDK_INT >= 28) { + final DisplayCutout cutout = + GeckoView.this.mSurfaceWrapper.getView().getRootWindowInsets().getDisplayCutout(); + if (cutout != null) { + mDisplay.safeAreaInsetsChanged( + cutout.getSafeInsetTop(), + cutout.getSafeInsetRight(), + cutout.getSafeInsetBottom(), + cutout.getSafeInsetLeft()); + } + } + } + } + + public boolean shouldPinOnScreen() { + return mDisplay != null ? mDisplay.shouldPinOnScreen() : false; + } + + public void setVerticalClipping(final int clippingHeight) { + mClippingHeight = clippingHeight; + + if (mDisplay != null) { + mDisplay.setVerticalClipping(clippingHeight); + } + } + + public void setDynamicToolbarMaxHeight(final int height) { + mDynamicToolbarMaxHeight = height; + + // Reset the vertical clipping value to zero whenever we change + // the dynamic toolbar __max__ height so that it can be properly + // propagated to both the main thread and the compositor thread, + // thus we will be able to reset the __current__ toolbar height + // on the both threads whatever the __current__ toolbar height is. + setVerticalClipping(0); + + if (mDisplay != null) { + mDisplay.setDynamicToolbarMaxHeight(height); + } + } + + /** + * Request a {@link Bitmap} of the visible portion of the web page currently being rendered. + * + * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and + * size information of the currently visible rendered web page. + */ + @UiThread + @NonNull + GeckoResult capturePixels() { + if (mDisplay == null) { + return GeckoResult.fromException( + new IllegalStateException("Display must be created before pixels can be captured")); + } + + return mDisplay.capturePixels(); + } + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoView(final Context context) { + super(context); + init(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoView(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(); + } + + private static Activity getActivityFromContext(final Context outerContext) { + Context context = outerContext; + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + return null; + } + + private void init() { + setFocusable(true); + setFocusableInTouchMode(true); + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + + // We are adding descendants to this LayerView, but we don't want the + // descendants to affect the way LayerView retains its focus. + setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS); + + // This will stop PropertyAnimator from creating a drawing cache (i.e. a + // bitmap) from a SurfaceView, which is just not possible (the bitmap will be + // transparent). + setWillNotCacheDrawing(false); + + mSurfaceWrapper = new SurfaceViewWrapper(getContext()); + mSurfaceWrapper.setBackgroundColor(Color.WHITE); + addView( + mSurfaceWrapper.getView(), + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + mSurfaceWrapper.setListener(mDisplay); + + final Activity activity = getActivityFromContext(getContext()); + if (activity != null) { + mSelectionActionDelegate = new BasicSelectionActionDelegate(activity); + } + + if (Build.VERSION.SDK_INT >= 26) { + mAutofillDelegate = new AndroidAutofillDelegate(); + } else { + // We don't support Autofill on SDK < 26 + mAutofillDelegate = new Autofill.Delegate() {}; + } + mPrintDelegate = new GeckoViewPrintDelegate(); + } + + /** + * 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}) + public @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()); + + if (mSession != null) { + mSession.getMagnifier().setView(mSurfaceWrapper.getView()); + } + } + + /** + * Return whether the view should be pinned on the screen. When pinned, the view should not be + * moved on the screen due to animation, scrolling, etc. A common reason for the view being pinned + * is when the user is dragging a selection caret inside the view; normal user interaction would + * be disrupted in that case if the view was moved on screen. + * + * @return True if view should be pinned on the screen. + */ + public boolean shouldPinOnScreen() { + ThreadUtils.assertOnUiThread(); + + return mDisplay.shouldPinOnScreen(); + } + + /** + * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion + * of the view. Tells gecko where to put bottom fixed elements so they are fully visible. + * + *

    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 + static final int DEFAULT_DARK_COLOR = 0xFF2A2A2E; + + private int defaultColor() { + // If the app set a default color, just use that + if (mLastCoverColor != null) { + return mLastCoverColor; + } + + if (mSession == null || !mSession.isOpen()) { + return Color.WHITE; + } + + // ... otherwise use the prefers-color-scheme color + return mSession.getRuntime().usesDarkTheme() ? DEFAULT_DARK_COLOR : Color.WHITE; + } + + /** + * Unsets the current session from this instance and returns it, if any. You must call this before + * {@link #setSession(GeckoSession)} if there is already an open session set for this instance. + * + *

    Note: this method does not close the session and the session remains active. The caller is + * responsible for calling {@link GeckoSession#close()} when appropriate. + * + * @return The {@link GeckoSession} that was set for this instance. May be null. + */ + @UiThread + public @Nullable GeckoSession releaseSession() { + ThreadUtils.assertOnUiThread(); + + if (mSession == null) { + return null; + } + + final GeckoSession session = mSession; + mSession.releaseDisplay(mDisplay.release()); + mSession.getOverscrollEdgeEffect().setInvalidationCallback(null); + mSession.getOverscrollEdgeEffect().setSession(null); + mSession.getCompositorController().setFirstPaintCallback(null); + + if (mSession.getAccessibility().getView() == this) { + mSession.getAccessibility().setView(null); + } + + if (mSession.getTextInput().getView() == this) { + mSession.getTextInput().setView(null); + } + + if (mSession.getSelectionActionDelegate() == mSelectionActionDelegate) { + mSession.setSelectionActionDelegate(null); + } + + if (mSession.getAutofillDelegate() == mAutofillDelegate) { + mSession.setAutofillDelegate(null); + } + + if (mSession.getPrintDelegate() == mPrintDelegate) { + session.setPrintDelegate(null); + } + + if (mSession.getMagnifier().getView() == mSurfaceWrapper.getView()) { + session.getMagnifier().setView(null); + } + + if (isFocused()) { + mSession.setFocused(false); + } + mSession = null; + mIsSessionPoisoned = false; + session.releaseOwner(); + return session; + } + + private final GeckoSession.Owner mSessionOwner = + new GeckoSession.Owner() { + @Override + public void onRelease() { + // The session that we own is being owned by some other object so we need to release it + // here. + releaseSession(); + // The session associated to this GeckoView is now invalid, but the app is not aware of + // it. We cannot display this GeckoView until the app sets a session again (or releases + // the poisoned session). + mIsSessionPoisoned = true; + } + }; + + /** + * Attach a session to this view. If this instance already has an open session, you must use + * {@link #releaseSession()} first, otherwise {@link IllegalStateException} will be thrown. This + * is to avoid potentially leaking the currently opened session. + * + * @param session The session to be attached. + * @throws IllegalArgumentException if an existing open session is already set. + */ + @UiThread + public void setSession(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + + if (session == mSession) { + // Nothing to do + return; + } + + releaseSession(); + + session.setOwner(mSessionOwner); + mSession = session; + mIsSessionPoisoned = false; + + // Make sure the clear color is set to the default + mSession.getCompositorController().setClearColor(defaultColor()); + + if (ViewCompat.isAttachedToWindow(this)) { + mDisplay.acquire(session.acquireDisplay()); + } + + final Context context = getContext(); + session.getOverscrollEdgeEffect().setTheme(context); + session.getOverscrollEdgeEffect().setSession(session); + session + .getOverscrollEdgeEffect() + .setInvalidationCallback( + new Runnable() { + @Override + public void run() { + if (Build.VERSION.SDK_INT >= 16) { + GeckoView.this.postInvalidateOnAnimation(); + } else { + GeckoView.this.postInvalidateDelayed(10); + } + } + }); + + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final TypedValue outValue = new TypedValue(); + if (context + .getTheme() + .resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) { + session.getPanZoomController().setScrollFactor(outValue.getDimension(metrics)); + } else { + session.getPanZoomController().setScrollFactor(0.075f * metrics.densityDpi); + } + + session.getCompositorController().setFirstPaintCallback(this::uncover); + + if (session.getTextInput().getView() == null) { + session.getTextInput().setView(this); + } + + if (session.getAccessibility().getView() == null) { + session.getAccessibility().setView(this); + } + + if (session.getSelectionActionDelegate() == null && mSelectionActionDelegate != null) { + session.setSelectionActionDelegate(mSelectionActionDelegate); + } + + if (mAutofillEnabled) { + session.setAutofillDelegate(mAutofillDelegate); + } + + if (session.getMagnifier().getView() == null) { + session.getMagnifier().setView(mSurfaceWrapper.getView()); + } + + if (session.getPrintDelegate() == null && mPrintDelegate != null) { + session.setPrintDelegate(mPrintDelegate); + } + + if (isFocused()) { + session.setFocused(true); + } + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable GeckoSession getSession() { + return mSession; + } + + @AnyThread + /* package */ @NonNull + EventDispatcher getEventDispatcher() { + return mSession.getEventDispatcher(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull PanZoomController getPanZoomController() { + ThreadUtils.assertOnUiThread(); + return mSession.getPanZoomController(); + } + + @Override + public void onAttachedToWindow() { + if (mIsSessionPoisoned) { + throw new IllegalStateException("Trying to display a view with invalid session."); + } + if (mSession != null) { + final GeckoRuntime runtime = mSession.getRuntime(); + if (runtime != null) { + runtime.orientationChanged(); + } + } + + if (mSession != null) { + mDisplay.acquire(mSession.acquireDisplay()); + } + + super.onAttachedToWindow(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (mSession == null) { + return; + } + + // Release the display before we detach from the window. + mSession.releaseDisplay(mDisplay.release()); + } + + @Override + protected void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (mSession != null) { + final GeckoRuntime runtime = mSession.getRuntime(); + if (runtime != null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + // onConfigurationChanged is not called for 180 degree orientation changes, + // we will miss such rotations and the screen orientation will not be + // updated. + // + // If API is 17+, we use DisplayManager API to detect all degree + // orientation change. + runtime.orientationChanged(newConfig.orientation); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // If API is 31+, DisplayManager API may report previous information. + // So we have to report it again. But since Configuration.orientation may still have + // previous information even if onConfigurationChanged is called, we have to calculate it + // from display data. + runtime.orientationChanged(); + } + + runtime.configurationChanged(newConfig); + } + } + } + + @Override + public boolean gatherTransparentRegion(final Region region) { + // For detecting changes in SurfaceView layout, we take a shortcut here and + // override gatherTransparentRegion, instead of registering a layout listener, + // which is more expensive. + if (mSurfaceWrapper != null) { + mDisplay.onGlobalLayout(); + } + return super.gatherTransparentRegion(region); + } + + @Override + public void onWindowFocusChanged(final boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + + // Only call setFocus(true) when the window gains focus. Any focus loss could be temporary + // (e.g. due to auto-fill popups) and we don't want to call setFocus(false) in those cases. + // Instead, we call setFocus(false) in onWindowVisibilityChanged. + if (mSession != null && hasWindowFocus && isFocused()) { + mSession.setFocused(true); + } + } + + @Override + protected void onWindowVisibilityChanged(final int visibility) { + super.onWindowVisibilityChanged(visibility); + + // We can be reasonably sure that the focus loss is not temporary, so call setFocus(false). + if (mSession != null && visibility != View.VISIBLE && !hasWindowFocus()) { + mSession.setFocused(false); + } + } + + @Override + protected void onFocusChanged( + final boolean gainFocus, final int direction, final Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + + if (mIsResettingFocus) { + return; + } + + if (mSession != null) { + mSession.setFocused(gainFocus); + } + + if (!gainFocus) { + return; + } + + post( + new Runnable() { + @Override + public void run() { + if (!isFocused()) { + return; + } + + final InputMethodManager imm = InputMethods.getInputMethodManager(getContext()); + // Bug 1404111: Through View#onFocusChanged, the InputMethodManager queues + // up a checkFocus call for the next spin of the message loop, so by + // posting this Runnable after super#onFocusChanged, the IMM should have + // completed its focus change handling at this point and we should be the + // active view for input handling. + + // If however onViewDetachedFromWindow for the previously active view gets + // called *after* onFocusChanged, but *before* the focus change has been + // fully processed by the IMM with the help of checkFocus, the IMM will + // lose track of the currently active view, which means that we can't + // interact with the IME. + if (!imm.isActive(GeckoView.this)) { + // If that happens, we bring the IMM's internal state back into sync + // by clearing and resetting our focus. + mIsResettingFocus = true; + clearFocus(); + // After calling clearFocus we might regain focus automatically, but + // we explicitly request it again in case this doesn't happen. If + // we've already got the focus back, this will then be a no-op anyway. + requestFocus(); + mIsResettingFocus = false; + } + } + }); + } + + @Override + public Handler getHandler() { + if (Build.VERSION.SDK_INT >= 24 || mSession == null) { + return super.getHandler(); + } + return mSession.getTextInput().getHandler(super.getHandler()); + } + + @Override + public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + if (mSession == null) { + return null; + } + return mSession.getTextInput().onCreateInputConnection(outAttrs); + } + + @Override + public boolean onKeyPreIme(final int keyCode, final KeyEvent event) { + if (super.onKeyPreIme(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyUp(final int keyCode, final KeyEvent event) { + if (super.onKeyUp(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent event) { + if (super.onKeyDown(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(final int keyCode, final KeyEvent event) { + if (super.onKeyLongPress(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyMultiple(final int keyCode, final int repeatCount, final KeyEvent event) { + if (super.onKeyMultiple(keyCode, repeatCount, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyMultiple(keyCode, repeatCount, event); + } + + @Override + public void dispatchDraw(final @Nullable Canvas canvas) { + super.dispatchDraw(canvas); + + if (mSession != null) { + mSession.getOverscrollEdgeEffect().draw(canvas); + } + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mSession == null) { + return false; + } + + mSession.getPanZoomController().onTouchEvent(event); + return true; + } + + /** + * Dispatches a {@link MotionEvent} to the {@link PanZoomController}. This is the same as {@link + * #onTouchEvent(MotionEvent)}, but instead returns a {@link PanZoomController.InputResult} + * indicating how the event was handled. + * + *

    NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited + * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and + * unnecessary GC pressure. + * + * @param event A {@link MotionEvent} + * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}. + */ + public @NonNull GeckoResult onTouchEventForDetailResult( + final @NonNull MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mSession == null) { + return GeckoResult.fromValue( + new PanZoomController.InputResultDetail( + PanZoomController.INPUT_RESULT_UNHANDLED, + PanZoomController.SCROLLABLE_FLAG_NONE, + PanZoomController.OVERSCROLL_FLAG_NONE)); + } + + // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be + // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop. + return mSession.getPanZoomController().onTouchEventForDetailResult(event); + } + + @Override + public boolean onGenericMotionEvent(final MotionEvent event) { + if (AndroidGamepadManager.handleMotionEvent(event)) { + return true; + } + + if (mSession == null) { + return true; + } + + if (mSession.getAccessibility().onMotionEvent(event)) { + return true; + } + + mSession.getPanZoomController().onMotionEvent(event); + return true; + } + + @Override + public void onProvideAutofillVirtualStructure(final ViewStructure structure, final int flags) { + if (mSession == null) { + return; + } + + final Autofill.Session autofillSession = mSession.getAutofillSession(); + + // Let's store the session here in case we need to autofill it later + mAutofillSession = new WeakReference<>(autofillSession); + autofillSession.fillViewStructure(this, structure, flags); + } + + @Override + @TargetApi(26) + public void autofill(@NonNull final SparseArray values) { + // Note: we can't use mSession.getAutofillSession() because the app might have swapped + // the session under us between the onProvideAutofillVirtualStructure and this call + // so mSession could refer to a different session or we might not have a session at all. + final Autofill.Session session = mAutofillSession.get(); + if (session == null) { + return; + } + final SparseArray strValues = new SparseArray<>(values.size()); + for (int i = 0; i < values.size(); i++) { + final AutofillValue value = values.valueAt(i); + if (value.isText()) { + // Only text is currently supported. + strValues.put(values.keyAt(i), value.getTextValue()); + } + } + session.autofill(strValues); + } + + @Override + public boolean isVisibleToUserForAutofill(final int virtualId) { + // If autofill service works with compatibility mode, + // View.isVisibleToUserForAutofill walks through the accessibility nodes. + // This override avoids it. + return true; + } + + /** + * Request a {@link Bitmap} of the visible portion of the web page currently being rendered. + * + *

    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 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; + } + + @TargetApi(26) + private class AndroidAutofillDelegate implements Autofill.Delegate { + AutofillManager mAutofillManager; + boolean mDisabled = false; + + private void ensureAutofillManager() { + if (mDisabled || mAutofillManager != null) { + // Nothing to do + return; + } + + mAutofillManager = GeckoView.this.getContext().getSystemService(AutofillManager.class); + if (mAutofillManager == null) { + // If we can't get a reference to the autofill manager, we cannot run the autofill service + mDisabled = true; + } + } + + private Rect displayRectForId( + @NonNull final GeckoSession session, @Nullable final Autofill.Node node) { + if (node == null) { + return new Rect(0, 0, 0, 0); + } + + if (!node.getScreenRect().isEmpty()) { + return node.getScreenRect(); + } + + final Matrix matrix = new Matrix(); + final RectF rectF = new RectF(node.getDimensions()); + session.getPageToScreenMatrix(matrix); + matrix.mapRect(rectF); + + final Rect screenRect = new Rect(); + rectF.roundOut(screenRect); + return screenRect; + } + + @Override + public void onNodeBlur( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node prev, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewExited(GeckoView.this, data.getId()); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewExited: ", e); + } + } + + @Override + public void onNodeAdd( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + if (!mSession.getAutofillSession().isVisible(node)) { + return; + } + final Autofill.Node focused = mSession.getAutofillSession().getFocused(); + // We must have a focused node because |node| is visible + Objects.requireNonNull(focused); + + final Autofill.NodeData focusedData = mSession.getAutofillSession().dataFor(focused); + Objects.requireNonNull(focusedData); + + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewExited(GeckoView.this, focusedData.getId()); + mAutofillManager.notifyViewEntered( + GeckoView.this, focusedData.getId(), displayRectForId(session, focused)); + } catch (final SecurityException e) { + Log.e( + LOGTAG, + "Failed to call AutofillManager.notifyViewExited or AutofillManager.notifyViewEntered: ", + e); + } + } + + @Override + public void onNodeFocus( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node focused, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewEntered( + GeckoView.this, data.getId(), displayRectForId(session, focused)); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewEntered: ", e); + } + } + + @Override + public void onNodeRemove( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) {} + + @Override + public void onNodeUpdate( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyValueChanged( + GeckoView.this, data.getId(), AutofillValue.forText(data.getValue())); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyValueChanged: ", e); + } + } + + @Override + public void onSessionCancel(final @NonNull GeckoSession session) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + // This line seems necessary for auto-fill to work on the initial page. + mAutofillManager.cancel(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e); + } + } + + @Override + public void onSessionCommit( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.commit(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.commit: ", e); + } + } + + @Override + public void onSessionStart(final @NonNull GeckoSession session) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + // This line seems necessary for auto-fill to work on the initial page. + mAutofillManager.cancel(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e); + } + } + } + + /** + * This delegate is used to provide the GeckoView an Activity context for certain operations such + * as retrieving a PrintManager, which requires an Activity context. Using getContext() directly + * might retrieve an Activity context or a Fragment context, this delegate ensures an Activity + * context. + * + *

    Not to be confused with the GeckoRuntime delegate {@link GeckoRuntime.ActivityDelegate} + * which is tightly coupled with WebAuthn - see bug 1671988. + */ + @AnyThread + public interface ActivityContextDelegate { + /** + * Method should return an Activity context. May return null if not available. + * + * @return Activity context + */ + @Nullable + Context getActivityContext(); + } + + /** + * Sets the delegate for the GeckoView. + * + * @param delegate to provide activity context or null + */ + public void setActivityContextDelegate(final @Nullable ActivityContextDelegate delegate) { + mActivityDelegate = delegate; + } + + /** + * Gets the delegate from the GeckoView. + * + * @return delegate, if set + */ + public @Nullable ActivityContextDelegate getActivityContextDelegate() { + return mActivityDelegate; + } + + /** + * Retrieves the GeckoView's print delegate. + * + * @return The GeckoView's print delegate. + */ + public @Nullable GeckoSession.PrintDelegate getPrintDelegate() { + return mPrintDelegate; + } + + /** + * Sets the GeckoView's print delegate. + * + * @param delegate for printing + */ + public void getPrintDelegate(@Nullable final GeckoSession.PrintDelegate delegate) { + mPrintDelegate = delegate; + } + + private class GeckoViewPrintDelegate implements GeckoSession.PrintDelegate { + public void onPrint(@NonNull final GeckoSession session) { + final GeckoResult geckoResult = session.saveAsPdf(); + geckoResult.accept( + pdfStream -> { + onPrint(pdfStream); + }, + exception -> Log.e(LOGTAG, "Could not create a content PDF to print.", exception)); + } + + public void onPrint(@NonNull final InputStream pdfStream) { + onPrintWithStatus(pdfStream); + } + + public GeckoResult onPrintWithStatus(@NonNull final InputStream pdfStream) { + final GeckoResult isDialogFinished = new GeckoResult(); + if (mActivityDelegate == null) { + Log.w(LOGTAG, "Missing an activity context delegate, which is required for printing."); + isDialogFinished.completeExceptionally( + new GeckoSession.GeckoPrintException(ERROR_NO_ACTIVITY_CONTEXT_DELEGATE)); + return isDialogFinished; + } + final Context printContext = mActivityDelegate.getActivityContext(); + if (printContext == null) { + Log.w(LOGTAG, "An activity context is required for printing."); + isDialogFinished.completeExceptionally( + new GeckoSession.GeckoPrintException(ERROR_NO_ACTIVITY_CONTEXT)); + return isDialogFinished; + } + final PrintManager printManager = + (PrintManager) + mActivityDelegate.getActivityContext().getSystemService(Context.PRINT_SERVICE); + final PrintDocumentAdapter pda = + new GeckoViewPrintDocumentAdapter(pdfStream, getContext(), isDialogFinished); + printManager.print("Firefox", pda, null); + return isDialogFinished; + } + } + + // GeckoDisplay.NewSurfaceProvider + + @Override + public void requestNewSurface() { + // Toggling the View's visibility is enough to provoke a surfaceChanged callback with a new + // Surface on all current versions of Android tested from 5 through to 13. On the more recent of + // those versions, however, this does not work when called from within a prior surfaceChanged + // callback, which we probably are here. We therefore post a Runnable to toggle the visibility + // from outside of the current callback. + post( + new Runnable() { + @Override + public void run() { + mSurfaceWrapper.getView().setVisibility(View.INVISIBLE); + mSurfaceWrapper.getView().setVisibility(View.VISIBLE); + } + }); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java new file mode 100644 index 0000000000..86052b3fcb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java @@ -0,0 +1,196 @@ +/* -*- 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.content.Context; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.print.PageRange; +import android.print.PrintAttributes; +import android.print.PrintDocumentAdapter; +import android.print.PrintDocumentInfo; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoViewPrintDocumentAdapter extends PrintDocumentAdapter { + private static final String LOGTAG = "GVPrintDocumentAdapter"; + private String mPrintName = "Document"; + private File mPdfFile; + private InputStream mPdfInputStream; + private Context mContext; + private Boolean mDoDeleteTmpPdf; + private GeckoResult mPrintDialogFinish = null; + + /** + * Default GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using + * the default Android print functionality. Will make a temporary PDF file from InputStream. + * + * @param pdfInputStream an input stream containing a PDF + * @param context context that should be used for making a temporary file + */ + public GeckoViewPrintDocumentAdapter( + @NonNull final InputStream pdfInputStream, @NonNull final Context context) { + this.mPdfInputStream = pdfInputStream; + this.mContext = context; + this.mDoDeleteTmpPdf = true; + } + + /** + * GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using the + * default Android print functionality. Will make a temporary PDF file from InputStream. + * + * @param pdfInputStream an input stream containing a PDF + * @param context context that should be used for making a temporary file + * @param printDialogFinish result to report that the print finished + */ + public GeckoViewPrintDocumentAdapter( + @NonNull final InputStream pdfInputStream, + @NonNull final Context context, + @Nullable final GeckoResult printDialogFinish) { + this.mPdfInputStream = pdfInputStream; + this.mContext = context; + this.mDoDeleteTmpPdf = true; + this.mPrintDialogFinish = printDialogFinish; + } + + /** + * Default GeckoView PrintDocumentAdapter to be used with a PrintManager to print documents using + * the default Android print functionality. Will use existing PDF file for rendering. The filename + * may be displayed to users. + * + *

    Note: Recommend using other constructor if the PDF file still needs to be created so that + * the UI reflects progress. + * + * @param pdfFile PDF file + */ + public GeckoViewPrintDocumentAdapter(@NonNull final File pdfFile) { + this.mPdfFile = pdfFile; + this.mDoDeleteTmpPdf = false; + this.mPrintName = mPdfFile.getName(); + } + + /** + * Writes the PDF InputStream to a file for the PrintDocumentAdapter to use. + * + * @param pdfInputStream - InputStream containing a PDF + * @param context context that should be used for making a temporary file + * @return temporary PDF file + */ + @AnyThread + public static @Nullable File makeTempPdfFile( + @NonNull final InputStream pdfInputStream, @NonNull final Context context) { + File file = null; + try { + file = File.createTempFile("temp", ".pdf", context.getCacheDir()); + } catch (final IOException ioe) { + Log.e(LOGTAG, "Could not make a file in the cache dir: ", ioe); + } + final int bufferSize = 8192; + final byte[] buffer = new byte[bufferSize]; + try (final OutputStream out = new BufferedOutputStream(new FileOutputStream(file))) { + int len; + while ((len = pdfInputStream.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + } catch (final IOException ioe) { + Log.e(LOGTAG, "Writing temporary PDF file failed: ", ioe); + } + return file; + } + + @Override + public void onStart() { + // Making the PDF file late, if needed, so that the UI reflects that it is preparing + if (mPdfFile == null && mPdfInputStream != null && mContext != null) { + this.mPdfFile = makeTempPdfFile(mPdfInputStream, mContext); + if (mPdfFile != null) { + this.mPrintName = mPdfFile.getName(); + } + } + } + + @Override + public void onLayout( + final PrintAttributes oldAttributes, + final PrintAttributes newAttributes, + final CancellationSignal cancellationSignal, + final LayoutResultCallback layoutResultCallback, + final Bundle bundle) { + if (cancellationSignal.isCanceled()) { + layoutResultCallback.onLayoutCancelled(); + return; + } + final PrintDocumentInfo pdi = + new PrintDocumentInfo.Builder(mPrintName) + .setContentType(PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .build(); + layoutResultCallback.onLayoutFinished(pdi, true); + } + + @Override + public void onWrite( + final PageRange[] pageRanges, + final ParcelFileDescriptor parcelFileDescriptor, + final CancellationSignal cancellationSignal, + final WriteResultCallback writeResultCallback) { + ThreadUtils.postToBackgroundThread( + new Runnable() { + @Override + public void run() { + InputStream input = null; + OutputStream output = null; + try { + input = new FileInputStream(mPdfFile); + output = new FileOutputStream(parcelFileDescriptor.getFileDescriptor()); + final int bufferSize = 8192; + final byte[] buffer = new byte[bufferSize]; + int bytesRead; + while ((bytesRead = input.read(buffer)) > 0) { + output.write(buffer, 0, bytesRead); + } + writeResultCallback.onWriteFinished(new PageRange[] {PageRange.ALL_PAGES}); + } catch (final Exception ex) { + Log.e(LOGTAG, "Could not complete onWrite for printing: ", ex); + writeResultCallback.onWriteFailed(null); + } finally { + try { + input.close(); + output.close(); + } catch (final Exception ex) { + Log.e(LOGTAG, "Could not close i/o stream: ", ex); + } + } + } + }); + } + + @Override + public void onFinish() { + // Remove the temporary file when the printing system is finished. + try { + if (mPdfFile != null && mDoDeleteTmpPdf) { + mPdfFile.delete(); + } + } catch (final NullPointerException npe) { + // Silence the exception. We only want to delete a real file. We don't + // care if the file doesn't exist. + } + if (this.mPrintDialogFinish != null) { + mPrintDialogFinish.complete(true); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java new file mode 100644 index 0000000000..1546451056 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java @@ -0,0 +1,189 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Locale; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * GeckoWebExecutor is responsible for fetching a {@link WebRequest} and delivering a {@link + * WebResponse} to the caller via {@link #fetch(WebRequest)}. Example: + * + *

    + *     final GeckoWebExecutor executor = new GeckoWebExecutor();
    + *
    + *     final GeckoResult<WebResponse> result = executor.fetch(
    + *             new WebRequest.Builder("https://example.org/json")
    + *             .header("Accept", "application/json")
    + *             .build());
    + *
    + *     result.then(response -> {
    + *         // Do something with response
    + *     });
    + * 
    + */ +@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 result); + + @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve") + private static native void nativeResolve(String host, GeckoResult result); + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult") + private static ByteBuffer createByteBuffer(final int capacity) { + return ByteBuffer.allocateDirect(capacity); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FETCH_FLAGS_NONE, + FETCH_FLAGS_ANONYMOUS, + FETCH_FLAGS_NO_REDIRECTS, + FETCH_FLAGS_PRIVATE, + FETCH_FLAGS_STREAM_FAILURE_TEST, + }) + public @interface FetchFlags {} + + /** No special treatment. */ + public static final int FETCH_FLAGS_NONE = 0; + + /** Don't send cookies or other user data along with the request. */ + @WrapForJNI public static final int FETCH_FLAGS_ANONYMOUS = 1; + + /** Don't automatically follow redirects. */ + @WrapForJNI public static final int FETCH_FLAGS_NO_REDIRECTS = 1 << 1; + + // There was supposed to be another flag, which we then decided not to implement. + // That's the reason there's no value 1 << 2, and it can absolutely be used :) + + /** Associates this download with the current private browsing session */ + @WrapForJNI public static final int FETCH_FLAGS_PRIVATE = 1 << 3; + + /** This flag causes a read error in the {@link WebResponse} body. Useful for testing. */ + @WrapForJNI public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1 << 10; + + /** + * Create a new GeckoWebExecutor instance. + * + * @param runtime A GeckoRuntime instance + */ + public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) { + mRuntime = runtime; + } + + /** + * Send the given {@link WebRequest}. + * + * @param request A {@link WebRequest} instance + * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the + * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a + * {@link WebRequestError}. + * @throws IllegalArgumentException if request is null or otherwise unusable. + */ + public @NonNull GeckoResult 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 fetch( + final @NonNull WebRequest request, final @FetchFlags int flags) { + if (request.body != null && !request.body.isDirect()) { + throw new IllegalArgumentException("Request body must be a direct ByteBuffer"); + } + + if (request.cacheMode < WebRequest.CACHE_MODE_FIRST + || request.cacheMode > WebRequest.CACHE_MODE_LAST) { + throw new IllegalArgumentException("Unknown cache mode"); + } + + final String uri = request.uri.toLowerCase(Locale.ROOT); + // We don't need to fully validate the URI here, just a sanity check + if (!uri.startsWith("http") && !uri.startsWith("blob")) { + throw new IllegalArgumentException( + "Unsupported URI scheme: " + (uri.length() > 10 ? uri.substring(0, 10) : uri)); + } + + final GeckoResult 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 resolve(final @NonNull String host) { + final GeckoResult result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeResolve(host, result); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + this, + "nativeResolve", + String.class, + host, + GeckoResult.class, + result); + } + return result; + } + + /** + * This causes a speculative connection to be made to the host in the specified URI. This is + * useful if an app thinks it may be making a request to that host in the near future. If no + * request is made, the connection will be cleaned up after an unspecified amount of time. + * + * @param uri A URI String. + */ + public void speculativeConnect(final @NonNull String uri) { + GeckoThread.speculativeConnect(uri); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java new file mode 100644 index 0000000000..34bf6b0161 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java @@ -0,0 +1,54 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.graphics.Bitmap; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ImageResource; + +/** Represents an Web API image resource as used in web app manifests and media session metadata. */ +@AnyThread +public class Image { + private final ImageResource.Collection mCollection; + + /* package */ Image(final ImageResource.Collection collection) { + mCollection = collection; + } + + /* package */ static Image fromSizeSrcBundle(final GeckoBundle bundle) { + return new Image(ImageResource.Collection.fromSizeSrcBundle(bundle)); + } + + /** + * Get the best version of this image for size size. Embedders are encouraged to + * cache the result of this method keyed with this instance. + * + * @param size pixel size at which this image will be displayed at. + * @return A {@link GeckoResult} that resolves to the bitmap when ready. Will resolve + * exceptionally to {@link ImageProcessingException} if the image cannot be processed. + */ + @NonNull + public GeckoResult getBitmap(final int size) { + return mCollection.getBitmap(size); + } + + /** Thrown whenever an image cannot be processed by {@link #getBitmap} */ + @WrapForJNI + public static class ImageProcessingException extends RuntimeException { + /** + * Build an instance of this class. + * + * @param message description of the error. + */ + public ImageProcessingException(final String message) { + super(message); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java new file mode 100644 index 0000000000..2d220458cc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java @@ -0,0 +1,647 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ImageResource; + +/** + * The MediaSession API provides media controls and events for a GeckoSession. This includes support + * for the DOM Media Session API and regular HTML media content. + * + * @see Media Session + * API + */ +@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 final GeckoSession session, @NonNull final 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 final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify on updated metadata. Metadata may be provided by content via the DOM API or by + * GeckoView when not availble. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param meta The updated metadata. + */ + default void onMetadata( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @NonNull final Metadata meta) {} + + /** + * Notify on updated supported features. Unsupported actions will have no effect. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param features A combination of {@link Feature}. + */ + default void onFeatures( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @MSFeature final long features) {} + + /** + * Notify that playback has started for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onPlay( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify that playback has paused for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onPause( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify that playback has stopped for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onStop( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify on updated position state. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param state An instance of {@link PositionState}. + */ + default void onPositionState( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @NonNull final PositionState state) {} + + /** + * Notify on changed fullscreen state. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param enabled True when this media session in in fullscreen mode. + * @param meta An instance of {@link ElementMetadata}, if enabled. + */ + default void onFullscreen( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + final boolean enabled, + @Nullable final ElementMetadata meta) {} + } + + /** The representation of a media element's metadata. */ + public static class ElementMetadata { + /** The media source URI. */ + public final @Nullable String source; + + /** The duration of the media in seconds. 0.0 if unknown. */ + public final double duration; + + /** The width of the video in device pixels. 0 if unknown. */ + public final long width; + + /** The height of the video in device pixels. 0 if unknown. */ + public final long height; + + /** The number of audio tracks contained in this element. */ + public final int audioTrackCount; + + /** The number of video tracks contained in this element. */ + public final int videoTrackCount; + + /** + * ElementMetadata constructor. + * + * @param source The media URI. + * @param duration The media duration in seconds. + * @param width The video width in device pixels. + * @param height The video height in device pixels. + * @param audioTrackCount The audio track count. + * @param videoTrackCount The video track count. + */ + public ElementMetadata( + @Nullable final String source, + final double duration, + final long width, + final long height, + final int audioTrackCount, + final int videoTrackCount) { + this.source = source; + this.duration = duration; + this.width = width; + this.height = height; + this.audioTrackCount = audioTrackCount; + this.videoTrackCount = videoTrackCount; + } + + /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) { + // Sync with MediaUtils.sys.mjs. + return new ElementMetadata( + bundle.getString("src"), + bundle.getDouble("duration", 0.0), + bundle.getLong("width", 0), + bundle.getLong("height", 0), + bundle.getInt("audioTrackCount", 0), + bundle.getInt("videoTrackCount", 0)); + } + } + + /** The representation of a media session's metadata. */ + public static class Metadata { + /** The media title. May be backfilled based on the document's title. May be null or empty. */ + public final @Nullable String title; + + /** The media artist name. May be null or empty. */ + public final @Nullable String artist; + + /** The media album title. May be null or empty. */ + public final @Nullable String album; + + /** The media artwork image. May be null. */ + public final @Nullable Image artwork; + + /** + * Metadata constructor. + * + * @param title The media title string. + * @param artist The media artist string. + * @param album The media album string. + * @param artwork The media artwork {@link Image}. + */ + protected Metadata( + final @Nullable String title, + final @Nullable String artist, + final @Nullable String album, + final @Nullable Image artwork) { + this.title = title; + this.artist = artist; + this.album = album; + this.artwork = artwork; + } + + @AnyThread + /* package */ static final class Builder { + private final GeckoBundle mBundle; + + public Builder(final GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + public Builder(final Metadata meta) { + mBundle = meta.toBundle(); + } + + @NonNull + Builder title(final @Nullable String title) { + mBundle.putString("title", title); + return this; + } + + @NonNull + Builder artist(final @Nullable String artist) { + mBundle.putString("artist", artist); + return this; + } + + @NonNull + Builder album(final @Nullable String album) { + mBundle.putString("album", album); + return this; + } + } + + /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) { + final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork"); + + final ImageResource.Collection.Builder artworkBuilder = + new ImageResource.Collection.Builder(); + + for (final GeckoBundle artworkBundle : artworkBundles) { + artworkBuilder.add(ImageResource.fromBundle(artworkBundle)); + } + + return new Metadata( + bundle.getString("title"), + bundle.getString("artist"), + bundle.getString("album"), + new Image(artworkBuilder.build())); + } + + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(3); + bundle.putString("title", title); + bundle.putString("artist", artist); + bundle.putString("album", album); + return bundle; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("Metadata {"); + builder + .append(", title=") + .append(title) + .append(", artist=") + .append(artist) + .append(", album=") + .append(album) + .append(", artwork=") + .append(artwork) + .append("}"); + return builder.toString(); + } + } + + /** Holds the details of the media session's playback state. */ + public static class PositionState { + /** The duration of the media in seconds. */ + public final double duration; + + /** The last reported media playback position in seconds. */ + public final double position; + + /** + * The media playback rate coefficient. The rate is positive for forward and negative for + * backward playback. + */ + public final double playbackRate; + + /** + * PositionState constructor. + * + * @param duration The media duration in seconds. + * @param position The current media playback position in seconds. + * @param playbackRate The playback rate coefficient. + */ + protected PositionState( + final double duration, final double position, final double playbackRate) { + this.duration = duration; + this.position = position; + this.playbackRate = playbackRate; + } + + /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) { + return new PositionState( + bundle.getDouble("duration"), + bundle.getDouble("position"), + bundle.getDouble("playbackRate")); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("PositionState {"); + builder + .append("duration=") + .append(duration) + .append(", position=") + .append(position) + .append(", playbackRate=") + .append(playbackRate) + .append("}"); + return builder.toString(); + } + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = { + Feature.NONE, + Feature.PLAY, + Feature.PAUSE, + Feature.STOP, + Feature.SEEK_TO, + Feature.SEEK_FORWARD, + Feature.SEEK_BACKWARD, + Feature.SKIP_AD, + Feature.NEXT_TRACK, + Feature.PREVIOUS_TRACK, + // Feature.SET_VIDEO_SURFACE + }) + public @interface MSFeature {} + + /** Flags for supported media session features. */ + public static class Feature { + public static final long NONE = 0; + + /** Playback supported. */ + public static final long PLAY = 1 << 0; + + /** Pausing supported. */ + public static final long PAUSE = 1 << 1; + + /** Stopping supported. */ + public static final long STOP = 1 << 2; + + /** Absolute seeking supported. */ + public static final long SEEK_TO = 1 << 3; + + /** Relative seeking supported (forward). */ + public static final long SEEK_FORWARD = 1 << 4; + + /** Relative seeking supported (backward). */ + public static final long SEEK_BACKWARD = 1 << 5; + + /** Skipping advertisements supported. */ + public static final long SKIP_AD = 1 << 6; + + /** Next track selection supported. */ + public static final long NEXT_TRACK = 1 << 7; + + /** Previous track selection supported. */ + public static final long PREVIOUS_TRACK = 1 << 8; + + /** Focusing supported. */ + public static final long FOCUS = 1 << 9; + + // /** + // * Custom video surface supported. + // */ + // public static final long SET_VIDEO_SURFACE = 1 << 10; + + /* package */ static long fromBundle(final GeckoBundle bundle) { + // Sync with MediaController.webidl. + final long features = + NONE + | (bundle.getBoolean("play") ? PLAY : NONE) + | (bundle.getBoolean("pause") ? PAUSE : NONE) + | (bundle.getBoolean("stop") ? STOP : NONE) + | (bundle.getBoolean("seekto") ? SEEK_TO : NONE) + | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE) + | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE) + | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE) + | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE) + | (bundle.getBoolean("skipad") ? SKIP_AD : NONE) + | (bundle.getBoolean("focus") ? FOCUS : NONE); + return features; + } + } + + private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated"; + private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated"; + private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata"; + private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState"; + private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features"; + private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen"; + private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None"; + private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused"; + private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing"; + + private static final String PLAY_EVENT = "GeckoView:MediaSession:Play"; + private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause"; + private static final String STOP_EVENT = "GeckoView:MediaSession:Stop"; + private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack"; + private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack"; + private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward"; + private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward"; + private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd"; + private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo"; + private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio"; + + /* package */ static class Handler extends GeckoSessionHandler { + + 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)) { + final boolean enabled = message.getBoolean("enabled"); + final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata")); + if (!mMediaSession.isActive()) { + if (DEBUG) { + Log.d(LOGTAG, "Media session is not active yet"); + } + callback.sendSuccess(false); + return; + } + delegate.onFullscreen(mSession, mMediaSession, enabled, meta); + callback.sendSuccess(true); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java new file mode 100644 index 0000000000..e2a4c236b5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java @@ -0,0 +1,60 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +public class OrientationController { + private OrientationDelegate mDelegate; + + OrientationController() {} + + /** + * Sets the {@link OrientationDelegate} for this instance. + * + * @param delegate The {@link OrientationDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable OrientationDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Gets the {@link OrientationDelegate} for this instance. + * + * @return delegate The {@link OrientationDelegate} instance. + */ + @UiThread + @Nullable + public OrientationDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + return mDelegate; + } + + /** This delegate will be called whenever an orientation lock is called. */ + @UiThread + public interface OrientationDelegate { + /** + * Called whenever the orientation should be locked. + * + * @param aOrientation The desired orientation such as ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + * @return A {@link GeckoResult} which resolves to a {@link AllowOrDeny} + */ + @Nullable + default GeckoResult onOrientationLock(@NonNull final int aOrientation) { + return null; + } + + /** Called whenever the orientation should be unlocked. */ + @Nullable + default void onOrientationUnlock() {} + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java new file mode 100644 index 0000000000..efd8061c98 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java @@ -0,0 +1,246 @@ +/* -*- 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.SuppressLint; +import android.content.Context; +import android.graphics.BlendMode; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.os.Build; +import android.widget.EdgeEffect; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.reflect.Field; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public final class OverscrollEdgeEffect { + // Used to index particular edges in the edges array + private static final int TOP = 0; + private static final int BOTTOM = 1; + private static final int LEFT = 2; + private static final int RIGHT = 3; + + /* package */ static final int AXIS_X = 0; + /* package */ static final int AXIS_Y = 1; + + // All four edges of the screen + private final EdgeEffect[] mEdges = new EdgeEffect[4]; + + private GeckoSession mSession; + private Runnable mInvalidationCallback; + private int mWidth; + private int mHeight; + + /* package */ OverscrollEdgeEffect() {} + + private static Field sPaintField; + + @SuppressLint("DiscouragedPrivateApi") + private void setBlendMode(final EdgeEffect edgeEffect) { + if (Build.VERSION.SDK_INT < 29) { + // setBlendMode is only supported on SDK_INT >= 29 and above. + + if (sPaintField == null) { + try { + sPaintField = EdgeEffect.class.getDeclaredField("mPaint"); + sPaintField.setAccessible(true); + } catch (final NoSuchFieldException e) { + // Cannot get the field, nothing we can do here + return; + } + } + + try { + final Paint paint = (Paint) sPaintField.get(edgeEffect); + final PorterDuffXfermode mode = new PorterDuffXfermode(PorterDuff.Mode.SRC); + paint.setXfermode(mode); + } catch (final IllegalAccessException ex) { + // Nothing we can do + } + + return; + } + + edgeEffect.setBlendMode(BlendMode.SRC); + } + + /** + * Set the theme to use for overscroll from a given Context. + * + * @param context Context to use for the overscroll theme. + */ + public void setTheme(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + + for (int i = 0; i < mEdges.length; i++) { + final EdgeEffect edgeEffect = new EdgeEffect(context); + if (mWidth != 0 || mHeight != 0) { + edgeEffect.setSize(mWidth, mHeight); + } + setBlendMode(edgeEffect); + mEdges[i] = edgeEffect; + } + } + + /* package */ void setSession(final @Nullable GeckoSession session) { + mSession = session; + } + + /** + * Set a Runnable that acts as a callback to invalidate the overscroll effect (for example, as a + * response to user fling for example). The Runnbale should schedule a future call to {@link + * #draw(Canvas)} as a result of the invalidation. + * + * @param runnable Invalidation Runnable. + * @see #getInvalidationCallback() + */ + public void setInvalidationCallback(final @Nullable Runnable runnable) { + ThreadUtils.assertOnUiThread(); + mInvalidationCallback = runnable; + } + + /** + * Get the current invalidatation Runnable. + * + * @return Invalidation Runnable. + * @see #setInvalidationCallback(Runnable) + */ + public @Nullable Runnable getInvalidationCallback() { + ThreadUtils.assertOnUiThread(); + return mInvalidationCallback; + } + + /* package */ void setSize(final int width, final int height) { + mEdges[LEFT].setSize(height, width); + mEdges[RIGHT].setSize(height, width); + mEdges[TOP].setSize(width, height); + mEdges[BOTTOM].setSize(width, height); + + mWidth = width; + mHeight = height; + } + + private EdgeEffect getEdgeForAxisAndSide(final int axis, final float side) { + if (axis == AXIS_Y) { + if (side < 0) { + return mEdges[TOP]; + } else { + return mEdges[BOTTOM]; + } + } else { + if (side < 0) { + return mEdges[LEFT]; + } else { + return mEdges[RIGHT]; + } + } + } + + /* package */ void setVelocity(final float velocity, final int axis) { + if (velocity == 0.0f) { + if (axis == AXIS_Y) { + mEdges[TOP].onRelease(); + mEdges[BOTTOM].onRelease(); + } else { + mEdges[LEFT].onRelease(); + mEdges[RIGHT].onRelease(); + } + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + return; + } + + final EdgeEffect edge = getEdgeForAxisAndSide(axis, velocity); + + // If we're showing overscroll already, start fading it out. + if (!edge.isFinished()) { + edge.onRelease(); + } else { + // Otherwise, show an absorb effect + edge.onAbsorb((int) velocity); + } + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + /* package */ void setDistance(final float distance, final int axis) { + // The first overscroll event often has zero distance. Throw it out + if (distance == 0.0f) { + return; + } + + final EdgeEffect edge = getEdgeForAxisAndSide(axis, (int) distance); + edge.onPull(distance / (axis == AXIS_X ? mWidth : mHeight)); + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + /** + * Draw the overscroll effect on a Canvas. + * + * @param canvas Canvas to draw on. + */ + public void draw(final @NonNull Canvas canvas) { + ThreadUtils.assertOnUiThread(); + + if (mSession == null) { + return; + } + + final Rect pageRect = new Rect(); + mSession.getSurfaceBounds(pageRect); + + // If we're pulling an edge, or fading it out, draw! + boolean invalidate = false; + if (!mEdges[TOP].isFinished()) { + invalidate |= draw(mEdges[TOP], canvas, pageRect.left, pageRect.top, 0); + } + + if (!mEdges[BOTTOM].isFinished()) { + invalidate |= draw(mEdges[BOTTOM], canvas, pageRect.right, pageRect.bottom, 180); + } + + if (!mEdges[LEFT].isFinished()) { + invalidate |= draw(mEdges[LEFT], canvas, pageRect.left, pageRect.bottom, 270); + } + + if (!mEdges[RIGHT].isFinished()) { + invalidate |= draw(mEdges[RIGHT], canvas, pageRect.right, pageRect.top, 90); + } + + // If the edge effect is animating off screen, invalidate. + if (invalidate && mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + private static boolean draw( + final EdgeEffect edge, + final Canvas canvas, + final float translateX, + final float translateY, + final float rotation) { + final int state = canvas.save(); + canvas.translate(translateX, translateY); + canvas.rotate(rotation); + final boolean invalidate = edge.draw(canvas); + canvas.restoreToCount(state); + + return invalidate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java new file mode 100644 index 0000000000..0731e4e095 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java @@ -0,0 +1,949 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.InputDevice; +import android.view.MotionEvent; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class PanZoomController { + private static final String LOGTAG = "GeckoNPZC"; + private static final int EVENT_SOURCE_SCROLL = 0; + private static final int EVENT_SOURCE_MOTION = 1; + private static final int EVENT_SOURCE_MOUSE = 2; + private static Boolean sTreatMouseAsTouch = null; + + private final GeckoSession mSession; + private final Rect mTempRect = new Rect(); + private boolean mAttached; + private float mPointerScrollFactor = 64.0f; + private long mLastDownTime; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({SCROLL_BEHAVIOR_SMOOTH, SCROLL_BEHAVIOR_AUTO}) + public @interface ScrollBehaviorType {} + + /** Specifies smooth scrolling which animates content to the desired scroll position. */ + public static final int SCROLL_BEHAVIOR_SMOOTH = 0; + + /** Specifies auto scrolling which jumps content to the desired scroll position. */ + public static final int SCROLL_BEHAVIOR_AUTO = 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + INPUT_RESULT_UNHANDLED, + INPUT_RESULT_HANDLED, + INPUT_RESULT_HANDLED_CONTENT, + INPUT_RESULT_IGNORED + }) + public @interface InputResult {} + + /** + * Specifies that an input event was not handled by the PanZoomController for a panning or zooming + * operation. The event may have been handled by Web content or internally (e.g. text selection). + */ + @WrapForJNI public static final int INPUT_RESULT_UNHANDLED = 0; + + /** + * Specifies that an input event was handled by the PanZoomController for a panning or zooming + * operation, but likely not by any touch event listeners in Web content. + */ + @WrapForJNI public static final int INPUT_RESULT_HANDLED = 1; + + /** + * Specifies that an input event was handled by the PanZoomController and passed on to touch event + * listeners in Web content. + */ + @WrapForJNI public static final int INPUT_RESULT_HANDLED_CONTENT = 2; + + /** + * Specifies that an input event was consumed by a PanZoomController internally and browsers + * should do nothing in response to the event. + */ + @WrapForJNI public static final int INPUT_RESULT_IGNORED = 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SCROLLABLE_FLAG_NONE, + SCROLLABLE_FLAG_TOP, + SCROLLABLE_FLAG_RIGHT, + SCROLLABLE_FLAG_BOTTOM, + SCROLLABLE_FLAG_LEFT + }) + public @interface ScrollableDirections {} + + /** + * Represents which directions can be scrolled in the scroll container where an input event was + * handled. This value is only useful in the case of {@link + * PanZoomController#INPUT_RESULT_HANDLED}. + */ + /* The container cannot be scrolled. */ + @WrapForJNI public static final int SCROLLABLE_FLAG_NONE = 0; + + /* The container cannot be scrolled to top */ + @WrapForJNI public static final int SCROLLABLE_FLAG_TOP = 1 << 0; + /* The container cannot be scrolled to right */ + @WrapForJNI public static final int SCROLLABLE_FLAG_RIGHT = 1 << 1; + /* The container cannot be scrolled to bottom */ + @WrapForJNI public static final int SCROLLABLE_FLAG_BOTTOM = 1 << 2; + /* The container cannot be scrolled to left */ + @WrapForJNI public static final int SCROLLABLE_FLAG_LEFT = 1 << 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {OVERSCROLL_FLAG_NONE, OVERSCROLL_FLAG_HORIZONTAL, OVERSCROLL_FLAG_VERTICAL}) + public @interface OverscrollDirections {} + + /** + * Represents which directions can be over-scrolled in the scroll container where an input event + * was handled. This value is only useful in the case of {@link + * PanZoomController#INPUT_RESULT_HANDLED}. + */ + /* the container cannot be over-scrolled. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_NONE = 0; + + /* the container can be over-scrolled horizontally. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_HORIZONTAL = 1 << 0; + /* the container can be over-scrolled vertically. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_VERTICAL = 1 << 1; + + /** + * Represents how a {@link MotionEvent} was handled in Gecko. This value can be used by browser + * apps to implement features like pull-to-refresh. Failing to account this value might break some + * websites expectations about touch events. + * + *

    For example, a {@link PanZoomController.InputResultDetail#handledResult} value of {@link + * PanZoomController#INPUT_RESULT_HANDLED} and {@link + * PanZoomController.InputResultDetail#overscrollDirections} of {@link + * PanZoomController#OVERSCROLL_FLAG_NONE} indicates that the event was consumed for a panning or + * zooming operation and that the website does not expect the browser to react to the touch event + * (say, by triggering the pull-to-refresh feature) even though the scroll container reached to + * the edge. + */ + @WrapForJNI + public static class InputResultDetail { + protected InputResultDetail( + final @InputResult int handledResult, + final @ScrollableDirections int scrollableDirections, + final @OverscrollDirections int overscrollDirections) { + mHandledResult = handledResult; + mScrollableDirections = scrollableDirections; + mOverscrollDirections = overscrollDirections; + } + + /** + * @return One of the {@link #INPUT_RESULT_UNHANDLED INPUT_RESULT_*} indicating how the event + * was handled. + */ + @AnyThread + public @InputResult int handledResult() { + return mHandledResult; + } + + /** + * @return an OR-ed value of {@link #SCROLLABLE_FLAG_NONE SCROLLABLE_FLAG_*} indicating which + * directions can be scrollable. + */ + @AnyThread + public @ScrollableDirections int scrollableDirections() { + return mScrollableDirections; + } + + /** + * @return an OR-ed value of {@link #OVERSCROLL_FLAG_NONE OVERSCROLL_FLAG_*} indicating which + * directions can be over-scrollable. + */ + @AnyThread + public @OverscrollDirections int overscrollDirections() { + return mOverscrollDirections; + } + + private final @InputResult int mHandledResult; + private final @ScrollableDirections int mScrollableDirections; + private final @OverscrollDirections int mOverscrollDirections; + } + + private SynthesizedEventState mPointerState; + + private ArrayList> mQueuedEvents; + + private boolean mSynthesizedEvent = false; + + @WrapForJNI + private static class MotionEventData { + public final int action; + public final int actionIndex; + public final long time; + public final int metaState; + public final int pointerId[]; + public final int historySize; + public final long historicalTime[]; + public final float historicalX[]; + public final float historicalY[]; + public final float historicalOrientation[]; + public final float historicalPressure[]; + public final float historicalToolMajor[]; + public final float historicalToolMinor[]; + public final float x[]; + public final float y[]; + public final float orientation[]; + public final float pressure[]; + public final float toolMajor[]; + public final float toolMinor[]; + + public MotionEventData(final MotionEvent event) { + final int count = event.getPointerCount(); + action = event.getActionMasked(); + actionIndex = event.getActionIndex(); + time = event.getEventTime(); + metaState = event.getMetaState(); + historySize = event.getHistorySize(); + historicalTime = new long[historySize]; + historicalX = new float[historySize * count]; + historicalY = new float[historySize * count]; + historicalOrientation = new float[historySize * count]; + historicalPressure = new float[historySize * count]; + historicalToolMajor = new float[historySize * count]; + historicalToolMinor = new float[historySize * count]; + pointerId = new int[count]; + x = new float[count]; + y = new float[count]; + orientation = new float[count]; + pressure = new float[count]; + toolMajor = new float[count]; + toolMinor = new float[count]; + + for (int historyIndex = 0; historyIndex < historySize; historyIndex++) { + historicalTime[historyIndex] = event.getHistoricalEventTime(historyIndex); + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + for (int i = 0; i < count; i++) { + pointerId[i] = event.getPointerId(i); + + for (int historyIndex = 0; historyIndex < historySize; historyIndex++) { + event.getHistoricalPointerCoords(i, historyIndex, coords); + + final int historicalI = historyIndex * count + i; + historicalX[historicalI] = coords.x; + historicalY[historicalI] = coords.y; + + historicalOrientation[historicalI] = coords.orientation; + historicalPressure[historicalI] = coords.pressure; + + // If we are converting to CSS pixels, we should adjust the radii as well. + historicalToolMajor[historicalI] = coords.toolMajor; + historicalToolMinor[historicalI] = coords.toolMinor; + } + + event.getPointerCoords(i, coords); + + x[i] = coords.x; + y[i] = coords.y; + + orientation[i] = coords.orientation; + pressure[i] = coords.pressure; + + // If we are converting to CSS pixels, we should adjust the radii as well. + toolMajor[i] = coords.toolMajor; + toolMinor[i] = coords.toolMinor; + } + } + } + + /* package */ final class NativeProvider extends JNIObject { + @Override // JNIObject + protected void disposeNative() { + // Disposal happens in native code. + throw new UnsupportedOperationException(); + } + + @WrapForJNI(calledFrom = "ui") + private native void handleMotionEvent( + MotionEventData eventData, + float screenX, + float screenY, + GeckoResult result); + + @WrapForJNI(calledFrom = "ui") + private native @InputResult int handleScrollEvent( + long time, int metaState, float x, float y, float hScroll, float vScroll); + + @WrapForJNI(calledFrom = "ui") + private native @InputResult int handleMouseEvent( + int action, long time, int metaState, float x, float y, int buttons); + + @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread. + private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled); + + @WrapForJNI(calledFrom = "ui") + private void synthesizeNativeTouchPoint( + final int pointerId, + final int eventType, + final int clientX, + final int clientY, + final double pressure, + final int orientation) { + if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) { + throw new IllegalArgumentException("Pointer ID reserved for mouse"); + } + synthesizeNativePointer( + InputDevice.SOURCE_TOUCHSCREEN, + pointerId, + eventType, + clientX, + clientY, + pressure, + orientation, + 0); + } + + @WrapForJNI(calledFrom = "ui") + private void synthesizeNativeMouseEvent( + final int eventType, final int clientX, final int clientY, final int button) { + synthesizeNativePointer( + InputDevice.SOURCE_MOUSE, + PointerInfo.RESERVED_MOUSE_POINTER_ID, + eventType, + clientX, + clientY, + 0, + 0, + button); + } + + @WrapForJNI(calledFrom = "ui") + private void setAttached(final boolean attached) { + if (attached) { + mAttached = true; + flushEventQueue(); + } else if (mAttached) { + mAttached = false; + enableEventQueue(); + } + } + } + + /* package */ final NativeProvider mNative = new NativeProvider(); + + private void handleMotionEvent(final MotionEvent event) { + handleMotionEvent(event, null); + } + + private void handleMotionEvent( + final MotionEvent event, final GeckoResult result) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event)); + if (result != null) { + result.complete( + new InputResultDetail( + INPUT_RESULT_HANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + return; + } + + final int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mLastDownTime = event.getDownTime(); + } else if (mLastDownTime != event.getDownTime()) { + if (result != null) { + result.complete( + new InputResultDetail( + INPUT_RESULT_UNHANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + return; + } + + final float screenX = event.getRawX() - event.getX(); + final float screenY = event.getRawY() - event.getY(); + + // Take this opportunity to update screen origin of session. This gets + // dispatched to the gecko thread, so we also pass the new screen x/y directly to apz. + // If this is a synthesized touch, the screen offset is bogus so ignore it. + if (!mSynthesizedEvent) { + mSession.onScreenOriginChanged((int) screenX, (int) screenY); + } + + final MotionEventData data = new MotionEventData(event); + mNative.handleMotionEvent(data, screenX, screenY, result); + } + + private @InputResult int handleScrollEvent(final MotionEvent event) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_SCROLL, event)); + return INPUT_RESULT_HANDLED; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return INPUT_RESULT_UNHANDLED; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + + // Translate surface origin to client origin for scroll events. + mSession.getSurfaceBounds(mTempRect); + final float x = coords.x - mTempRect.left; + final float y = coords.y - mTempRect.top; + + final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * mPointerScrollFactor; + final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * mPointerScrollFactor; + + return mNative.handleScrollEvent( + event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll); + } + + private @InputResult int handleMouseEvent(final MotionEvent event) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOUSE, event)); + return INPUT_RESULT_UNHANDLED; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return INPUT_RESULT_UNHANDLED; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + + // Translate surface origin to client origin for mouse events. + mSession.getSurfaceBounds(mTempRect); + final float x = coords.x - mTempRect.left; + final float y = coords.y - mTempRect.top; + + return mNative.handleMouseEvent( + event.getActionMasked(), + event.getEventTime(), + event.getMetaState(), + x, + y, + event.getButtonState()); + } + + protected PanZoomController(final GeckoSession session) { + mSession = session; + enableEventQueue(); + } + + private boolean treatMouseAsTouch() { + if (sTreatMouseAsTouch == null) { + final Context c = GeckoAppShell.getApplicationContext(); + if (c == null) { + // This might happen if the GeckoRuntime has not been initialized yet. + return false; + } + final UiModeManager m = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE); + // on TV devices, treat mouse as touch. everywhere else, don't + sTreatMouseAsTouch = (m.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION); + } + + return sTreatMouseAsTouch; + } + + /** + * Set the current scroll factor. The scroll factor is the maximum scroll amount that one scroll + * event may generate, in device pixels. + * + * @param factor Scroll factor. + */ + public void setScrollFactor(final float factor) { + ThreadUtils.assertOnUiThread(); + mPointerScrollFactor = factor; + } + + /** + * Get the current scroll factor. + * + * @return Scroll factor. + */ + public float getScrollFactor() { + ThreadUtils.assertOnUiThread(); + return mPointerScrollFactor; + } + + /** + * This is a workaround for touch pad on Android app by Chrome OS. Android app on Chrome OS fires + * weird motion event by two finger scroll. See https://crbug.com/704051 + */ + private boolean mayTouchpadScroll(final @NonNull MotionEvent event) { + final int action = event.getActionMasked(); + return event.getButtonState() == 0 + && (action == MotionEvent.ACTION_DOWN + || (mLastDownTime == event.getDownTime() + && (action == MotionEvent.ACTION_MOVE + || action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL))); + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather + * than as "mouse". Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onTouchEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!treatMouseAsTouch() + && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE + && !mayTouchpadScroll(event)) { + handleMouseEvent(event); + return; + } + handleMotionEvent(event); + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather + * than as "mouse". Pointer coordinates should be relative to the display surface. + * + *

    NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited + * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and + * unnecessary GC pressure. Instead, prefer to call {@link #onTouchEvent(MotionEvent)}. + * + * @param event MotionEvent to process. + * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}). + */ + public @NonNull GeckoResult onTouchEventForDetailResult( + final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!treatMouseAsTouch() + && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE + && !mayTouchpadScroll(event)) { + return GeckoResult.fromValue( + new InputResultDetail( + handleMouseEvent(event), SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + + final GeckoResult result = new GeckoResult<>(); + handleMotionEvent(event, result); + return result; + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "mouse" rather + * than as "touch". Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onMouseEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) { + return; + } + handleMotionEvent(event); + } + + @Override + protected void finalize() throws Throwable { + mNative.setAttached(false); + } + + /** + * Process a non-touch motion event through the pan-zoom controller. Currently, hover and scroll + * events are supported. Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onMotionEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + final int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_SCROLL) { + if (event.getDownTime() >= mLastDownTime) { + mLastDownTime = event.getDownTime(); + } else if ((InputDevice.getDevice(event.getDeviceId()) != null) + && (InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD) + == InputDevice.SOURCE_TOUCHPAD) { + return; + } + handleScrollEvent(event); + } else if ((action == MotionEvent.ACTION_HOVER_MOVE) + || (action == MotionEvent.ACTION_HOVER_ENTER) + || (action == MotionEvent.ACTION_HOVER_EXIT)) { + handleMouseEvent(event); + } + } + + private void enableEventQueue() { + if (mQueuedEvents != null) { + throw new IllegalStateException("Already have an event queue"); + } + mQueuedEvents = new ArrayList<>(); + } + + private void flushEventQueue() { + if (mQueuedEvents == null) { + return; + } + + final ArrayList> events = mQueuedEvents; + mQueuedEvents = null; + for (final Pair pair : events) { + switch (pair.first) { + case EVENT_SOURCE_MOTION: + handleMotionEvent(pair.second); + break; + case EVENT_SOURCE_SCROLL: + handleScrollEvent(pair.second); + break; + case EVENT_SOURCE_MOUSE: + handleMouseEvent(pair.second); + break; + } + } + } + + /** + * Set whether Gecko should generate long-press events. + * + * @param isLongpressEnabled True if Gecko should generate long-press events. + */ + public void setIsLongpressEnabled(final boolean isLongpressEnabled) { + ThreadUtils.assertOnUiThread(); + + if (mAttached) { + mNative.nativeSetIsLongpressEnabled(isLongpressEnabled); + } + } + + private static class PointerInfo { + // We reserve one pointer ID for the mouse, so that tests don't have + // to worry about tracking pointer IDs if they just want to test mouse + // event synthesization. If somebody tries to use this ID for a + // synthesized touch event we'll throw an exception. + public static final int RESERVED_MOUSE_POINTER_ID = 100000; + + public int pointerId; + public int source; + public int surfaceX; + public int surfaceY; + public double pressure; + public int orientation; + public int buttonState; + + public MotionEvent.PointerCoords getCoords() { + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.orientation = orientation; + coords.pressure = (float) pressure; + coords.x = surfaceX; + coords.y = surfaceY; + return coords; + } + } + + private static class SynthesizedEventState { + public final ArrayList pointers; + public long downTime; + + SynthesizedEventState() { + pointers = new ArrayList(); + } + + int getPointerIndex(final int pointerId) { + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).pointerId == pointerId) { + return i; + } + } + return -1; + } + + int addPointer(final int pointerId, final int source) { + final PointerInfo info = new PointerInfo(); + info.pointerId = pointerId; + info.source = source; + pointers.add(info); + return pointers.size() - 1; + } + + int getPointerCount(final int source) { + int count = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + count++; + } + } + return count; + } + + int getPointerButtonState(final int source) { + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + return pointers.get(i).buttonState; + } + } + return 0; + } + + MotionEvent.PointerProperties[] getPointerProperties(final int source) { + final MotionEvent.PointerProperties[] props = + new MotionEvent.PointerProperties[getPointerCount(source)]; + int index = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + final MotionEvent.PointerProperties p = new MotionEvent.PointerProperties(); + p.id = pointers.get(i).pointerId; + switch (source) { + case InputDevice.SOURCE_TOUCHSCREEN: + p.toolType = MotionEvent.TOOL_TYPE_FINGER; + break; + case InputDevice.SOURCE_MOUSE: + p.toolType = MotionEvent.TOOL_TYPE_MOUSE; + break; + } + props[index++] = p; + } + } + return props; + } + + MotionEvent.PointerCoords[] getPointerCoords(final int source) { + final MotionEvent.PointerCoords[] coords = + new MotionEvent.PointerCoords[getPointerCount(source)]; + int index = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + coords[index++] = pointers.get(i).getCoords(); + } + } + return coords; + } + } + + private void synthesizeNativePointer( + final int source, + final int pointerId, + final int originalEventType, + final int clientX, + final int clientY, + final double pressure, + final int orientation, + final int button) { + if (mPointerState == null) { + mPointerState = new SynthesizedEventState(); + } + + // Find the pointer if it already exists + int pointerIndex = mPointerState.getPointerIndex(pointerId); + + // Event-specific handling + int eventType = originalEventType; + switch (originalEventType) { + case MotionEvent.ACTION_POINTER_UP: + if (pointerIndex < 0) { + Log.w(LOGTAG, "Pointer-up for invalid pointer"); + return; + } + if (mPointerState.pointers.size() == 1) { + // Last pointer is going up + eventType = MotionEvent.ACTION_UP; + } + break; + case MotionEvent.ACTION_CANCEL: + if (pointerIndex < 0) { + Log.w(LOGTAG, "Pointer-cancel for invalid pointer"); + return; + } + break; + case MotionEvent.ACTION_POINTER_DOWN: + if (pointerIndex < 0) { + // Adding a new pointer + pointerIndex = mPointerState.addPointer(pointerId, source); + if (pointerIndex == 0) { + // first pointer + eventType = MotionEvent.ACTION_DOWN; + mPointerState.downTime = SystemClock.uptimeMillis(); + } + } else { + // We're moving an existing pointer + eventType = MotionEvent.ACTION_MOVE; + } + break; + case MotionEvent.ACTION_HOVER_MOVE: + if (pointerIndex < 0) { + // Mouse-move a pointer without it going "down". However + // in order to send the right MotionEvent without a lot of + // duplicated code, we add the pointer to mPointerState, + // and then remove it at the bottom of this function. + pointerIndex = mPointerState.addPointer(pointerId, source); + } else { + // We're moving an existing mouse pointer that went down. + eventType = MotionEvent.ACTION_MOVE; + } + break; + } + + // Translate client origin to surface origin. + mSession.getSurfaceBounds(mTempRect); + final int surfaceX = clientX + mTempRect.left; + final int surfaceY = clientY + mTempRect.top; + + // Update the pointer with the new info + final PointerInfo info = mPointerState.pointers.get(pointerIndex); + info.surfaceX = surfaceX; + info.surfaceY = surfaceY; + info.pressure = pressure; + info.orientation = orientation; + if (source == InputDevice.SOURCE_MOUSE) { + if (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE) { + info.buttonState |= button; + } else if (eventType == MotionEvent.ACTION_UP) { + info.buttonState &= button; + } + } + + // Dispatch the event + int action = 0; + if (eventType == MotionEvent.ACTION_POINTER_DOWN + || eventType == MotionEvent.ACTION_POINTER_UP) { + // for pointer-down and pointer-up events we need to add the + // index of the relevant pointer. + action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + action &= MotionEvent.ACTION_POINTER_INDEX_MASK; + } + action |= (eventType & MotionEvent.ACTION_MASK); + final MotionEvent event = + MotionEvent.obtain( + /*downTime*/ mPointerState.downTime, + /*eventTime*/ SystemClock.uptimeMillis(), + /*action*/ action, + /*pointerCount*/ mPointerState.getPointerCount(source), + /*pointerProperties*/ mPointerState.getPointerProperties(source), + /*pointerCoords*/ mPointerState.getPointerCoords(source), + /*metaState*/ 0, + /*buttonState*/ mPointerState.getPointerButtonState(source), + /*xPrecision*/ 0, + /*yPrecision*/ 0, + /*deviceId*/ 0, + /*edgeFlags*/ 0, + /*source*/ source, + /*flags*/ 0); + + mSynthesizedEvent = true; + onTouchEvent(event); + mSynthesizedEvent = false; + + // Forget about removed pointers + if (eventType == MotionEvent.ACTION_POINTER_UP + || eventType == MotionEvent.ACTION_UP + || eventType == MotionEvent.ACTION_CANCEL + || eventType == MotionEvent.ACTION_HOVER_MOVE) { + mPointerState.pointers.remove(pointerIndex); + } + } + + /** + * Scroll the document body by an offset from the current scroll position. Uses {@link + * #SCROLL_BEHAVIOR_SMOOTH}. + * + * @param width {@link ScreenLength} offset to scroll along X axis. + * @param height {@link ScreenLength} offset to scroll along Y axis. + */ + @UiThread + public void scrollBy(final @NonNull ScreenLength width, final @NonNull ScreenLength height) { + scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH); + } + + /** + * Scroll the document body by an offset from the current scroll position. + * + * @param width {@link ScreenLength} offset to scroll along X axis. + * @param height {@link ScreenLength} offset to scroll along Y axis. + * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link + * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content. + */ + @UiThread + public void scrollBy( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = buildScrollMessage(width, height, behavior); + mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg); + } + + /** + * Scroll the document body to an absolute position. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. + * + * @param width {@link ScreenLength} position to scroll along X axis. + * @param height {@link ScreenLength} position to scroll along Y axis. + */ + @UiThread + public void scrollTo(final @NonNull ScreenLength width, final @NonNull ScreenLength height) { + scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH); + } + + /** + * Scroll the document body to an absolute position. + * + * @param width {@link ScreenLength} position to scroll along X axis. + * @param height {@link ScreenLength} position to scroll along Y axis. + * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link + * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content. + */ + @UiThread + public void scrollTo( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = buildScrollMessage(width, height, behavior); + mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg); + } + + /** Scroll to the top left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */ + @UiThread + public void scrollToTop() { + scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH); + } + + /** Scroll to the bottom left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */ + @UiThread + public void scrollToBottom() { + scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH); + } + + private GeckoBundle buildScrollMessage( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = new GeckoBundle(); + msg.putDouble("widthValue", width.getValue()); + msg.putInt("widthType", width.getType()); + msg.putDouble("heightValue", height.getValue()); + msg.putInt("heightType", height.getType()); + msg.putInt("behavior", behavior); + return msg; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java new file mode 100644 index 0000000000..7feb7d88ae --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java @@ -0,0 +1,19 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; + +class ParcelableUtils { + public static void writeBoolean(final Parcel out, final boolean val) { + out.writeByte((byte) (val ? 1 : 0)); + } + + public static boolean readBoolean(final Parcel source) { + return source.readByte() == 1; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java new file mode 100644 index 0000000000..9e655c5eb7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java @@ -0,0 +1,182 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.GeckoJavaSampler; + +/** + * ProfilerController is used to manage GeckoProfiler related features. + * + *

    If you want to add a profiler marker to mark a point in time (without a duration) you can + * directly use profilerController.addMarker("marker name"). Or if you want to provide + * more information, you can use + * profilerController.addMarker("marker name", "extra information") If you want to add a + * profiler marker with a duration (with start and end time) you can use it like this: + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * profilerController.addMarker("name", startTime); + * Or you can capture start and end time in somewhere, then add the marker in somewhere + * else: + * 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); + * Here's an addMarker example with all the possible parameters: + * 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"); + * isProfilerActive 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: + * + *

    + * 
    + *     Double startTime = profilerController.getProfilerTime();
    + *     ...some code you want to measure...
    + *     if (profilerController.isProfilerActive()) {
    + *         String info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive();
    + *         profilerController.addMarker("name", startTime, info);
    + *     }
    + * 
    + * 
    + * + * 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: + * + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * profilerController.addMarker("name", startTime); + * + * + * @return profiler time as double or null if the profiler is not active. + */ + public @Nullable Double getProfilerTime() { + return GeckoJavaSampler.tryToGetProfilerTime(); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. No-op if profiler is not + * active. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + GeckoJavaSampler.addMarker(aMarkerName, aStartTime, aEndTime, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final String aText) { + GeckoJavaSampler.addMarker(aMarkerName, aStartTime, null, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + */ + public void addMarker(@NonNull final String aMarkerName, @Nullable final Double aStartTime) { + addMarker(aMarkerName, aStartTime, null, null); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker(@NonNull final String aMarkerName, @Nullable final String aText) { + addMarker(aMarkerName, null, null, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + */ + public void addMarker(@NonNull final String aMarkerName) { + addMarker(aMarkerName, null, null, null); + } + + /** + * Start the Gecko profiler with the given settings. This is used by embedders which want to + * control the profiler from the embedding app. This allows them to provide an easier access point + * to profiling, as an alternative to the traditional way of using a desktop Firefox instance + * connected via USB + adb. + * + * @param aFilters The list of threads to profile, as an array of string of thread names filters. + * Each filter is used as a case-insensitive substring match against the actual thread names. + * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array. + */ + public void startProfiler( + @NonNull final String[] aFilters, @NonNull final String[] aFeaturesArr) { + GeckoJavaSampler.startProfiler(aFilters, aFeaturesArr); + } + + /** + * Stop the profiler and capture the recorded profile. This method is asynchronous. + * + * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer + * containing a gzip-compressed payload (with gzip header) of the profile JSON. + */ + public @NonNull GeckoResult stopProfiler() { + return GeckoJavaSampler.stopProfiler(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java new file mode 100644 index 0000000000..72a07c218e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java @@ -0,0 +1,646 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import java.util.HashMap; +import java.util.Map; +import org.json.JSONException; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.Autocomplete.AddressSaveOption; +import org.mozilla.geckoview.Autocomplete.AddressSelectOption; +import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption; +import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption; +import org.mozilla.geckoview.Autocomplete.LoginSaveOption; +import org.mozilla.geckoview.Autocomplete.LoginSelectOption; +import org.mozilla.geckoview.GeckoSession.PromptDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt.AuthOptions; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt.Observer; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ButtonPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ColorPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PopupPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.RepostConfirmPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.TextPrompt; + +/* package */ class PromptController { + private static final String LOGTAG = "Prompts"; + + private static class PromptStorage implements BasePrompt.Observer { + private final Map mPrompts = new HashMap<>(); + + public void addPrompt(final String id, final BasePrompt prompt) { + if (mPrompts.containsKey(id)) { + Log.e(LOGTAG, "Prompt already exists! id=" + id); + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Prompt already exists! id=" + id); + } + } + mPrompts.put(id, prompt); + } + + @Override + public void onPromptCompleted(final BasePrompt prompt) { + // No need to notify this delegate since the prompt has been completed already. + mPrompts.remove(prompt.id); + } + + public void dismiss(final String id) { + final BasePrompt prompt = mPrompts.get(id); + if (prompt == null) { + return; + } + final PromptInstanceDelegate delegate = prompt.getDelegate(); + if (delegate != null) { + delegate.onPromptDismiss(prompt); + } + mPrompts.remove(prompt.id); + } + + public boolean contains(final String id) { + return mPrompts.containsKey(id); + } + + public void update(final BasePrompt prompt) { + final BasePrompt previousPrompt = mPrompts.get(prompt.id); + if (previousPrompt == null) { + return; + } + final PromptInstanceDelegate delegate = previousPrompt.getDelegate(); + if (delegate == null) { + return; + } + prompt.setDelegate(delegate); + delegate.onPromptUpdate(prompt); + mPrompts.put(prompt.id, prompt); + } + } + + final PromptStorage mStorage = new PromptStorage(); + + public void dismissPrompt(final String id) { + mStorage.dismiss(id); + } + + public void updatePrompt(final GeckoBundle message) { + final String type = message.getString("type"); + final PromptHandler handler = sPromptHandlers.handlerFor(type); + if (handler == null) { + // Invalid prompt message type to update the prompt. + return; + } + final BasePrompt prompt = handler.newPrompt(message, mStorage); + if (prompt == null) { + // Invalid prompt message to update the prompt. + return; + } + if (!mStorage.contains(prompt.id)) { + // Invalid prompt id to update the prompt. Dismissed? + return; + } + + mStorage.update(prompt); + } + + public void handleEvent( + final GeckoSession session, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleEvent " + message.getString("type")); + final PromptDelegate delegate = session.getPromptDelegate(); + if (delegate == null) { + // Default behavior is same as calling dismiss() on callback. + callback.sendSuccess(null); + return; + } + + final String type = message.getString("type"); + final PromptHandler handler = sPromptHandlers.handlerFor(type); + if (handler == null) { + callback.sendError("Invalid type: " + type); + return; + } + final GeckoResult res = getResponse(message, session, delegate, handler); + + if (res == null) { + // Adhere to default behavior if the delegate returns null. + callback.sendSuccess(null); + } else { + res.accept( + value -> value.dispatch(callback), + exception -> callback.sendError("Failed to get prompt response.")); + } + } + + private GeckoResult getResponse( + final GeckoBundle message, + final GeckoSession session, + final PromptDelegate delegate, + final PromptHandler handler) { + final PromptType prompt = handler.newPrompt(message, mStorage); + if (prompt == null) { + try { + Log.e(LOGTAG, "Invalid prompt: " + message.toJSONObject().toString()); + } catch (final JSONException ex) { + Log.e(LOGTAG, "Invalid prompt, invalid data", ex); + } + + return GeckoResult.fromException(new IllegalArgumentException("Invalid prompt data.")); + } + + mStorage.addPrompt(prompt.id, prompt); + return handler.callDelegate(prompt, session, delegate); + } + + private interface PromptHandler { + PromptType newPrompt(GeckoBundle info, Observer observer); + + GeckoResult callDelegate( + PromptType prompt, GeckoSession session, PromptDelegate delegate); + } + + private static final class AlertHandler implements PromptHandler { + @Override + public AlertPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new AlertPrompt( + info.getString("id"), info.getString("title"), info.getString("msg"), observer); + } + + @Override + public GeckoResult callDelegate( + final AlertPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onAlertPrompt(session, prompt); + } + } + + private static final class BeforeUnloadHandler implements PromptHandler { + @Override + public BeforeUnloadPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new BeforeUnloadPrompt(info.getString("id"), observer); + } + + @Override + public GeckoResult callDelegate( + final BeforeUnloadPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onBeforeUnloadPrompt(session, prompt); + } + } + + private static final class ButtonHandler implements PromptHandler { + @Override + public ButtonPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new ButtonPrompt( + info.getString("id"), info.getString("title"), info.getString("msg"), observer); + } + + @Override + public GeckoResult callDelegate( + final ButtonPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onButtonPrompt(session, prompt); + } + } + + private static final class TextHandler implements PromptHandler { + @Override + public TextPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new TextPrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + info.getString("value"), + observer); + } + + @Override + public GeckoResult callDelegate( + final TextPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onTextPrompt(session, prompt); + } + } + + private static final class AuthHandler implements PromptHandler { + @Override + public AuthPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new AuthPrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + new AuthOptions(info.getBundle("options")), + observer); + } + + @Override + public GeckoResult callDelegate( + final AuthPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onAuthPrompt(session, prompt); + } + } + + private static final class ChoiceHandler implements PromptHandler { + @Override + public ChoicePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final int intMode; + final String mode = info.getString("mode"); + if ("menu".equals(mode)) { + intMode = ChoicePrompt.Type.MENU; + } else if ("single".equals(mode)) { + intMode = ChoicePrompt.Type.SINGLE; + } else if ("multiple".equals(mode)) { + intMode = ChoicePrompt.Type.MULTIPLE; + } else { + return null; + } + + final GeckoBundle[] choiceBundles = info.getBundleArray("choices"); + final ChoicePrompt.Choice[] choices; + if (choiceBundles == null || choiceBundles.length == 0) { + choices = new ChoicePrompt.Choice[0]; + } else { + choices = new ChoicePrompt.Choice[choiceBundles.length]; + for (int i = 0; i < choiceBundles.length; i++) { + choices[i] = new ChoicePrompt.Choice(choiceBundles[i]); + } + } + + return new ChoicePrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + intMode, + choices, + observer); + } + + @Override + public GeckoResult callDelegate( + final ChoicePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onChoicePrompt(session, prompt); + } + } + + private static final class ColorHandler implements PromptHandler { + @Override + public ColorPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new ColorPrompt( + info.getString("id"), + info.getString("title"), + info.getString("value"), + info.getStringArray("predefinedValues"), + observer); + } + + @Override + public GeckoResult callDelegate( + final ColorPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onColorPrompt(session, prompt); + } + } + + private static final class DateTimeHandler implements PromptHandler { + @Override + public DateTimePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final String mode = info.getString("mode"); + final int intMode; + if ("date".equals(mode)) { + intMode = DateTimePrompt.Type.DATE; + } else if ("month".equals(mode)) { + intMode = DateTimePrompt.Type.MONTH; + } else if ("week".equals(mode)) { + intMode = DateTimePrompt.Type.WEEK; + } else if ("time".equals(mode)) { + intMode = DateTimePrompt.Type.TIME; + } else if ("datetime-local".equals(mode)) { + intMode = DateTimePrompt.Type.DATETIME_LOCAL; + } else { + return null; + } + + final String defaultValue = info.getString("value"); + final String minValue = info.getString("min"); + final String maxValue = info.getString("max"); + final String stepValue = info.getString("step"); + return new DateTimePrompt( + info.getString("id"), + info.getString("title"), + intMode, + defaultValue, + minValue, + maxValue, + stepValue, + observer); + } + + @Override + public GeckoResult callDelegate( + final DateTimePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onDateTimePrompt(session, prompt); + } + } + + private static final class FileHandler implements PromptHandler { + @Override + public FilePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final String mode = info.getString("mode"); + final int intMode; + if ("single".equals(mode)) { + intMode = FilePrompt.Type.SINGLE; + } else if ("multiple".equals(mode)) { + intMode = FilePrompt.Type.MULTIPLE; + } else { + return null; + } + + final String[] mimeTypes = info.getStringArray("mimeTypes"); + final int capture = info.getInt("capture"); + return new FilePrompt( + info.getString("id"), info.getString("title"), intMode, capture, mimeTypes, observer); + } + + @Override + public GeckoResult callDelegate( + final FilePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onFilePrompt(session, prompt); + } + } + + private static final class PopupHandler implements PromptHandler { + @Override + public PopupPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new PopupPrompt(info.getString("id"), info.getString("targetUri"), observer); + } + + @Override + public GeckoResult callDelegate( + final PopupPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onPopupPrompt(session, prompt); + } + } + + private static final class RepostHandler implements PromptHandler { + @Override + public RepostConfirmPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new RepostConfirmPrompt(info.getString("id"), observer); + } + + @Override + public GeckoResult callDelegate( + final RepostConfirmPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onRepostConfirmPrompt(session, prompt); + } + } + + private static final class ShareHandler implements PromptHandler { + @Override + public SharePrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new SharePrompt( + info.getString("id"), + info.getString("title"), + info.getString("text"), + info.getString("uri"), + observer); + } + + @Override + public GeckoResult callDelegate( + final SharePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onSharePrompt(session, prompt); + } + } + + private static final class LoginSaveHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final int hint = info.getInt("hint"); + final GeckoBundle[] loginBundles = info.getBundleArray("logins"); + + if (loginBundles == null) { + return null; + } + + final Autocomplete.LoginSaveOption[] options = + new Autocomplete.LoginSaveOption[loginBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.LoginSaveOption(new Autocomplete.LoginEntry(loginBundles[i]), hint); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onLoginSave(session, prompt); + } + } + + private static final class CreditCardSaveHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final int hint = info.getInt("hint"); + final GeckoBundle[] creditCardBundles = info.getBundleArray("creditCards"); + + if (creditCardBundles == null) { + return null; + } + + final Autocomplete.CreditCardSaveOption[] options = + new Autocomplete.CreditCardSaveOption[creditCardBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.CreditCardSaveOption( + new Autocomplete.CreditCard(creditCardBundles[i]), hint); + } + + return new PromptDelegate.AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onCreditCardSave(session, prompt); + } + } + + private static final class AddressSaveHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] addressBundles = info.getBundleArray("addresses"); + + if (addressBundles == null) { + return null; + } + + final Autocomplete.AddressSaveOption[] options = + new Autocomplete.AddressSaveOption[addressBundles.length]; + + final int hint = info.getInt("hint"); + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.AddressSaveOption(new Autocomplete.Address(addressBundles[i]), hint); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onAddressSave(session, prompt); + } + } + + private static final class LoginSelectHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.LoginSelectOption[] options = + new Autocomplete.LoginSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.LoginSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onLoginSelect(session, prompt); + } + } + + private static final class CreditCardSelectHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.CreditCardSelectOption[] options = + new Autocomplete.CreditCardSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.CreditCardSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onCreditCardSelect(session, prompt); + } + } + + private static final class AddressSelectHandler + implements PromptHandler> { + @Override + public AutocompleteRequest newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.AddressSelectOption[] options = + new Autocomplete.AddressSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.AddressSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult callDelegate( + final AutocompleteRequest prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onAddressSelect(session, prompt); + } + } + + private static class PromptHandlers { + final Map> mPromptHandlers = new HashMap<>(); + + public void register(final PromptHandler handler, final String type) { + mPromptHandlers.put(type, handler); + } + + public PromptHandler handlerFor(final String type) { + return mPromptHandlers.get(type); + } + } + + private static final PromptHandlers sPromptHandlers = new PromptHandlers(); + + static { + sPromptHandlers.register(new AlertHandler(), "alert"); + sPromptHandlers.register(new BeforeUnloadHandler(), "beforeUnload"); + sPromptHandlers.register(new ButtonHandler(), "button"); + sPromptHandlers.register(new TextHandler(), "text"); + sPromptHandlers.register(new AuthHandler(), "auth"); + sPromptHandlers.register(new ChoiceHandler(), "choice"); + sPromptHandlers.register(new ColorHandler(), "color"); + sPromptHandlers.register(new DateTimeHandler(), "datetime"); + sPromptHandlers.register(new FileHandler(), "file"); + sPromptHandlers.register(new PopupHandler(), "popup"); + sPromptHandlers.register(new RepostHandler(), "repost"); + sPromptHandlers.register(new ShareHandler(), "share"); + sPromptHandlers.register(new LoginSaveHandler(), "Autocomplete:Save:Login"); + sPromptHandlers.register(new CreditCardSaveHandler(), "Autocomplete:Save:CreditCard"); + sPromptHandlers.register(new AddressSaveHandler(), "Autocomplete:Save:Address"); + sPromptHandlers.register(new LoginSelectHandler(), "Autocomplete:Select:Login"); + sPromptHandlers.register(new CreditCardSelectHandler(), "Autocomplete:Select:CreditCard"); + sPromptHandlers.register(new AddressSelectHandler(), "Autocomplete:Select:Address"); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java new file mode 100644 index 0000000000..299dec95f1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java @@ -0,0 +1,266 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * Base class for (nested) runtime settings. + * + *

    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 { + 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 { + 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 mChildren; + private final ArrayList> 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>(); + mChildren = new ArrayList(); + + 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 uncheckedPref = (Pref) 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 getPrefsMap() { + final ArrayMap 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> 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 names = new ArrayList(); + 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 uncheckedPref = (Pref) pref; + uncheckedPref.commit(source.readValue(getClass().getClassLoader())); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java new file mode 100644 index 0000000000..1fad0cb17e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java @@ -0,0 +1,171 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** The telemetry API gives access to telemetry data of the Gecko runtime. */ +public final class RuntimeTelemetry { + protected RuntimeTelemetry() {} + + /** + * The runtime telemetry metric object. + * + * @param type of the underlying metric sample + */ + public static class Metric { + /** 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 { + /** 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 metric) {} + + /** + * A runtime telemetry long scalar has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onLongScalar(final @NonNull Metric metric) {} + + /** + * A runtime telemetry string scalar has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onStringScalar(final @NonNull Metric metric) {} + } + + // The proxy connects to telemetry core and forwards telemetry events + // to the attached delegate. + /* package */ static final class Proxy extends JNIObject { + private final Delegate mDelegate; + + public Proxy(final @NonNull Delegate delegate) { + mDelegate = delegate; + } + + // Attach to current runtime. + // We might have different mechanics of attaching to specific runtimes + // in future, for which case we should split the delegate assignment in + // the setup phase from the attaching. + public void attach() { + if (GeckoThread.isRunning()) { + registerDelegateProxy(this); + } else { + GeckoThread.queueNativeCall(Proxy.class, "registerDelegateProxy", Proxy.class, this); + } + } + + public @NonNull Delegate getDelegate() { + return mDelegate; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void registerDelegateProxy(Proxy proxy); + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchHistogram( + final boolean isCategorical, final String name, final long[] values) { + if (mDelegate == null) { + // TODO throw? + return; + } + mDelegate.onHistogram(new Histogram(isCategorical, name, values)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchStringScalar(final String name, final String value) { + if (mDelegate == null) { + return; + } + mDelegate.onStringScalar(new Metric<>(name, value)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchBooleanScalar(final String name, final boolean value) { + if (mDelegate == null) { + return; + } + mDelegate.onBooleanScalar(new Metric<>(name, value)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchLongScalar(final String name, final long value) { + if (mDelegate == null) { + return; + } + mDelegate.onLongScalar(new Metric<>(name, value)); + } + + @Override // JNIObject + protected void disposeNative() { + // We don't hold native references. + throw new UnsupportedOperationException(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java new file mode 100644 index 0000000000..1ce4b41659 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java @@ -0,0 +1,164 @@ +/* License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * ScreenLength is a class that represents a length on the screen using different units. The default + * unit is a pixel. However lengths may be also represented by a dimension of the visual viewport or + * of the full scroll size of the root document. + */ +public class ScreenLength { + @Retention(RetentionPolicy.SOURCE) + @IntDef({PIXEL, VISUAL_VIEWPORT_WIDTH, VISUAL_VIEWPORT_HEIGHT, DOCUMENT_WIDTH, DOCUMENT_HEIGHT}) + public @interface ScreenLengthType {} + + /** Pixel units. */ + public static final int PIXEL = 0; + + /** + * Units are in visual viewport width. If the visual viewport is 100 pixels wide, then a value of + * 2.0 would represent a length of 200 pixels. + * + * @see MDN Visual + * Viewport + */ + 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 MDN Visual + * Viewport + */ + 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..e8a50d71b6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java @@ -0,0 +1,936 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.os.Build; +import android.os.Bundle; +import android.text.InputType; +import android.text.TextUtils; +import android.util.Log; +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeInfo.CollectionInfo; +import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo; +import android.view.accessibility.AccessibilityNodeInfo.RangeInfo; +import android.view.accessibility.AccessibilityNodeProvider; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class SessionAccessibility { + private static final String LOGTAG = "GeckoAccessibility"; + + // This is the number BrailleBack uses to start indexing routing keys. + private static final int BRAILLE_CLICK_BASE_INDEX = -275000000; + private static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE = + "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE"; + + @WrapForJNI static final int FLAG_ACCESSIBILITY_FOCUSED = 0; + @WrapForJNI static final int FLAG_CHECKABLE = 1 << 1; + @WrapForJNI static final int FLAG_CHECKED = 1 << 2; + @WrapForJNI static final int FLAG_CLICKABLE = 1 << 3; + @WrapForJNI static final int FLAG_CONTENT_INVALID = 1 << 4; + @WrapForJNI static final int FLAG_CONTEXT_CLICKABLE = 1 << 5; + @WrapForJNI static final int FLAG_EDITABLE = 1 << 6; + @WrapForJNI static final int FLAG_ENABLED = 1 << 7; + @WrapForJNI static final int FLAG_FOCUSABLE = 1 << 8; + @WrapForJNI static final int FLAG_FOCUSED = 1 << 9; + @WrapForJNI static final int FLAG_LONG_CLICKABLE = 1 << 10; + @WrapForJNI static final int FLAG_MULTI_LINE = 1 << 11; + @WrapForJNI static final int FLAG_PASSWORD = 1 << 12; + @WrapForJNI static final int FLAG_SCROLLABLE = 1 << 13; + @WrapForJNI static final int FLAG_SELECTED = 1 << 14; + @WrapForJNI static final int FLAG_VISIBLE_TO_USER = 1 << 15; + @WrapForJNI static final int FLAG_SELECTABLE = 1 << 16; + @WrapForJNI static final int FLAG_EXPANDABLE = 1 << 17; + @WrapForJNI static final int FLAG_EXPANDED = 1 << 18; + + static final int CLASSNAME_UNKNOWN = -1; + @WrapForJNI static final int CLASSNAME_VIEW = 0; + @WrapForJNI static final int CLASSNAME_BUTTON = 1; + @WrapForJNI static final int CLASSNAME_CHECKBOX = 2; + @WrapForJNI static final int CLASSNAME_DIALOG = 3; + @WrapForJNI static final int CLASSNAME_EDITTEXT = 4; + @WrapForJNI static final int CLASSNAME_GRIDVIEW = 5; + @WrapForJNI static final int CLASSNAME_IMAGE = 6; + @WrapForJNI static final int CLASSNAME_LISTVIEW = 7; + @WrapForJNI static final int CLASSNAME_MENUITEM = 8; + @WrapForJNI static final int CLASSNAME_PROGRESSBAR = 9; + @WrapForJNI static final int CLASSNAME_RADIOBUTTON = 10; + @WrapForJNI static final int CLASSNAME_SEEKBAR = 11; + @WrapForJNI static final int CLASSNAME_SPINNER = 12; + @WrapForJNI static final int CLASSNAME_TABWIDGET = 13; + @WrapForJNI static final int CLASSNAME_TOGGLEBUTTON = 14; + @WrapForJNI static final int CLASSNAME_WEBVIEW = 15; + + private static final String[] CLASSNAMES = { + "android.view.View", + "android.widget.Button", + "android.widget.CheckBox", + "android.app.Dialog", + "android.widget.EditText", + "android.widget.GridView", + "android.widget.Image", + "android.widget.ListView", + "android.view.MenuItem", + "android.widget.ProgressBar", + "android.widget.RadioButton", + "android.widget.SeekBar", + "android.widget.Spinner", + "android.widget.TabWidget", + "android.widget.ToggleButton", + "android.webkit.WebView" + }; + + @WrapForJNI static final int HTML_GRANULARITY_DEFAULT = -1; + @WrapForJNI static final int HTML_GRANULARITY_ARTICLE = 0; + @WrapForJNI static final int HTML_GRANULARITY_BUTTON = 1; + @WrapForJNI static final int HTML_GRANULARITY_CHECKBOX = 2; + @WrapForJNI static final int HTML_GRANULARITY_COMBOBOX = 3; + @WrapForJNI static final int HTML_GRANULARITY_CONTROL = 4; + @WrapForJNI static final int HTML_GRANULARITY_FOCUSABLE = 5; + @WrapForJNI static final int HTML_GRANULARITY_FRAME = 6; + @WrapForJNI static final int HTML_GRANULARITY_GRAPHIC = 7; + @WrapForJNI static final int HTML_GRANULARITY_H1 = 8; + @WrapForJNI static final int HTML_GRANULARITY_H2 = 9; + @WrapForJNI static final int HTML_GRANULARITY_H3 = 10; + @WrapForJNI static final int HTML_GRANULARITY_H4 = 11; + @WrapForJNI static final int HTML_GRANULARITY_H5 = 12; + @WrapForJNI static final int HTML_GRANULARITY_H6 = 13; + @WrapForJNI static final int HTML_GRANULARITY_HEADING = 14; + @WrapForJNI static final int HTML_GRANULARITY_LANDMARK = 15; + @WrapForJNI static final int HTML_GRANULARITY_LINK = 16; + @WrapForJNI static final int HTML_GRANULARITY_LIST = 17; + @WrapForJNI static final int HTML_GRANULARITY_LIST_ITEM = 18; + @WrapForJNI static final int HTML_GRANULARITY_MAIN = 19; + @WrapForJNI static final int HTML_GRANULARITY_MEDIA = 20; + @WrapForJNI static final int HTML_GRANULARITY_RADIO = 21; + @WrapForJNI static final int HTML_GRANULARITY_SECTION = 22; + @WrapForJNI static final int HTML_GRANULARITY_TABLE = 23; + @WrapForJNI static final int HTML_GRANULARITY_TEXT_FIELD = 24; + @WrapForJNI static final int HTML_GRANULARITY_UNVISITED_LINK = 25; + @WrapForJNI static final int HTML_GRANULARITY_VISITED_LINK = 26; + + private static String[] sHtmlGranularities = { + "ARTICLE", + "BUTTON", + "CHECKBOX", + "COMBOBOX", + "CONTROL", + "FOCUSABLE", + "FRAME", + "GRAPHIC", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADING", + "LANDMARK", + "LINK", + "LIST", + "LIST_ITEM", + "MAIN", + "MEDIA", + "RADIO", + "SECTION", + "TABLE", + "TEXT_FIELD", + "UNVISITED_LINK", + "VISITED_LINK" + }; + + private static String getClassName(final int index) { + if (index >= 0 && index < CLASSNAMES.length) { + return CLASSNAMES[index]; + } + + Log.e(LOGTAG, "Index " + index + " our of CLASSNAME bounds."); + return "android.view.View"; // Fallback class is View + } + + /* package */ final class NodeProvider extends AccessibilityNodeProvider { + @Override + public AccessibilityNodeInfo createAccessibilityNodeInfo(final int virtualDescendantId) { + AccessibilityNodeInfo node = null; + if (mAttached) { + node = getNodeFromGecko(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); + return true; + case AccessibilityNodeInfo.ACTION_LONG_CLICK: + // XXX: Implement long press. + return true; + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + if (virtualViewId == View.NO_ID) { + // Scroll the viewport forwards by approximately 80%. + mSession + .getPanZoomController() + .scrollBy( + ScreenLength.zero(), + ScreenLength.fromVisualViewportHeight(0.8), + PanZoomController.SCROLL_BEHAVIOR_AUTO); + } else { + // XXX: It looks like we never call scroll on virtual views. + // If we did, we should synthesize a wheel event on it's center coordinate. + } + return true; + case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: + if (virtualViewId == View.NO_ID) { + // Scroll the viewport backwards by approximately 80%. + mSession + .getPanZoomController() + .scrollBy( + ScreenLength.zero(), + ScreenLength.fromVisualViewportHeight(-0.8), + PanZoomController.SCROLL_BEHAVIOR_AUTO); + } else { + // XXX: It looks like we never call scroll on virtual views. + // If we did, we should synthesize a wheel event on it's center coordinate. + } + return true; + case AccessibilityNodeInfo.ACTION_SELECT: + nativeProvider.click(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: + requestViewFocus(); + return pivot( + virtualViewId, + arguments != null + ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING) + : "", + true, + false); + case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: + requestViewFocus(); + return pivot( + virtualViewId, + arguments != null + ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING) + : "", + false, + false); + case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: + case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: + // XXX: Self brailling gives this action with a bogus argument instead of an actual click + // action; + // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that + // was hit. + // Other negative values are used by ChromeVox, but we don't support them. + // FAKE_GRANULARITY_READ_CURRENT = -1 + // FAKE_GRANULARITY_READ_TITLE = -2 + // FAKE_GRANULARITY_STOP_SPEECH = -3 + // FAKE_GRANULARITY_CHANGE_SHIFTER = -4 + if (arguments == null) { + return false; + } + final int granularity = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + if (granularity <= BRAILLE_CLICK_BASE_INDEX) { + // XXX: Use click offset to update caret position in editables (BRAILLE_CLICK_BASE_INDEX + // - granularity). + nativeProvider.click(virtualViewId); + } else if (granularity > 0) { + final boolean extendSelection = + arguments.getBoolean( + AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); + final boolean next = + action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY; + // We must return false if we're already at the edge. + if (next) { + if (mAtEndOfText) { + return false; + } + if (granularity == AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD && mAtLastWord) { + return false; + } + } else if (mAtStartOfText) { + return false; + } + nativeProvider.navigateText( + virtualViewId, granularity, mStartOffset, mEndOffset, next, extendSelection); + } + return true; + case AccessibilityNodeInfo.ACTION_SET_SELECTION: + if (arguments == null) { + return false; + } + final int selectionStart = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT); + final int selectionEnd = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT); + nativeProvider.setSelection(virtualViewId, selectionStart, selectionEnd); + return true; + case AccessibilityNodeInfo.ACTION_CUT: + nativeProvider.cut(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_COPY: + nativeProvider.copy(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_PASTE: + nativeProvider.paste(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_SET_TEXT: + if (arguments == null) { + return false; + } + final String value = + arguments.getString( + Build.VERSION.SDK_INT >= 21 + ? AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE + : ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE); + if (mAttached) { + nativeProvider.setText(virtualViewId, value); + } + return true; + } + + return mView.performAccessibilityAction(action, arguments); + } + + @Override + public AccessibilityNodeInfo findFocus(final int focus) { + switch (focus) { + case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: + if (mAccessibilityFocusedNode != 0) { + return createAccessibilityNodeInfo(mAccessibilityFocusedNode); + } + break; + case AccessibilityNodeInfo.FOCUS_INPUT: + if (mFocusedNode != 0) { + return createAccessibilityNodeInfo(mFocusedNode); + } + break; + } + + return super.findFocus(focus); + } + + private AccessibilityNodeInfo getNodeFromGecko(final int virtualViewId) { + ThreadUtils.assertOnUiThread(); + final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId); + nativeProvider.getNodeInfo(virtualViewId, node); + + // We set the bounds in parent here because we need to use the client-to-screen matrix + // and it is only available in the UI thread. + final Rect bounds = new Rect(); + node.getBoundsInParent(bounds); + + final Matrix matrix = new Matrix(); + mSession.getClientToScreenMatrix(matrix); + final float[] origin = new float[2]; + matrix.mapPoints(origin); + bounds.offset((int) origin[0], (int) origin[1]); + node.setBoundsInScreen(bounds); + + return node; + } + } + + // 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 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; + private boolean mViewFocusRequested = false; + + /* package */ SessionAccessibility(final GeckoSession session) { + mSession = session; + Settings.updateAccessibilitySettings(); + } + + /* package */ static void setForceEnabled(final boolean forceEnabled) { + Settings.setForceEnabled(forceEnabled); + } + + /** + * Get the View instance that delegates accessibility to this session. + * + * @return View instance. + */ + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + + return mView; + } + + /** + * Set the View instance that should delegate accessibility to this session. + * + * @param view View instance. + */ + @UiThread + public void setView(final @Nullable View view) { + ThreadUtils.assertOnUiThread(); + + if (mView != null) { + mView.setAccessibilityDelegate(null); + } + + mView = view; + + if (mView == null) { + return; + } + + mView.setAccessibilityDelegate( + new View.AccessibilityDelegate() { + private NodeProvider mProvider; + + @Override + public AccessibilityNodeProvider getAccessibilityNodeProvider(final View hostView) { + if (hostView != mView) { + return null; + } + if (mProvider == null) { + mProvider = new NodeProvider(); + } + return mProvider; + } + + @Override + public void sendAccessibilityEvent(final View host, final int eventType) { + if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) { + // We rely on the focus events sent from Gecko. + return; + } + + super.sendAccessibilityEvent(host, eventType); + } + }); + } + + private boolean isInTest() { + return Build.VERSION.SDK_INT >= 17 && mView != null && mView.getDisplay() == null; + } + + private void requestViewFocus() { + if (!mView.isFocused() && !isInTest()) { + mViewFocusRequested = true; + mView.requestFocus(); + } + } + + private static class Settings { + private static volatile boolean sEnabled; + private static volatile boolean sTouchExplorationEnabled; + private static volatile boolean sForceEnabled; + + public static void setForceEnabled(final boolean forceEnabled) { + sForceEnabled = forceEnabled; + dispatch(); + } + + static { + final Context context = GeckoAppShell.getApplicationContext(); + final AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + + accessibilityManager.addAccessibilityStateChangeListener( + enabled -> updateAccessibilitySettings()); + + if (Build.VERSION.SDK_INT >= 19) { + accessibilityManager.addTouchExplorationStateChangeListener( + enabled -> updateAccessibilitySettings()); + } + } + + public static boolean isEnabled() { + return sEnabled || sForceEnabled; + } + + public static boolean isTouchExplorationEnabled() { + return sTouchExplorationEnabled || sForceEnabled; + } + + public static void updateAccessibilitySettings() { + final AccessibilityManager accessibilityManager = + (AccessibilityManager) + GeckoAppShell.getApplicationContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + sEnabled = accessibilityManager.isEnabled(); + sTouchExplorationEnabled = sEnabled && accessibilityManager.isTouchExplorationEnabled(); + dispatch(); + } + + /* package */ static void dispatch() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + toggleNativeAccessibility(isEnabled()); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + Settings.class, + "toggleNativeAccessibility", + isEnabled()); + } + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void toggleNativeAccessibility(boolean enable); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public boolean onMotionEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!Settings.isTouchExplorationEnabled()) { + return false; + } + + if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) { + return false; + } + + final int action = event.getActionMasked(); + if ((action != MotionEvent.ACTION_HOVER_MOVE) + && (action != MotionEvent.ACTION_HOVER_ENTER) + && (action != MotionEvent.ACTION_HOVER_EXIT)) { + return false; + } + + requestViewFocus(); + + nativeProvider.exploreByTouch( + mAccessibilityFocusedNode != 0 ? mAccessibilityFocusedNode : View.NO_ID, + event.getX(), + event.getY()); + + return true; + } + + /* package */ void sendEvent( + final int eventType, final int sourceId, final int className, final GeckoBundle eventData) { + ThreadUtils.assertOnUiThread(); + if (mView == null || !mAttached) { + return; + } + + if (mViewFocusRequested && className == CLASSNAME_WEBVIEW) { + // If the view was focused from an accessiblity action or + // explore-by-touch, we supress this focus event to avoid noise. + mViewFocusRequested = false; + return; + } + + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + event.setSource(mView, sourceId); + event.setEnabled(true); + + int eventClassName = className; + if (eventClassName == CLASSNAME_UNKNOWN) { + eventClassName = nativeProvider.getNodeClassName(sourceId); + } + event.setClassName(getClassName(eventClassName)); + + if (eventData != null) { + if (eventData.containsKey("text")) { + event.getText().add(eventData.getString("text")); + } + event.setContentDescription(eventData.getString("description", "")); + event.setAddedCount(eventData.getInt("addedCount", -1)); + event.setRemovedCount(eventData.getInt("removedCount", -1)); + event.setFromIndex(eventData.getInt("fromIndex", -1)); + event.setItemCount(eventData.getInt("itemCount", -1)); + event.setCurrentItemIndex(eventData.getInt("currentItemIndex", -1)); + event.setBeforeText(eventData.getString("beforeText", "")); + event.setToIndex(eventData.getInt("toIndex", -1)); + event.setScrollX(eventData.getInt("scrollX", -1)); + event.setScrollY(eventData.getInt("scrollY", -1)); + event.setMaxScrollX(eventData.getInt("maxScrollX", -1)); + event.setMaxScrollY(eventData.getInt("maxScrollY", -1)); + event.setChecked((eventData.getInt("flags") & FLAG_CHECKED) != 0); + } + + // Update stored state from this event. + switch (eventType) { + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: + if (mAccessibilityFocusedNode == sourceId) { + mAccessibilityFocusedNode = 0; + } + break; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + mStartOffset = -1; + mEndOffset = -1; + mAtStartOfText = false; + mAtEndOfText = false; + mAtLastWord = false; + mAccessibilityFocusedNode = sourceId; + break; + case AccessibilityEvent.TYPE_VIEW_FOCUSED: + mFocusedNode = sourceId; + if (!mView.isFocused() && !isInTest()) { + // Don't dispatch a focus event if the parent view is not focused + return; + } + break; + case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY: + mStartOffset = event.getFromIndex(); + mEndOffset = event.getToIndex(); + // We must synchronously return false for text navigation + // actions if the user attempts to navigate past the edge. + // Because we do navigation async, we can't query this + // on demand when the action is performed. Therefore, we cache + // whether we're at either edge here. + mAtStartOfText = mStartOffset == 0; + final CharSequence text = event.getText().get(0); + mAtEndOfText = mEndOffset >= text.length(); + mAtLastWord = mAtEndOfText; + if (!mAtLastWord) { + // Words exclude trailing spaces. To figure out whether + // we're at the last word, we need to get the text after + // our end offset and check if it's just spaces. + final CharSequence afterText = text.subSequence(mEndOffset, text.length()); + if (TextUtils.getTrimmedLength(afterText) == 0) { + mAtLastWord = true; + } + } + break; + } + + try { + ((ViewParent) mView).requestSendAccessibilityEvent(mView, event); + } catch (final IllegalStateException ex) { + // Accessibility could be activated in Gecko via xpcom, for example when using a11y + // devtools. Events that are forwarded to the platform will throw an exception. + } + } + + private boolean pivot( + final int id, final String granularity, final boolean forward, final boolean inclusive) { + if (!forward && id == View.NO_ID) { + // If attempting to pivot backwards from the root view, return false. + return false; + } + + final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity); + final boolean success = nativeProvider.pivotNative(id, gran, forward, inclusive); + if (!success && !forward) { + // If we failed to pivot backwards set the root view as the a11y focus. + sendEvent( + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, View.NO_ID, CLASSNAME_WEBVIEW, null); + return true; + } + + return success; + } + + /* 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 void getNodeInfo(int id, AccessibilityNodeInfo nodeInfo); + + @WrapForJNI(dispatchTo = "current") + public native int getNodeClassName(int id); + + @WrapForJNI(dispatchTo = "gecko") + public native void setText(int id, String text); + + @WrapForJNI(dispatchTo = "gecko") + public native void click(int id); + + @WrapForJNI(dispatchTo = "current", stubName = "Pivot") + public native boolean 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 + private void populateNodeInfo( + final AccessibilityNodeInfo node, + final int id, + final int parentId, + final int[] children, + final int flags, + final int className, + final int[] bounds, + @Nullable final String text, + @Nullable final String description, + @Nullable final String hint, + @Nullable final String geckoRole, + @Nullable final String roleDescription, + @Nullable final String viewIdResourceName, + final int inputType) { + if (mView == null) { + return; + } + + final boolean isRoot = id == View.NO_ID; + if (isRoot) { + if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) { + // When running junit tests we don't have a display + mView.onInitializeAccessibilityNodeInfo(node); + } + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + } else { + node.setParent(mView, parentId); + } + + // The basics + node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + node.setClassName(getClassName(className)); + + if (text != null) { + node.setText(text); + } + + if (description != null) { + node.setContentDescription(description); + } + + // Add actions + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); + node.setMovementGranularities( + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); + if ((flags & FLAG_CLICKABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_CLICK); + } + + // Set boolean properties + node.setCheckable((flags & FLAG_CHECKABLE) != 0); + node.setChecked((flags & FLAG_CHECKED) != 0); + node.setClickable((flags & FLAG_CLICKABLE) != 0); + node.setEnabled((flags & FLAG_ENABLED) != 0); + node.setFocusable((flags & FLAG_FOCUSABLE) != 0); + node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0); + node.setPassword((flags & FLAG_PASSWORD) != 0); + node.setScrollable((flags & FLAG_SCROLLABLE) != 0); + node.setSelected((flags & FLAG_SELECTED) != 0); + node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0); + // Other boolean properties to consider later: + // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText, + // setDismissable + + if (mAccessibilityFocusedNode == id) { + node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); + node.setAccessibilityFocused(true); + } else { + node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); + } + node.setFocused(mFocusedNode == id); + + final Rect parentBounds = new Rect(bounds[0], bounds[1], bounds[2], bounds[3]); + node.setBoundsInParent(parentBounds); + + for (final int childId : children) { + node.addChild(mView, childId); + } + + // SDK 18 and above + if (Build.VERSION.SDK_INT >= 18) { + node.setViewIdResourceName(viewIdResourceName); + + if ((flags & FLAG_EDITABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); + node.addAction(AccessibilityNodeInfo.ACTION_CUT); + node.addAction(AccessibilityNodeInfo.ACTION_COPY); + node.addAction(AccessibilityNodeInfo.ACTION_PASTE); + node.setEditable(true); + } + } + + // SDK 19 and above + if (Build.VERSION.SDK_INT >= 19) { + node.setMultiLine((flags & FLAG_MULTI_LINE) != 0); + node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0); + + // Set bundle keys like role and hint + final Bundle bundle = node.getExtras(); + if (hint != null) { + bundle.putCharSequence("AccessibilityNodeInfo.hint", hint); + if (Build.VERSION.SDK_INT >= 26) { + node.setHintText(hint); + } + } + if (geckoRole != null) { + bundle.putCharSequence("AccessibilityNodeInfo.geckoRole", geckoRole); + } + if (roleDescription != null) { + bundle.putCharSequence("AccessibilityNodeInfo.roleDescription", roleDescription); + } + if (isRoot) { + // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT. + // This is mostly here to let TalkBack know we are a legit "WebView". + bundle.putCharSequence( + "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES", + TextUtils.join(",", sHtmlGranularities)); + } + + if (inputType != InputType.TYPE_NULL) { + node.setInputType(inputType); + } + } + + // SDK 21 and above + if (Build.VERSION.SDK_INT >= 21) { + if ((flags & FLAG_EXPANDABLE) != 0) { + if ((flags & FLAG_EXPANDED) != 0) { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + } else { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + } + } + } + + // SDK 23 and above + if (Build.VERSION.SDK_INT >= 23) { + node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0); + } + } + + @WrapForJNI + private void populateNodeCollectionItemInfo( + final AccessibilityNodeInfo node, + final int rowIndex, + final int rowSpan, + final int columnIndex, + final int columnSpan) { + final CollectionItemInfo collectionItemInfo = + CollectionItemInfo.obtain(rowIndex, rowSpan, columnIndex, columnSpan, false); + node.setCollectionItemInfo(collectionItemInfo); + } + + @WrapForJNI + private void populateNodeCollectionInfo( + final AccessibilityNodeInfo node, + final int rowCount, + final int columnCount, + final int selectionMode, + final boolean isHierarchical) { + final CollectionInfo collectionInfo = + Build.VERSION.SDK_INT >= 21 + ? CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode) + : CollectionInfo.obtain(rowCount, columnCount, isHierarchical); + node.setCollectionInfo(collectionInfo); + } + + @WrapForJNI + private void populateNodeRangeInfo( + final AccessibilityNodeInfo node, + final int rangeType, + final float min, + final float max, + final float current) { + final RangeInfo rangeInfo = RangeInfo.obtain(rangeType, min, max, current); + node.setRangeInfo(rangeInfo); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java new file mode 100644 index 0000000000..2ed0b1a6c3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Pair; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Arrays; +import java.util.List; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags; +import org.mozilla.geckoview.GeckoSession.FinderFindFlags; +import org.mozilla.geckoview.GeckoSession.FinderResult; + +/** + * {@code SessionFinder} instances returned by {@link GeckoSession#getFinder()} performs + * find-in-page operations. + */ +@AnyThread +public final class SessionFinder { + private static final String LOGTAG = "GeckoSessionFinder"; + + private static final List> 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 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 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 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/SessionPdfFileSaver.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java new file mode 100644 index 0000000000..6e7c93ca8b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java @@ -0,0 +1,98 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * {@code PdfFileSaver} instances returned by {@link GeckoSession#getPdfFileSaver()} performs save + * operation. + */ +@AnyThread +public final class SessionPdfFileSaver { + private static final String LOGTAG = "GeckoPdfFileSaver"; + + private final GeckoSession mSession; + + /* package */ SessionPdfFileSaver(@NonNull final GeckoSession session) { + mSession = session; + } + + /** + * Save the current PDF. + * + * @return Result of the save operation as a {@link GeckoResult} object. + */ + @NonNull + public GeckoResult save() { + final GeckoResult geckoResult = new GeckoResult<>(); + mSession + .getEventDispatcher() + .queryBundle("GeckoView:PDFSave", null) + .map( + response -> { + geckoResult.completeFrom( + SessionPdfFileSaver.createResponse( + mSession, + response.getString("url"), + response.getString("filename"), + response.getString("originalUrl"), + true, + false)); + return null; + }); + return geckoResult; + } + + /** + * Create a WebResponse from some binary data in order to use it to download a PDF file. + * + * @param session The session. + * @param url The url for fetching the data. + * @param filename The file name. + * @param originalUrl The original url for the file. + * @param skipConfirmation Whether to skip the confirmation dialog. + * @param requestExternalApp Whether to request an external app to open the file. + * @return a response used to "download" the pdf. + */ + public static @Nullable GeckoResult createResponse( + @NonNull final GeckoSession session, + @NonNull final String url, + @NonNull final String filename, + @NonNull final String originalUrl, + final boolean skipConfirmation, + final boolean requestExternalApp) { + try { + final GeckoWebExecutor executor = new GeckoWebExecutor(session.getRuntime()); + final WebRequest request = new WebRequest(url); + return executor + .fetch(request) + .then( + new GeckoResult.OnValueListener() { + @Override + public GeckoResult onValue(final WebResponse response) { + final int statusCode = response.statusCode != 0 ? response.statusCode : 200; + return GeckoResult.fromValue( + new WebResponse.Builder(originalUrl) + .statusCode(statusCode) + .body(response.body) + .skipConfirmation(skipConfirmation) + .requestExternalApp(requestExternalApp) + .addHeader("Content-Type", "application/pdf") + .addHeader( + "Content-Disposition", "attachment; filename=\"" + filename + "\"") + .build()); + } + }); + } catch (final Exception e) { + Log.d(LOGTAG, e.getMessage()); + return null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java new file mode 100644 index 0000000000..f5e6c6976c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java @@ -0,0 +1,463 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.RectF; +import android.os.Handler; +import android.text.Editable; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.CursorAnchorInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.NativeQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * {@code SessionTextInput} handles text input for {@code GeckoSession} through key events or input + * methods. It is typically used to implement certain methods in {@link android.view.View} such as + * {@link android.view.View#onCreateInputConnection}, by forwarding such calls to corresponding + * methods in {@code SessionTextInput}. + * + *

    For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be + * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null, + * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link + * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in + * behavior in this viewless mode. + */ +public final class SessionTextInput { + /* package */ static final String LOGTAG = "GeckoSessionTextInput"; + private static final boolean DEBUG = false; + + // Interface to access GeckoInputConnection from SessionTextInput. + /* package */ interface InputConnectionClient { + View getView(); + + Handler getHandler(Handler defHandler); + + InputConnection onCreateInputConnection(EditorInfo attrs); + } + + // Interface to access GeckoEditable from GeckoInputConnection. + /* package */ interface EditableClient { + // The following value is used by requestCursorUpdates + // ONE_SHOT calls updateCompositionRects() after getting current composing + // character rects. + @Retention(RetentionPolicy.SOURCE) + @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR}) + /* package */ @interface CursorMonitorMode {} + + @WrapForJNI static final int ONE_SHOT = 1; + // START_MONITOR start the monitor for composing character rects. If is is + // updaed, call updateCompositionRects() + @WrapForJNI static final int START_MONITOR = 2; + // ENDT_MONITOR stops the monitor for composing character rects. + @WrapForJNI static final int END_MONITOR = 3; + + void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event); + + Editable getEditable(); + + void setBatchMode(boolean isBatchMode); + + Handler setInputConnectionHandler(@NonNull Handler handler); + + void postToInputConnection(@NonNull Runnable runnable); + + void requestCursorUpdates(@CursorMonitorMode int requestMode); + + void insertImage(@NonNull byte[] data, @NonNull String mimeType); + } + + // Interface to access GeckoInputConnection from GeckoEditable. + /* package */ interface EditableListener { + // IME notification type for notifyIME(), corresponding to NotificationToIME enum. + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + NOTIFY_IME_OF_TOKEN, + NOTIFY_IME_OPEN_VKB, + NOTIFY_IME_REPLY_EVENT, + NOTIFY_IME_OF_FOCUS, + NOTIFY_IME_OF_BLUR, + NOTIFY_IME_TO_COMMIT_COMPOSITION, + NOTIFY_IME_TO_CANCEL_COMPOSITION + }) + /* package */ @interface IMENotificationType {} + + @WrapForJNI static final int NOTIFY_IME_OF_TOKEN = -3; + @WrapForJNI static final int NOTIFY_IME_OPEN_VKB = -2; + @WrapForJNI static final int NOTIFY_IME_REPLY_EVENT = -1; + @WrapForJNI static final int NOTIFY_IME_OF_FOCUS = 1; + @WrapForJNI static final int NOTIFY_IME_OF_BLUR = 2; + @WrapForJNI static final int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8; + @WrapForJNI static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9; + + // IME enabled state for notifyIMEContext(). + @Retention(RetentionPolicy.SOURCE) + @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD}) + /* package */ @interface IMEState {} + + static final int IME_STATE_UNKNOWN = -1; + static final int IME_STATE_DISABLED = 0; + static final int IME_STATE_ENABLED = 1; + static final int IME_STATE_PASSWORD = 2; + + // Flags for notifyIMEContext(). + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED}) + /* package */ @interface IMEContextFlags {} + + @WrapForJNI static final int IME_FLAG_PRIVATE_BROWSING = 1 << 0; + @WrapForJNI static final int IME_FLAG_USER_ACTION = 1 << 1; + @WrapForJNI static final int IME_FOCUS_NOT_CHANGED = 1 << 2; + + void notifyIME(@IMENotificationType int type); + + void notifyIMEContext( + @IMEState int state, + String typeHint, + String modeHint, + String actionHint, + @IMEContextFlags int flag); + + void onSelectionChange(); + + void onTextChange(); + + void onDiscardComposition(); + + void onDefaultKeyEvent(KeyEvent event); + + void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect); + } + + private static final class DefaultDelegate implements GeckoSession.TextInputDelegate { + public static final DefaultDelegate INSTANCE = new DefaultDelegate(); + + private InputMethodManager getInputMethodManager(@Nullable final View view) { + if (view == null) { + return null; + } + return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + } + + @Override + public void restartInput(@NonNull final GeckoSession session, final int reason) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + + final InputMethodManager imm = getInputMethodManager(view); + if (imm == null) { + return; + } + + // InputMethodManager has internal logic to detect if we are restarting input + // in an already focused View, which is the case here because all content text + // fields are inside one LayerView. When this happens, InputMethodManager will + // tell the input method to soft reset instead of hard reset. Stock latin IME + // on Android 4.2+ has a quirk that when it soft resets, it does not clear the + // composition. The following workaround tricks the IME into clearing the + // composition when soft resetting. + if (InputMethods.needsSoftResetWorkaround( + InputMethods.getCurrentInputMethod(view.getContext()))) { + // Fake a selection change, because the IME clears the composition when + // the selection changes, even if soft-resetting. Offsets here must be + // different from the previous selection offsets, and -1 seems to be a + // reasonable, deterministic value + imm.updateSelection(view, -1, -1, -1, -1); + } + + try { + imm.restartInput(view); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Error restarting input", e); + } + } + + @Override + public void showSoftInput(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + if (view.hasFocus() && !imm.isActive(view)) { + // Marshmallow workaround: The view has focus but it is not the active + // view for the input method. (Bug 1211848) + view.clearFocus(); + view.requestFocus(); + } + imm.showSoftInput(view, 0); + } + } + + @Override + public void hideSoftInput(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + + @Override + public void updateSelection( + @NonNull final GeckoSession session, + final int selStart, + final int selEnd, + final int compositionStart, + final int compositionEnd) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + // When composition start and end is -1, + // InputMethodManager.updateSelection will remove composition + // on most IMEs. If not working, we have to add a workaround + // to EditableListener.onDiscardComposition. + imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd); + } + } + + @Override + public void updateExtractedText( + @NonNull final GeckoSession session, + @NonNull final ExtractedTextRequest request, + @NonNull final ExtractedText text) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.updateExtractedText(view, request.token, text); + } + } + + @TargetApi(21) + @Override + public void updateCursorAnchorInfo( + @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.updateCursorAnchorInfo(view, info); + } + } + } + + private final GeckoSession mSession; + private final NativeQueue mQueue; + private final GeckoEditable mEditable; + private InputConnectionClient mInputConnection; + private GeckoSession.TextInputDelegate mDelegate; + + /* package */ SessionTextInput( + final @NonNull GeckoSession session, final @NonNull NativeQueue queue) { + mSession = session; + mQueue = queue; + mEditable = new GeckoEditable(session); + } + + /* package */ void onWindowChanged(final GeckoSession.Window window) { + if (mQueue.isReady()) { + window.attachEditable(mEditable); + } else { + mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable); + } + } + + /** + * Get a Handler for the background input method thread. In order to use a background thread for + * input method operations on systems prior to Nougat, first override {@code View.getHandler()} + * for the View returning the InputConnection instance, and then call this method from the + * overridden method. + * + *

    For example: + * + *

    +   * @Override
    +   * public Handler getHandler() {
    +   *     if (Build.VERSION.SDK_INT >= 24) {
    +   *         return super.getHandler();
    +   *     }
    +   *     return getSession().getTextInput().getHandler(super.getHandler());
    +   * }
    + * + * @param defHandler Handler returned by the system {@code getHandler} implementation. + * @return Handler to return to the system through {@code getHandler}. + */ + @AnyThread + public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) { + // May be called on any thread. + if (mInputConnection != null) { + return mInputConnection.getHandler(defHandler); + } + return defHandler; + } + + /** + * Get the current {@link android.view.View} for text input. + * + * @return Current text input View or null if not set. + * @see #setView(View) + */ + @UiThread + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + return mInputConnection != null ? mInputConnection.getView() : null; + } + + /** + * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used + * to interact with the system input method manager and to display certain text input UI elements. + * See the {@code SessionTextInput} class documentation for information on viewless mode, when the + * current {@link android.view.View} is not set or set to null. + * + * @param view Text input View or null to clear current View. + * @see #getView() + */ + @UiThread + public synchronized void setView(final @Nullable View view) { + ThreadUtils.assertOnUiThread(); + + if (view == null) { + mInputConnection = null; + } else if (mInputConnection == null || mInputConnection.getView() != view) { + mInputConnection = GeckoInputConnection.create(mSession, view, mEditable); + } + mEditable.setListener((EditableListener) mInputConnection); + } + + /** + * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method + * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value + * will always be null. + * + * @param attrs EditorInfo instance to be filled on return. + * @return InputConnection instance, or null if there is no active input (or if in viewless mode). + */ + @AnyThread + public synchronized @Nullable InputConnection onCreateInputConnection( + final @NonNull EditorInfo attrs) { + // May be called on any thread. + mEditable.onCreateInputConnection(attrs); + + if (!mQueue.isReady() || mInputConnection == null) { + return null; + } + return mInputConnection.onCreateInputConnection(attrs); + } + + /** + * Process a KeyEvent as a pre-IME event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyPreIme(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a key-down event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyDown(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a key-up event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyUp(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a long-press event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyLongPress(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a multiple-press event. + * + * @param keyCode Key code. + * @param repeatCount Key repeat count. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyMultiple( + final int keyCode, final int repeatCount, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event); + } + + /** + * Set the current text input delegate. + * + * @param delegate TextInputDelegate instance or null to restore to default. + */ + @UiThread + public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Get the current text input delegate. + * + * @return TextInputDelegate instance or a default instance if no delegate has been set. + */ + @UiThread + public @NonNull GeckoSession.TextInputDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + if (mDelegate == null) { + mDelegate = DefaultDelegate.INSTANCE; + } + return mDelegate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java new file mode 100644 index 0000000000..d25c51ef9a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java @@ -0,0 +1,20 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; + +/** + * Used by a ContentDelegate to indicate what action to take on a slow script event. + * + * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String) + */ +@AnyThread +public enum SlowScriptResponse { + STOP, + CONTINUE; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java new file mode 100644 index 0000000000..a49cdf26a5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java @@ -0,0 +1,405 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission; + +/** + * Manage runtime storage data. + * + *

    Retrieve an instance via {@link GeckoRuntime#getStorageController}. + */ +public final class StorageController { + private static final String LOGTAG = "StorageController"; + + // Keep in sync with GeckoViewStorageController.ClearFlags. + /** Flags used for data clearing operations. */ + public static class ClearFlags { + /** Cookies. */ + public static final long COOKIES = 1 << 0; + + /** Network cache. */ + public static final long NETWORK_CACHE = 1 << 1; + + /** Image cache. */ + public static final long IMAGE_CACHE = 1 << 2; + + /** DOM storages. */ + public static final long DOM_STORAGES = 1 << 4; + + /** Auth tokens and caches. */ + public static final long AUTH_SESSIONS = 1 << 5; + + /** Site permissions. */ + public static final long PERMISSIONS = 1 << 6; + + /** All caches. */ + public static final long ALL_CACHES = NETWORK_CACHE | IMAGE_CACHE; + + /** All site settings (permissions, content preferences, security settings, etc.). */ + public static final long SITE_SETTINGS = 1 << 7 | PERMISSIONS; + + /** All site-related data (cookies, storages, caches, permissions, etc.). */ + public static final long SITE_DATA = + 1 << 8 | COOKIES | DOM_STORAGES | ALL_CACHES | PERMISSIONS | SITE_SETTINGS; + + /** All data. */ + public static final long ALL = 1 << 9; + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = { + ClearFlags.COOKIES, + ClearFlags.NETWORK_CACHE, + ClearFlags.IMAGE_CACHE, + ClearFlags.DOM_STORAGES, + ClearFlags.AUTH_SESSIONS, + ClearFlags.PERMISSIONS, + ClearFlags.ALL_CACHES, + ClearFlags.SITE_SETTINGS, + ClearFlags.SITE_DATA, + ClearFlags.ALL + }) + public @interface StorageControllerClearFlags {} + + /** + * Clear data for all hosts. + * + *

    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 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 clearDataFromHost( + final @NonNull String host, final @StorageControllerClearFlags long flags) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("host", host); + bundle.putLong("flags", flags); + + return EventDispatcher.getInstance().queryVoid("GeckoView:ClearHostData", bundle); + } + + /** + * Clear data owned by the given base domain (eTLD+1). Clearing data for a base domain will also + * clear any associated third-party storage. This includes clearing for third-parties embedded by + * the domain and for the given domain embedded under other sites. + * + *

    Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions prior to clearing data. + * + * @param baseDomain The base domain to be used. + * @param flags Combination of {@link ClearFlags}. + * @return A {@link GeckoResult} that will complete when clearing has finished. + */ + @AnyThread + public @NonNull GeckoResult clearDataFromBaseDomain( + final @NonNull String baseDomain, final @StorageControllerClearFlags long flags) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("baseDomain", baseDomain); + bundle.putLong("flags", flags); + + return EventDispatcher.getInstance().queryVoid("GeckoView:ClearBaseDomainData", bundle); + } + + /** + * Clear data for the given context ID. Use {@link GeckoSessionSettings.Builder#contextId}.to set + * a context ID for a session. + * + *

    Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions for the given context prior to + * clearing data. + * + * @param contextId The context ID for the storage data to be deleted. + */ + @AnyThread + public void clearDataForSessionContext(final @NonNull String contextId) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("contextId", createSafeSessionContextId(contextId)); + + EventDispatcher.getInstance().dispatch("GeckoView:ClearSessionContextData", bundle); + } + + /* package */ static @Nullable String createSafeSessionContextId( + final @Nullable String contextId) { + if (contextId == null) { + return null; + } + if (contextId.isEmpty()) { + // Let's avoid empty strings for Gecko. + return "gvctxempty"; + } + // We don't want to restrict the session context ID string options, so to + // ensure that the string is safe for Gecko processing, we translate it to + // its hex representation. + return String.format("gvctx%x", new BigInteger(contextId.getBytes())).toLowerCase(Locale.ROOT); + } + + /* package */ static @Nullable String retrieveUnsafeSessionContextId( + final @Nullable String contextId) { + if (contextId == null || contextId.isEmpty()) { + return null; + } + if ("gvctxempty".equals(contextId)) { + return ""; + } + final byte[] bytes = new BigInteger(contextId.substring(5), 16).toByteArray(); + return new String(bytes, Charset.forName("UTF-8")); + } + + /** + * Get all currently stored permissions. + * + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s. + */ + @AnyThread + public @NonNull GeckoResult> getAllPermissions() { + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetAllPermissions") + .map( + bundle -> { + final GeckoBundle[] permsArray = bundle.getBundleArray("permissions"); + return ContentPermission.fromBundleArray(permsArray); + }); + } + + /** + * Get all currently stored permissions for a given URI and default (unset) context ID, in normal + * mode This API will be deprecated in the future + * https://bugzilla.mozilla.org/show_bug.cgi?id=1797379 + * + * @param uri A String representing the URI to get permissions for. + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult> getPermissions(final @NonNull String uri) { + return getPermissions(uri, null, false); + } + + /** + * Get all currently stored permissions for a given URI and default (unset) context ID. + * + * @param uri A String representing the URI to get permissions for. + * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal + * mode. + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult> getPermissions( + final @NonNull String uri, final boolean privateMode) { + return getPermissions(uri, null, privateMode); + } + + /** + * Get all currently stored permissions for a given URI and context ID. + * + * @param uri A String representing the URI to get permissions for. + * @param contextId A String specifying the context ID. + * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal + * mode + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult> getPermissions( + final @NonNull String uri, final @Nullable String contextId, final boolean privateMode) { + final GeckoBundle msg = new GeckoBundle(2); + final int privateBrowsingId = (privateMode) ? 1 : 0; + msg.putString("uri", uri); + msg.putString("contextId", createSafeSessionContextId(contextId)); + msg.putInt("privateBrowsingId", privateBrowsingId); + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetPermissionsByURI", msg) + .map( + bundle -> { + final GeckoBundle[] permsArray = bundle.getBundleArray("permissions"); + return ContentPermission.fromBundleArray(permsArray); + }); + } + + /** + * Set a new value for an existing permission. + * + *

    Note: in private browsing, this value will only be cleared at the end of the session to add + * permanent permissions in private browsing, you can use {@link + * #setPrivateBrowsingPermanentPermission}. + * + * @param perm A {@link ContentPermission} that you wish to update the value of. + * @param value The new value for the permission. + */ + @AnyThread + public void setPermission( + final @NonNull ContentPermission perm, final @ContentPermission.Value int value) { + setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ false); + } + + /** + * Set a permanent value for a permission in a private browsing session. + * + *

    Normally permissions in private browsing are cleared at the end of the session. This method + * allows you to set a permanent permission bypassing this behavior. + * + *

    Note: permanent permissions in private browsing are web discoverable and might make the user + * more easily trackable. + * + * @see #setPermission + * @param perm A {@link ContentPermission} that you wish to update the value of. + * @param value The new value for the permission. + */ + @AnyThread + public void setPrivateBrowsingPermanentPermission( + final @NonNull ContentPermission perm, final @ContentPermission.Value int value) { + setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ true); + } + + private void setPermissionInternal( + final @NonNull ContentPermission perm, + final @ContentPermission.Value int value, + final boolean allowPermanentPrivateBrowsing) { + if (perm.permission == GeckoSession.PermissionDelegate.PERMISSION_TRACKING + && value == ContentPermission.VALUE_PROMPT) { + Log.w(LOGTAG, "Cannot set a tracking permission to VALUE_PROMPT, aborting."); + return; + } + final GeckoBundle msg = perm.toGeckoBundle(); + msg.putInt("newValue", value); + msg.putBoolean("allowPermanentPrivateBrowsing", allowPermanentPrivateBrowsing); + EventDispatcher.getInstance().dispatch("GeckoView:SetPermission", msg); + } + + /** + * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode. + * + * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode} + * value. + * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri. + * @param isPrivateBrowsing Indicates in which browsing mode the given {@link + * ContentBlocking.CBCookieBannerMode} should be applied. + * @return A {@link GeckoResult} that will complete when the mode has been set. + */ + @AnyThread + public @NonNull GeckoResult setCookieBannerModeForDomain( + final @NonNull String uri, + final @ContentBlocking.CBCookieBannerMode int mode, + final boolean isPrivateBrowsing) { + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putInt("mode", mode); + data.putBoolean("allowPermanentPrivateBrowsing", false); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data); + } + + /** + * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri in private mode. + * + * @param uri for which you want to change the {@link ContentBlocking.CBCookieBannerMode} value. + * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri. + * @return A {@link GeckoResult} that will complete when the mode has been set. + */ + @AnyThread + public @NonNull GeckoResult setCookieBannerModeAndPersistInPrivateBrowsingForDomain( + final @NonNull String uri, final @ContentBlocking.CBCookieBannerMode int mode) { + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putInt("mode", mode); + data.putBoolean("allowPermanentPrivateBrowsing", true); + return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data); + } + + /** + * Removes a {@link ContentBlocking.CBCookieBannerMode} for the given uri and and browsing mode. + * + * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode} + * value. + * @param isPrivateBrowsing Indicates in which mode the given mode should be applied. + * @return A {@link GeckoResult} that will complete when the mode has been removed. + */ + @AnyThread + public @NonNull GeckoResult removeCookieBannerModeForDomain( + final @NonNull String uri, final boolean isPrivateBrowsing) { + + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance() + .queryVoid("GeckoView:RemoveCookieBannerModeForDomain", data); + } + + /** + * Gets the actual {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode. + * + * @param uri An uri for which you want get the {@link ContentBlocking.CBCookieBannerMode}. + * @param isPrivateBrowsing Indicates in which browsing mode the given uri should be. + * @return A {@link GeckoResult} that resolves to a {@link ContentBlocking.CBCookieBannerMode} for + * the given uri and browsing mode. + */ + @AnyThread + public @NonNull @ContentBlocking.CBCookieBannerMode GeckoResult + getCookieBannerModeForDomain(final @NonNull String uri, final boolean isPrivateBrowsing) { + + final GeckoBundle data = new GeckoBundle(2); + data.putString("uri", uri); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetCookieBannerModeForDomain", data) + .map(StorageController::cookieBannerModeFromBundle, StorageController::fromQueryException); + } + + private static @ContentBlocking.CBCookieBannerMode int cookieBannerModeFromBundle( + final GeckoBundle bundle) throws Exception { + if (bundle == null) { + throw new Exception("Unable to parse cookie banner mode"); + } + return bundle.getInt("mode"); + } + + private static Throwable fromQueryException(final Throwable exception) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) exception; + final Object response = queryException.data; + return new Exception(response.toString()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java new file mode 100644 index 0000000000..ffcdf45d6c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java @@ -0,0 +1,586 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.app.PendingIntent; +import android.content.Intent; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; +import com.google.android.gms.fido.Fido; +import com.google.android.gms.fido.common.Transport; +import com.google.android.gms.fido.fido2.Fido2ApiClient; +import com.google.android.gms.fido.fido2.Fido2PrivilegedApiClient; +import com.google.android.gms.fido.fido2.api.common.Algorithm; +import com.google.android.gms.fido.fido2.api.common.Attachment; +import com.google.android.gms.fido.fido2.api.common.AttestationConveyancePreference; +import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensions; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria; +import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialCreationOptions; +import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialRequestOptions; +import com.google.android.gms.fido.fido2.api.common.EC2Algorithm; +import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameters; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity; +import com.google.android.gms.fido.fido2.api.common.RSAAlgorithm; +import com.google.android.gms.fido.fido2.api.common.ResidentKeyRequirement; +import com.google.android.gms.tasks.Task; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.GeckoBundle; + +/* package */ class WebAuthnTokenManager { + private static final String LOGTAG = "WebAuthnTokenManager"; + + // from u2fhid-capi.h + private static final byte AUTHENTICATOR_TRANSPORT_USB = 1; + private static final byte AUTHENTICATOR_TRANSPORT_NFC = 2; + private static final byte AUTHENTICATOR_TRANSPORT_BLE = 4; + + private static final Algorithm[] SUPPORTED_ALGORITHMS = { + EC2Algorithm.ES256, + EC2Algorithm.ES384, + EC2Algorithm.ES512, + EC2Algorithm.ED256, /* no ED384 */ + EC2Algorithm.ED512, + RSAAlgorithm.PS256, + RSAAlgorithm.PS384, + RSAAlgorithm.PS512, + RSAAlgorithm.RS256, + RSAAlgorithm.RS384, + RSAAlgorithm.RS512 + }; + + private static List getTransportsForByte(final byte transports) { + final ArrayList result = new ArrayList(); + 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 CombineBuffers( + final Object[] idObjectList, final ByteBuffer transportList) { + if (idObjectList.length != transportList.remaining()) { + throw new RuntimeException("Couldn't extract allowed list!"); + } + + final ArrayList credList = + new ArrayList(); + + final byte[] transportBytes = new byte[transportList.remaining()]; + transportList.get(transportBytes); + + for (int i = 0; i < idObjectList.length; i++) { + final ByteBuffer id = (ByteBuffer) idObjectList[i]; + final byte[] idBytes = new byte[id.remaining()]; + id.get(idBytes); + + credList.add(new WebAuthnPublicCredential(idBytes, transportBytes[i])); + } + return credList; + } + } + + // From WebAuthentication.webidl + public enum AttestationPreference { + NONE, + INDIRECT, + DIRECT, + } + + @WrapForJNI + public static class MakeCredentialResponse { + public final byte[] clientDataJson; + public final byte[] keyHandle; + public final byte[] attestationObject; + + public MakeCredentialResponse( + final byte[] clientDataJson, final byte[] keyHandle, final byte[] attestationObject) { + this.clientDataJson = clientDataJson; + this.keyHandle = keyHandle; + this.attestationObject = attestationObject; + } + } + + public static class Exception extends RuntimeException { + public Exception(final String error) { + super(error); + } + } + + public static GeckoResult makeCredential( + final GeckoBundle credentialBundle, + final byte[] userId, + final byte[] challenge, + final WebAuthnTokenManager.WebAuthnPublicCredential[] excludeList, + final GeckoBundle authenticatorSelection, + final GeckoBundle extensions) { + if (!credentialBundle.containsKey("isWebAuthn")) { + // FIDO U2F not supported by Android (for us anyway) at this time + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR")); + } + + final PublicKeyCredentialCreationOptions.Builder requestBuilder = + new PublicKeyCredentialCreationOptions.Builder(); + + final List params = + new ArrayList(); + + // WebAuthn supports more algorithms + for (final Algorithm algo : SUPPORTED_ALGORITHMS) { + params.add( + new PublicKeyCredentialParameters( + PublicKeyCredentialType.PUBLIC_KEY.toString(), algo.getAlgoValue())); + } + + final PublicKeyCredentialUserEntity user = + new PublicKeyCredentialUserEntity( + userId, + credentialBundle.getString("userName", ""), + credentialBundle.getString("userIcon", ""), + credentialBundle.getString("userDisplayName", "")); + + AttestationConveyancePreference pref = AttestationConveyancePreference.NONE; + final String attestationPreference = + authenticatorSelection.getString("attestationPreference", "NONE"); + if (attestationPreference.equalsIgnoreCase(AttestationConveyancePreference.DIRECT.name())) { + pref = AttestationConveyancePreference.DIRECT; + } else if (attestationPreference.equalsIgnoreCase( + AttestationConveyancePreference.INDIRECT.name())) { + pref = AttestationConveyancePreference.INDIRECT; + } + + final AuthenticatorSelectionCriteria.Builder selBuild = + new AuthenticatorSelectionCriteria.Builder(); + if (authenticatorSelection.getInt("requirePlatformAttachment", 0) == 1) { + selBuild.setAttachment(Attachment.PLATFORM); + } + if (authenticatorSelection.getInt("requireCrossPlatformAttachment", 0) == 1) { + selBuild.setAttachment(Attachment.CROSS_PLATFORM); + } + final String residentKey = authenticatorSelection.getString("residentKey", ""); + if (residentKey.equals("required")) { + selBuild + .setRequireResidentKey(true) + .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_REQUIRED); + } else if (residentKey.equals("preferred")) { + selBuild + .setRequireResidentKey(false) + .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_PREFERRED); + } else if (residentKey.equals("discouraged")) { + selBuild + .setRequireResidentKey(false) + .setResidentKeyRequirement(ResidentKeyRequirement.RESIDENT_KEY_DISCOURAGED); + } + final AuthenticatorSelectionCriteria sel = selBuild.build(); + + final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder(); + if (extensions.containsKey("fidoAppId")) { + extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId"))); + } + final AuthenticationExtensions ext = extBuilder.build(); + + // requireUserVerification are not yet consumed by Android's API + + final List excludedList = + new ArrayList(); + for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) { + excludedList.add( + new PublicKeyCredentialDescriptor( + PublicKeyCredentialType.PUBLIC_KEY.toString(), + cred.id, + getTransportsForByte(cred.transports))); + } + + final PublicKeyCredentialRpEntity rp = + new PublicKeyCredentialRpEntity( + credentialBundle.getString("rpId"), + credentialBundle.getString("rpName", ""), + credentialBundle.getString("rpIcon", "")); + + final PublicKeyCredentialCreationOptions requestOptions = + requestBuilder + .setUser(user) + .setAttestationConveyancePreference(pref) + .setAuthenticatorSelection(sel) + .setAuthenticationExtensions(ext) + .setChallenge(challenge) + .setRp(rp) + .setParameters(params) + .setTimeoutSeconds(credentialBundle.getLong("timeoutMS") / 1000.0) + .setExcludeList(excludedList) + .build(); + + final Uri origin = Uri.parse(credentialBundle.getString("origin")); + + final BrowserPublicKeyCredentialCreationOptions browserOptions = + new BrowserPublicKeyCredentialCreationOptions.Builder() + .setPublicKeyCredentialCreationOptions(requestOptions) + .setOrigin(origin) + .build(); + + final Task intentTask; + + if (BuildConfig.MOZILLA_OFFICIAL) { + // Certain Fenix builds and signing keys are whitelisted for Web Authentication. + // See https://wiki.mozilla.org/Security/Web_Authentication + // + // Third party apps will need to get whitelisted themselves. + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getRegisterPendingIntent(browserOptions); + } else { + // For non-official builds, websites have to opt-in to permit the + // particular version of Gecko to perform WebAuthn operations on + // them. See https://developers.google.com/digital-asset-links + // for the general form, and Step 1 of + // https://developers.google.com/identity/fido/android/native-apps + // for details about doing this correctly for the FIDO2 API. + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getRegisterPendingIntent(requestOptions); + } + + final GeckoResult result = new GeckoResult<>(); + + intentTask.addOnSuccessListener( + pendingIntent -> { + GeckoRuntime.getInstance() + .startActivityForResult(pendingIntent) + .accept( + intent -> { + final WebAuthnTokenManager.Exception error = parseErrorIntent(intent); + if (error != null) { + result.completeExceptionally(error); + return; + } + + final byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA); + if (rspData != null) { + final AuthenticatorAttestationResponse responseData = + AuthenticatorAttestationResponse.deserializeFromBytes(rspData); + + Log.d( + LOGTAG, + "key handle: " + + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "clientDataJSON: " + + Base64.encodeToString( + responseData.getClientDataJSON(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "attestation Object: " + + Base64.encodeToString( + responseData.getAttestationObject(), Base64.DEFAULT)); + + result.complete( + new WebAuthnTokenManager.MakeCredentialResponse( + responseData.getClientDataJSON(), + responseData.getKeyHandle(), + responseData.getAttestationObject())); + } + }, + e -> { + Log.w(LOGTAG, "Failed to launch activity: ", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR")); + }); + }); + + intentTask.addOnFailureListener( + e -> { + Log.w(LOGTAG, "Failed to get FIDO intent", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR")); + }); + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult webAuthnMakeCredential( + final GeckoBundle credentialBundle, + final ByteBuffer userId, + final ByteBuffer challenge, + final Object[] idList, + final ByteBuffer transportList, + final GeckoBundle authenticatorSelection, + final GeckoBundle extensions) { + final ArrayList excludeList; + + final byte[] challBytes = new byte[challenge.remaining()]; + final byte[] userBytes = new byte[userId.remaining()]; + try { + challenge.get(challBytes); + userId.get(userBytes); + + excludeList = WebAuthnPublicCredential.CombineBuffers(idList, transportList); + } catch (final RuntimeException e) { + Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + + try { + return makeCredential( + credentialBundle, + userBytes, + challBytes, + excludeList.toArray(new WebAuthnPublicCredential[0]), + authenticatorSelection, + extensions); + } catch (final Exception e) { + // We need to ensure we catch any possible exception here in order to ensure + // that the Promise on the content side is appropriately rejected. In particular, + // we will get `NoClassDefFoundError` if we're running on a device that does not + // have Google Play Services. + Log.w(LOGTAG, "Couldn't make credential", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + } + + @WrapForJNI + public static class GetAssertionResponse { + public final byte[] clientDataJson; + public final byte[] keyHandle; + public final byte[] authData; + public final byte[] signature; + public final byte[] userHandle; + + public GetAssertionResponse( + final byte[] clientDataJson, + final byte[] keyHandle, + final byte[] authData, + final byte[] signature, + final byte[] userHandle) { + this.clientDataJson = clientDataJson; + this.keyHandle = keyHandle; + this.authData = authData; + this.signature = signature; + this.userHandle = userHandle; + } + } + + private static WebAuthnTokenManager.Exception parseErrorIntent(final Intent intent) { + if (!intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) { + return null; + } + + final byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA); + final AuthenticatorErrorResponse responseData = + AuthenticatorErrorResponse.deserializeFromBytes(errData); + + Log.e(LOGTAG, "errorCode.name: " + responseData.getErrorCode()); + Log.e(LOGTAG, "errorMessage: " + responseData.getErrorMessage()); + + return new WebAuthnTokenManager.Exception(responseData.getErrorCode().name()); + } + + private static GeckoResult getAssertion( + final byte[] challenge, + final WebAuthnTokenManager.WebAuthnPublicCredential[] allowList, + final GeckoBundle assertionBundle, + final GeckoBundle extensions) { + + if (!assertionBundle.containsKey("isWebAuthn")) { + // FIDO U2F not supported by Android (for us anyway) at this time + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR")); + } + + final List allowedList = + new ArrayList(); + for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) { + allowedList.add( + new PublicKeyCredentialDescriptor( + PublicKeyCredentialType.PUBLIC_KEY.toString(), + cred.id, + getTransportsForByte(cred.transports))); + } + + final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder(); + if (extensions.containsKey("fidoAppId")) { + extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId"))); + } + final AuthenticationExtensions ext = extBuilder.build(); + + final PublicKeyCredentialRequestOptions requestOptions = + new PublicKeyCredentialRequestOptions.Builder() + .setChallenge(challenge) + .setAllowList(allowedList) + .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0) + .setRpId(assertionBundle.getString("rpId")) + .setAuthenticationExtensions(ext) + .build(); + + final Uri origin = Uri.parse(assertionBundle.getString("origin")); + final BrowserPublicKeyCredentialRequestOptions browserOptions = + new BrowserPublicKeyCredentialRequestOptions.Builder() + .setPublicKeyCredentialRequestOptions(requestOptions) + .setOrigin(origin) + .build(); + + final Task intentTask; + // See the makeCredential method for documentation about this + // conditional. + if (BuildConfig.MOZILLA_OFFICIAL) { + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getSignPendingIntent(browserOptions); + } else { + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getSignPendingIntent(requestOptions); + } + + final GeckoResult result = new GeckoResult<>(); + intentTask.addOnSuccessListener( + pendingIntent -> { + GeckoRuntime.getInstance() + .startActivityForResult(pendingIntent) + .accept( + intent -> { + final WebAuthnTokenManager.Exception error = parseErrorIntent(intent); + if (error != null) { + result.completeExceptionally(error); + return; + } + + if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) { + final byte[] rspData = + intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA); + final AuthenticatorAssertionResponse responseData = + AuthenticatorAssertionResponse.deserializeFromBytes(rspData); + + Log.d( + LOGTAG, + "key handle: " + + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "clientDataJSON: " + + Base64.encodeToString( + responseData.getClientDataJSON(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "auth data: " + + Base64.encodeToString( + responseData.getAuthenticatorData(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "signature: " + + Base64.encodeToString(responseData.getSignature(), Base64.DEFAULT)); + + // Nullable field + byte[] userHandle = responseData.getUserHandle(); + if (userHandle == null) { + userHandle = new byte[0]; + } + + result.complete( + new WebAuthnTokenManager.GetAssertionResponse( + responseData.getClientDataJSON(), + responseData.getKeyHandle(), + responseData.getAuthenticatorData(), + responseData.getSignature(), + userHandle)); + } + }, + e -> { + Log.w(LOGTAG, "Failed to get FIDO intent", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + }); + }); + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult webAuthnGetAssertion( + final ByteBuffer challenge, + final Object[] idList, + final ByteBuffer transportList, + final GeckoBundle assertionBundle, + final GeckoBundle extensions) { + final ArrayList allowList; + + final byte[] challBytes = new byte[challenge.remaining()]; + try { + challenge.get(challBytes); + allowList = WebAuthnPublicCredential.CombineBuffers(idList, transportList); + } catch (final RuntimeException e) { + Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + + try { + return getAssertion( + challBytes, + allowList.toArray(new WebAuthnPublicCredential[0]), + assertionBundle, + extensions); + } catch (final java.lang.Exception e) { + Log.w(LOGTAG, "Couldn't get assertion", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult webAuthnIsUserVerifyingPlatformAuthenticatorAvailable() { + final Task task; + if (BuildConfig.MOZILLA_OFFICIAL) { + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable(); + } else { + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable(); + } + + final GeckoResult res = new GeckoResult<>(); + task.addOnSuccessListener( + isUVPAA -> { + res.complete(isUVPAA); + }); + task.addOnFailureListener( + e -> { + Log.w(LOGTAG, "isUserVerifyingPlatformAuthenticatorAvailable is failed", e); + res.complete(false); + }); + return res; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java new file mode 100644 index 0000000000..da5573b3c7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java @@ -0,0 +1,2806 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/** Represents a WebExtension that may be used by GeckoView. */ +public class WebExtension { + /** + * file: or resource: 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 resource://android alias. E.g. + * + *

    
    +   *      resource://android/assets/web_extensions/my_webextension/
    +   * 
    + * + * Will point to folder /assets/web_extensions/my_webextension/ in the APK. + */ + public final @NonNull String location; + + /** Unique identifier for this WebExtension */ + public final @NonNull String id; + + /** {@link Flags} for this WebExtension. */ + public final @WebExtensionFlags long flags; + + /** Provides information about this {@link WebExtension}. */ + public final @NonNull MetaData metaData; + + /** + * Whether this extension is built-in. Built-in extension can be installed using {@link + * WebExtensionController#installBuiltIn}. + */ + public final boolean isBuiltIn; + + /** + * Called whenever a delegate is set or unset on this {@link WebExtension} instance. /* package + */ + interface DelegateController { + void onMessageDelegate(final String nativeApp, final MessageDelegate delegate); + + void onActionDelegate(final ActionDelegate delegate); + + void onBrowsingDataDelegate(final BrowsingDataDelegate delegate); + + void onTabDelegate(final TabDelegate delegate); + + void onDownloadDelegate(final DownloadDelegate delegate); + + ActionDelegate getActionDelegate(); + + BrowsingDataDelegate getBrowsingDataDelegate(); + + TabDelegate getTabDelegate(); + + DownloadDelegate getDownloadDelegate(); + } + + /* package */ interface DelegateControllerProvider { + @NonNull + DelegateController controllerFor(final WebExtension extension); + } + + private final DelegateController mDelegateController; + + @Override + public String toString() { + return "WebExtension {" + + "location=" + + location + + ", " + + "id=" + + id + + ", " + + "flags=" + + flags + + "}"; + } + + private static final String LOGTAG = "WebExtension"; + + // Keep in sync with GeckoViewWebExtension.sys.mjs + public static class Flags { + /* + * Default flags for this WebExtension. + */ + public static final long NONE = 0; + + /** + * Set this flag if you want to enable content scripts messaging. To listen to such messages you + * can use {@link SessionController#setMessageDelegate}. + */ + public static final long ALLOW_CONTENT_MESSAGING = 1 << 0; + + // Do not instantiate this class. + protected Flags() {} + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Flags.NONE, Flags.ALLOW_CONTENT_MESSAGING}) + public @interface WebExtensionFlags {} + + /* package */ WebExtension(final DelegateControllerProvider provider, final GeckoBundle bundle) { + location = bundle.getString("locationURI"); + id = bundle.getString("webExtensionId"); + flags = bundle.getInt("webExtensionFlags", 0); + isBuiltIn = bundle.getBoolean("isBuiltIn", false); + if (bundle.containsKey("metaData")) { + metaData = new MetaData(bundle.getBundle("metaData")); + } else { + metaData = null; + } + mDelegateController = provider.controllerFor(this); + } + + /** + * Defines the message delegate for a Native App. + * + *

    This message delegate will receive messages from the background script for the native app + * specified in nativeApp. + * + *

    For messages from content scripts, set a session-specific message delegate using {@link + * SessionController#setMessageDelegate}. + * + *

    See also + * WebExtensions/Native_messaging + * + * @param messageDelegate handles messaging between the WebExtension and the app. To send a + * message from the WebExtension use the runtime.sendNativeMessage WebExtension + * API: E.g. + *

    
    +   *        browser.runtime.sendNativeMessage(nativeApp,
    +   *              {message: "Hello from WebExtension!"});
    +   *      
    + * For bidirectional communication, use runtime.connectNative. E.g. in a content + * script: + *
    
    +   *          let port = browser.runtime.connectNative(nativeApp);
    +   *          port.onMessage.addListener(message => {
    +   *              console.log("Message received from app");
    +   *          });
    +   *          port.postMessage("Ping from WebExtension");
    +   *      
    + * 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 nativeApp specified in the WebExtension needs to match the + * nativeApp parameter of this method. + *

    You can unset the message delegate by setting a null messageDelegate. + * @param nativeApp which native app id this message delegate will handle messaging for. Needs to + * match the application parameter of runtime.sendNativeMessage and + * runtime.connectNative. + * @see SessionController#setMessageDelegate + */ + @UiThread + public void setMessageDelegate( + final @Nullable MessageDelegate messageDelegate, final @NonNull String nativeApp) { + mDelegateController.onMessageDelegate(nativeApp, messageDelegate); + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + value = { + BrowsingDataDelegate.Type.CACHE, + BrowsingDataDelegate.Type.COOKIES, + BrowsingDataDelegate.Type.DOWNLOADS, + BrowsingDataDelegate.Type.FORM_DATA, + BrowsingDataDelegate.Type.HISTORY, + BrowsingDataDelegate.Type.LOCAL_STORAGE, + BrowsingDataDelegate.Type.PASSWORDS + }, + flag = true) + public @interface BrowsingDataTypes {} + + /** + * This delegate is used to handle calls from the |browsingData| WebExtension API. + * + *

    See also: + * WebExtensions/API/browsingData + */ + @UiThread + public interface BrowsingDataDelegate { + /** + * This class represents the current default settings for the "Clear Data" functionality in the + * browser. + * + *

    See also: + * WebExtensions/API/browsingData/settings + */ + @UiThread + class Settings { + /** + * Currently selected setting in the browser's "Clear Data" UI for how far back in time to + * remove data given in milliseconds since the UNIX epoch. + */ + public final int sinceUnixTimestamp; + + /** + * Data types that can be toggled in the browser's "Clear Data" UI. One or more flags from + * {@link Type}. + */ + public final @BrowsingDataTypes long toggleableTypes; + + /** + * Data types currently selected in the browser's "Clear Data" UI. One or more flags from + * {@link Type}. + */ + public final @BrowsingDataTypes long selectedTypes; + + /** + * Creates an instance of Settings. + * + *

    This class represents the current default settings for the "Clear Data" functionality in + * the browser. + * + * @param since Currently selected setting in the browser's "Clear Data" UI for how far back + * in time to remove data given in milliseconds since the UNIX epoch. + * @param toggleableTypes Data types that can be toggled in the browser's "Clear Data" UI. One + * or more flags from {@link Type}. + * @param selectedTypes Data types currently selected in the browser's "Clear Data" UI. One or + * more flags from {@link Type}. + */ + @UiThread + public Settings( + final int since, + final @BrowsingDataTypes long toggleableTypes, + final @BrowsingDataTypes long selectedTypes) { + this.toggleableTypes = toggleableTypes; + this.selectedTypes = selectedTypes; + this.sinceUnixTimestamp = since; + } + + private GeckoBundle fromBrowsingDataType(final @BrowsingDataTypes long types) { + final GeckoBundle result = new GeckoBundle(7); + result.putBoolean("cache", (types & Type.CACHE) != 0); + result.putBoolean("cookies", (types & Type.COOKIES) != 0); + result.putBoolean("downloads", (types & Type.DOWNLOADS) != 0); + result.putBoolean("formData", (types & Type.FORM_DATA) != 0); + result.putBoolean("history", (types & Type.HISTORY) != 0); + result.putBoolean("localStorage", (types & Type.LOCAL_STORAGE) != 0); + result.putBoolean("passwords", (types & Type.PASSWORDS) != 0); + return result; + } + + /* package */ GeckoBundle toGeckoBundle() { + final GeckoBundle options = new GeckoBundle(1); + options.putLong("since", sinceUnixTimestamp); + + final GeckoBundle result = new GeckoBundle(3); + result.putBundle("options", options); + result.putBundle("dataToRemove", fromBrowsingDataType(selectedTypes)); + result.putBundle("dataRemovalPermitted", fromBrowsingDataType(toggleableTypes)); + return result; + } + } + + /** Types of data that a browser "Clear Data" UI might have access to. */ + class Type { + protected Type() {} + + public static final long CACHE = 1 << 0; + public static final long COOKIES = 1 << 1; + public static final long DOWNLOADS = 1 << 2; + public static final long FORM_DATA = 1 << 3; + public static final long HISTORY = 1 << 4; + public static final long LOCAL_STORAGE = 1 << 5; + public static final long PASSWORDS = 1 << 6; + } + + /** + * Gets current settings for the browser's "Clear Data" UI. + * + * @return a {@link GeckoResult} that resolves to an instance of {@link Settings} that + * represents the current state for the browser's "Clear Data" UI. + * @see Settings + */ + @Nullable + default GeckoResult 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 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 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 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 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 + * runtime.sendNativeMessage. + * + * @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 onMessage( + final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull MessageSender sender) { + return null; + } + + /** + * Called whenever the WebExtension connects to an app using runtime.connectNative. + * + * @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: + * WebExtensions/API/runtime/Port . + * + * @see MessageDelegate#onConnect + */ + @UiThread + public static class Port { + /* package */ final long id; + /* package */ PortDelegate delegate; + /* package */ boolean disconnected = false; + /* package */ final EventDispatcher mEventDispatcher; + /* package */ boolean mListenersRegistered = false; + + /** {@link MessageSender} corresponding to this port. */ + public @NonNull final MessageSender sender; + + /** The application identifier of the MessageDelegate that opened this port. */ + public @NonNull final String name; + + /** Override for tests. */ + protected Port() { + this.id = -1; + this.delegate = null; + this.sender = null; + this.name = null; + mEventDispatcher = null; + } + + /* package */ Port(final String name, final long id, final MessageSender sender) { + this.id = id; + this.delegate = null; + this.sender = sender; + this.name = name; + mEventDispatcher = EventDispatcher.byName("port:" + id); + } + + private BundleEventListener mEventListener = + new BundleEventListener() { + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if ("GeckoView:WebExtension:Disconnect".equals(event)) { + disconnectFromExtension(callback); + } else if ("GeckoView:WebExtension:PortMessage".equals(event)) { + portMessage(message, callback); + } + } + }; + + private void disconnectFromExtension(final EventCallback callback) { + delegate.onDisconnect(this); + disconnected(); + } + + private void portMessage(final GeckoBundle bundle, final EventCallback callback) { + final Object content; + try { + content = bundle.toJSONObject().get("data"); + } catch (final JSONException ex) { + callback.sendError(ex); + return; + } + + delegate.onPortMessage(content, this); + } + + /** + * Post a message to the WebExtension connected to this {@link Port} instance. + * + * @param message {@link JSONObject} that will be sent to the WebExtension. + */ + public void postMessage(final @NonNull JSONObject message) { + final GeckoBundle args = new GeckoBundle(1); + try { + args.putBundle("message", GeckoBundle.fromJSONObject(message)); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + mEventDispatcher.dispatch("GeckoView:WebExtension:PortMessageFromApp", args); + } + + /** Disconnects this port and notifies the other end. */ + public void disconnect() { + if (this.disconnected) { + return; + } + + final GeckoBundle args = new GeckoBundle(1); + args.putLong("portId", id); + + mEventDispatcher.dispatch("GeckoView:WebExtension:PortDisconnect", args); + disconnected(); + } + + private void disconnected() { + unregisterListeners(); + mEventDispatcher.shutdown(); + this.disconnected = true; + } + + /** + * Set a delegate for incoming messages through this {@link Port}. + * + * @param delegate Delegate that will receive messages sent through this {@link Port}. + */ + public void setDelegate(final @Nullable PortDelegate delegate) { + this.delegate = delegate; + + if (delegate != null) { + registerListeners(); + } else { + unregisterListeners(); + } + } + + private void unregisterListeners() { + if (!mListenersRegistered) { + return; + } + + mEventDispatcher.unregisterUiThreadListener( + mEventListener, + "GeckoView:WebExtension:Disconnect", + "GeckoView:WebExtension:PortMessage"); + mListenersRegistered = false; + } + + private void registerListeners() { + if (mListenersRegistered) { + return; + } + + mEventDispatcher.registerUiThreadListener( + mEventListener, + "GeckoView:WebExtension:Disconnect", + "GeckoView:WebExtension:PortMessage"); + mListenersRegistered = true; + } + } + + /** + * This delegate is invoked whenever an extension uses the `tabs` WebExtension API to modify the + * state of a tab. See also WebExtensions/API/tabs. + */ + 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: + * WebExtensions/API/tabs/remove + * + * @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 onCloseTab( + @Nullable final WebExtension source, @NonNull final GeckoSession session) { + return GeckoResult.deny(); + } + + /** + * Called when tabs.update is invoked. The uri is provided for informational purposes, there's + * no need to call loadURI on it. The page will be loaded if this method returns + * GeckoResult.ALLOW. + * + *

    See also: + * WebExtensions/API/tabs/update + * + * @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 GeckoResult.ALLOW if the tab will be updated, GeckoResult.DENY + * otherwise. + */ + @UiThread + @NonNull + default GeckoResult onUpdateTab( + final @NonNull WebExtension extension, + final @NonNull GeckoSession session, + final @NonNull UpdateTabDetails details) { + return GeckoResult.deny(); + } + } + + /** + * Provides details about upating a tab with tabs.update. + * + *

    Whenever a field is not passed in by the extension that value will be null. + * + *

    See also: + * WebExtensions/API/tabs/update . + */ + public static class UpdateTabDetails { + /** + * Whether the tab should become active. If true, non-active highlighted tabs + * should stop being highlighted. If false, 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 true 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 GeckoResult.ALLOW 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 tabs.create. See also: + * WebExtensions/API/tabs/create . + * + *

    Whenever a field is not passed in by the extension that value will be null. + */ + public static class CreateTabDetails { + /** + * Whether the tab should become active. If true, non-active highlighted tabs + * should stop being highlighted. If false, 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 GeckoResult.ALLOW 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 WebExtensions/API/tabs. + */ + 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 onNewTab( + @NonNull final WebExtension source, @NonNull final CreateTabDetails createDetails) { + return null; + } + + /** + * Called when runtime.openOptionsPage is invoked with options_ui.open_in_tab = false. In this + * case, GeckoView delegates options page handling to the app. With options_ui.open_in_tab = + * true, {@link #onNewTab} is called instead. + * + * @param source An instance of {@link WebExtension}. + */ + @UiThread + default void onOpenOptionsPage(@NonNull final WebExtension source) {} + } + + /** + * Get the tab delegate for this extension. + * + *

    See also WebExtensions/API/tabs. + * + * @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 WebExtensions/API/tabs. + * + * @param delegate the {@link TabDelegate} instance for this extension. + */ + @UiThread + public void setTabDelegate(final @Nullable TabDelegate delegate) { + mDelegateController.onTabDelegate(delegate); + } + + @UiThread + @Nullable + public BrowsingDataDelegate getBrowsingDataDelegate() { + return mDelegateController.getBrowsingDataDelegate(); + } + + @UiThread + public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) { + mDelegateController.onBrowsingDataDelegate(delegate); + } + + private static class Sender { + public String webExtensionId; + public String nativeApp; + + public Sender(final String webExtensionId, final String nativeApp) { + this.webExtensionId = webExtensionId; + this.nativeApp = nativeApp; + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof Sender)) { + return false; + } + + final Sender o = (Sender) other; + return webExtensionId.equals(o.webExtensionId) && nativeApp.equals(o.nativeApp); + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {webExtensionId, nativeApp}); + } + } + + // Public wrapper for Listener + public static class SessionController { + private final Listener 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 nativeApp. + * + * @param extension {@link WebExtension} that this delegate receives messages from. + * @param nativeApp identifier for the native app + * @return The {@link MessageDelegate} attached to the nativeApp. null + * 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 + * tabs API. + * + * @param extension the {@link WebExtension} this delegate will receive updates for + * @param delegate the {@link TabDelegate} that will receive updates. + * @see WebExtension#setTabDelegate + */ + @AnyThread + public void setTabDelegate( + final @NonNull WebExtension extension, final @Nullable SessionTabDelegate delegate) { + mListener.setTabDelegate(extension, delegate); + } + + /** + * Get the TabDelegate for the given extension. + * + * @param extension the {@link WebExtension} this delegate refers to. + * @return the current {@link SessionTabDelegate} instance + */ + @AnyThread + @Nullable + public SessionTabDelegate getTabDelegate(final @NonNull WebExtension extension) { + return mListener.getTabDelegate(extension); + } + } + + /* package */ static final class Listener implements BundleEventListener { + private final HashMap mMessageDelegates; + private final HashMap mActionDelegates; + private final HashMap mBrowsingDataDelegates; + private final HashMap mTabDelegates; + private final HashMap mDownloadDelegates; + + private final GeckoSession mSession; + private final EventDispatcher mEventDispatcher; + + private boolean mActionDelegateRegistered = false; + private boolean mBrowsingDataDelegateRegistered = false; + private boolean mTabDelegateRegistered = false; + + public GeckoRuntime runtime; + + public Listener(final GeckoRuntime runtime) { + this(null, runtime); + } + + public Listener(final GeckoSession session) { + this(session, null); + + // Close tab event is forwarded to the main listener so we need to listen + // to it here. + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:NewTab", + "GeckoView:WebExtension:UpdateTab", + "GeckoView:WebExtension:CloseTab", + "GeckoView:WebExtension:OpenOptionsPage"); + mTabDelegateRegistered = true; + } + + private Listener(final GeckoSession session, final GeckoRuntime runtime) { + mMessageDelegates = new HashMap<>(); + mActionDelegates = new HashMap<>(); + mBrowsingDataDelegates = new HashMap<>(); + mTabDelegates = new HashMap<>(); + mDownloadDelegates = new HashMap<>(); + mEventDispatcher = + session != null ? session.getEventDispatcher() : EventDispatcher.getInstance(); + mSession = session; + this.runtime = runtime; + + // We queue these messages if the delegate has not been attached yet, + // so we need to start listening immediately. + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:Message", + "GeckoView:WebExtension:PortMessage", + "GeckoView:WebExtension:Connect", + "GeckoView:WebExtension:Disconnect", + "GeckoView:BrowsingData:GetSettings", + "GeckoView:BrowsingData:Clear", + "GeckoView:WebExtension:Download"); + } + + public void unregisterWebExtension(final WebExtension extension) { + mMessageDelegates.remove(extension.id); + mActionDelegates.remove(extension.id); + mBrowsingDataDelegates.remove(extension.id); + mTabDelegates.remove(extension.id); + mDownloadDelegates.remove(extension.id); + } + + public void setTabDelegate(final WebExtension webExtension, final TabDelegate delegate) { + if (!mTabDelegateRegistered && delegate != null) { + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:NewTab", + "GeckoView:WebExtension:UpdateTab", + "GeckoView:WebExtension:CloseTab", + "GeckoView:WebExtension:OpenOptionsPage"); + mTabDelegateRegistered = true; + } + + mTabDelegates.put(webExtension.id, delegate); + } + + public TabDelegate getTabDelegate(final WebExtension webExtension) { + return mTabDelegates.get(webExtension.id); + } + + public void setBrowsingDataDelegate( + final WebExtension webExtension, final BrowsingDataDelegate delegate) { + mBrowsingDataDelegates.put(webExtension.id, delegate); + } + + public BrowsingDataDelegate getBrowsingDataDelegate(final WebExtension webExtension) { + return mBrowsingDataDelegates.get(webExtension.id); + } + + public void setActionDelegate( + final WebExtension webExtension, final WebExtension.ActionDelegate delegate) { + if (!mActionDelegateRegistered && delegate != null) { + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:BrowserAction:Update", + "GeckoView:BrowserAction:OpenPopup", + "GeckoView:PageAction:Update", + "GeckoView:PageAction:OpenPopup"); + mActionDelegateRegistered = true; + } + + mActionDelegates.put(webExtension.id, delegate); + } + + public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) { + return mActionDelegates.get(webExtension.id); + } + + public void setMessageDelegate( + final WebExtension webExtension, + final WebExtension.MessageDelegate delegate, + final String nativeApp) { + mMessageDelegates.put(new Sender(webExtension.id, nativeApp), delegate); + + if (runtime != null && delegate != null) { + runtime + .getWebExtensionController() + .releasePendingMessages(webExtension, nativeApp, mSession); + } + } + + public WebExtension.MessageDelegate getMessageDelegate( + final WebExtension webExtension, final String nativeApp) { + return mMessageDelegates.get(new Sender(webExtension.id, nativeApp)); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (runtime == null) { + return; + } + + runtime.getWebExtensionController().handleMessage(event, message, callback, mSession); + } + + public void setDownloadDelegate( + final @NonNull WebExtension extension, final @Nullable DownloadDelegate delegate) { + mDownloadDelegates.put(extension.id, delegate); + } + + public WebExtension.DownloadDelegate getDownloadDelegate(final WebExtension extension) { + return mDownloadDelegates.get(extension.id); + } + } + + /** + * Describes the sender of a message from a WebExtension. + * + *

    See also: + * WebExtensions/API/runtime/MessageSender + */ + @UiThread + public static class MessageSender { + /** {@link WebExtension} that sent this message. */ + public final @NonNull WebExtension webExtension; + + /** + * {@link GeckoSession} that sent this message. null if coming from a background + * script. + */ + public final @Nullable GeckoSession session; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ENV_TYPE_UNKNOWN, ENV_TYPE_EXTENSION, ENV_TYPE_CONTENT_SCRIPT}) + public @interface EnvType {} + + /* package */ static final int ENV_TYPE_UNKNOWN = 0; + + /** This sender originated inside a privileged extension context like a background script. */ + public static final int ENV_TYPE_EXTENSION = 1; + + /** This sender originated inside a content script. */ + public static final int ENV_TYPE_CONTENT_SCRIPT = 2; + + /** + * Type of environment that sent this message, either + * + *

      + *
    • {@link MessageSender#ENV_TYPE_EXTENSION} if the message was sent from a background page + *
    • {@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent from a content + * script + *
    + */ + // 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 DelegateControllerProvider provider, final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + return new WebExtension(provider, bundle.getBundle("extension")); + } + + /** + * Represents either a Browser Action or a Page Action from the WebExtension API. + * + *

    Instances of this class may represent the default Action which applies to all + * WebExtension tabs or a tab-specific override. To reconstruct the full Action + * 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}.
    + * See also + * + *

    + */ + @AnyThread + public static class Action { + /** + * Title of this Action. + * + *

    See also: + * pageAction/getTitle, + * browserAction/getTitle + */ + public final @Nullable String title; + + /** + * Icon for this Action. + * + *

    See also: + * pageAction/setIcon, + * browserAction/setIcon + */ + public final @Nullable Image icon; + + /** + * Whether this action is enabled and should be visible. + * + *

    Note: for page action, this is true when the extension calls + * pageAction.show and false when the extension calls pageAction.hide + * . + * + *

    See also: + * pageAction/show, + * browserAction/enabled + */ + public final @Nullable Boolean enabled; + + /** + * Badge text for this action. + * + *

    See also: + * browserAction/getBadgeText + */ + public final @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: + * browserAction/getBadgeBackgroundColor + */ + public final @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: + * browserAction/getBadgeTextColor + */ + public final @Nullable Integer badgeTextColor; + + private final WebExtension mExtension; + + /* package */ static final int TYPE_BROWSER_ACTION = 1; + /* package */ static final int TYPE_PAGE_ACTION = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION}) + public @interface ActionType {} + + /* package */ final @ActionType int type; + + /* package */ Action( + final @ActionType int type, final GeckoBundle bundle, final WebExtension extension) { + mExtension = extension; + + this.type = type; + + title = bundle.getString("title"); + badgeText = bundle.getString("badgeText"); + badgeBackgroundColor = colorFromRgbaArray(bundle.getDoubleArray("badgeBackgroundColor")); + badgeTextColor = colorFromRgbaArray(bundle.getDoubleArray("badgeTextColor")); + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + + if (bundle.getBoolean("patternMatching", false)) { + // This action was enabled by pattern matching + enabled = true; + } else if (bundle.containsKey("enabled")) { + enabled = bundle.getBoolean("enabled"); + } else { + enabled = null; + } + } + + private Integer colorFromRgbaArray(final double[] c) { + if (c == null) { + return null; + } + + return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]); + } + + @Override + public String toString() { + return "Action {\n" + + "\ttitle: " + + this.title + + ",\n" + + "\ticon: " + + this.icon + + ",\n" + + "\tenabled: " + + this.enabled + + ",\n" + + "\tbadgeText: " + + this.badgeText + + ",\n" + + "\tbadgeTextColor: " + + this.badgeTextColor + + ",\n" + + "\tbadgeBackgroundColor: " + + this.badgeBackgroundColor + + ",\n" + + "}"; + } + + // For testing + protected Action() { + type = TYPE_BROWSER_ACTION; + mExtension = null; + title = null; + icon = null; + enabled = null; + badgeText = null; + badgeTextColor = null; + badgeBackgroundColor = null; + } + + /** + * Merges values from this Action with the default Action. + * + * @param defaultValue the default Action as received from {@link + * ActionDelegate#onBrowserAction} or {@link ActionDelegate#onPageAction}. + * @return an {@link Action} where all null values from this instance are replaced + * with values from defaultValue. + * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action + * is a Page Action and default value is a Browser Action. + */ + @NonNull + public Action withDefault(final @NonNull Action defaultValue) { + return new Action(this, defaultValue); + } + + /** + * @see Action#withDefault + */ + private Action(final Action source, final Action defaultValue) { + if (source.type != defaultValue.type) { + throw new IllegalArgumentException("defaultValue must be of the same type."); + } + + type = source.type; + mExtension = source.mExtension; + + title = source.title != null ? source.title : defaultValue.title; + icon = source.icon != null ? source.icon : defaultValue.icon; + enabled = source.enabled != null ? source.enabled : defaultValue.enabled; + badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText; + badgeTextColor = + source.badgeTextColor != null ? source.badgeTextColor : defaultValue.badgeTextColor; + badgeBackgroundColor = + source.badgeBackgroundColor != null + ? source.badgeBackgroundColor + : defaultValue.badgeBackgroundColor; + } + + /** Notifies the extension that the user has clicked on this Action. */ + @UiThread + public void click() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", mExtension.id); + + // The click event will return the popup uri if we should open a popup in + // response to clicking on the action button. + final GeckoResult popupUri; + if (type == TYPE_BROWSER_ACTION) { + popupUri = + EventDispatcher.getInstance().queryString("GeckoView:BrowserAction:Click", bundle); + } else if (type == TYPE_PAGE_ACTION) { + popupUri = EventDispatcher.getInstance().queryString("GeckoView:PageAction:Click", bundle); + } else { + throw new IllegalStateException("Unknown Action type"); + } + + popupUri.accept( + uri -> { + if (uri == null || uri.isEmpty()) { + return; + } + + final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate(); + if (delegate == null) { + return; + } + + // The .accept method will be called from the UIThread in this case because + // the GeckoResult instance was created on the UIThread + @SuppressLint("WrongThread") + final GeckoResult popup = delegate.onTogglePopup(mExtension, this); + openPopup(popup, uri); + }); + } + + /* package */ void openPopup(final GeckoResult popup, final String popupUri) { + if (popup == null) { + return; + } + + popup.accept( + session -> { + if (session == null) { + return; + } + + session.getSettings().setIsPopup(true); + session.loadUri(popupUri); + }); + } + } + + /** + * Receives updates whenever a Browser action or a Page action has been defined by an extension. + * + *

    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 + * WebExtensions/API/browserAction , + * WebExtensions/manifest.json/browser_action . + * + * @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. null if action is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if session is null. + */ + @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 + * WebExtensions/API/pageAction , + * WebExtensions/manifest.json/page_action . + * + * @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. null if action is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if session is null. + */ + @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 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 onOpenPopup( + final @NonNull WebExtension extension, final @NonNull Action action) { + return null; + } + } + + /** Extension thrown when an error occurs during extension installation. */ + public static class InstallException extends Exception { + public static class ErrorCodes { + /** The download failed due to network problems. */ + public static final int ERROR_NETWORK_FAILURE = -1; + + /** The downloaded file did not match the provided hash. */ + public static final int ERROR_INCORRECT_HASH = -2; + + /** The downloaded file seems to be corrupted in some way. */ + public static final int ERROR_CORRUPT_FILE = -3; + + /** An error occurred trying to write to the filesystem. */ + public static final int ERROR_FILE_ACCESS = -4; + + /** The extension must be signed and isn't. */ + public static final int ERROR_SIGNEDSTATE_REQUIRED = -5; + + /** The downloaded extension had a different type than expected. */ + public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6; + + /** The downloaded extension had a different version than expected */ + public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9; + + /** The extension did not have the expected ID. */ + public static final int ERROR_INCORRECT_ID = -7; + + /** The extension did not have the expected ID. */ + public static final int ERROR_INVALID_DOMAIN = -8; + + /** The extension install was canceled. */ + public static final int ERROR_USER_CANCELED = -100; + + /** The extension install was postponed until restart. */ + public static final int ERROR_POSTPONED = -101; + + /** For testing. */ + protected ErrorCodes() {} + } + + /** These states should match gecko's AddonManager.STATE_* constants. */ + private static class StateCodes { + public static final int STATE_POSTPONED = 7; + public static final int STATE_CANCELED = 12; + } + + /* package */ static Throwable fromQueryException(final Throwable exception) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) exception; + final Object response = queryException.data; + if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) { + final GeckoBundle bundle = (GeckoBundle) response; + int errorCode = bundle.getInt("installError"); + final int installState = bundle.getInt("state"); + if (errorCode == 0 && installState == StateCodes.STATE_CANCELED) { + errorCode = ErrorCodes.ERROR_USER_CANCELED; + } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) { + errorCode = ErrorCodes.ERROR_POSTPONED; + } + return new WebExtension.InstallException(errorCode); + } else { + return new Exception(response.toString()); + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ErrorCodes.ERROR_NETWORK_FAILURE, + ErrorCodes.ERROR_INCORRECT_HASH, + ErrorCodes.ERROR_CORRUPT_FILE, + ErrorCodes.ERROR_FILE_ACCESS, + ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE, + ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION, + ErrorCodes.ERROR_INCORRECT_ID, + ErrorCodes.ERROR_INVALID_DOMAIN, + ErrorCodes.ERROR_USER_CANCELED, + ErrorCodes.ERROR_POSTPONED, + }) + public @interface Codes {} + + /** One of {@link ErrorCodes} that provides more information about this exception. */ + public final @Codes int code; + + /** For testing */ + protected InstallException() { + this.code = ErrorCodes.ERROR_NETWORK_FAILURE; + } + + @Override + public String toString() { + return "InstallException: " + code; + } + + /* package */ InstallException(final @Codes int code) { + this.code = code; + } + } + + /** + * Set the Action delegate for this WebExtension. + * + *

    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) { + 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 Add-on signing + * in Firefox. + */ + public static class SignedStateFlags { + // Keep in sync with AddonManager.jsm + /** + * This extension may be signed but by a certificate that doesn't chain to our our trusted + * certificate. + */ + public static final int UNKNOWN = -1; + + /** This extension is unsigned. */ + public static final int MISSING = 0; + + /** This extension has been preliminarily reviewed. */ + public static final int PRELIMINARY = 1; + + /** This extension has been fully reviewed. */ + public static final int SIGNED = 2; + + /** This extension is a system add-on. */ + public static final int SYSTEM = 3; + + /** This extension is signed with a "Mozilla Extensions" certificate. */ + public static final int PRIVILEGED = 4; + + /* package */ static final int LAST = PRIVILEGED; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SignedStateFlags.UNKNOWN, + SignedStateFlags.MISSING, + SignedStateFlags.PRELIMINARY, + SignedStateFlags.SIGNED, + SignedStateFlags.SYSTEM, + SignedStateFlags.PRIVILEGED + }) + public @interface SignedState {} + + /** + * Describes the blocklist state for a {@link WebExtension}. See Add-ons that + * cause stability or security issues are put on a blocklist . + */ + public static class BlocklistStateFlags { + // Keep in sync with nsIBlocklistService.idl + /** This extension does not appear in the blocklist. */ + public static final int NOT_BLOCKED = 0; + + /** + * This extension is in the blocklist but the problem is not severe enough to warant forcibly + * blocking. + */ + public static final int SOFTBLOCKED = 1; + + /** This extension should be blocked and never used. */ + public static final int BLOCKED = 2; + + /** This extension is considered outdated, and there is a known update available. */ + public static final int OUTDATED = 3; + + /** This extension is vulnerable and there is an update. */ + public static final int VULNERABLE_UPDATE_AVAILABLE = 4; + + /** This extension is vulnerable and there is no update. */ + public static final int VULNERABLE_NO_UPDATE = 5; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + BlocklistStateFlags.NOT_BLOCKED, + BlocklistStateFlags.SOFTBLOCKED, + BlocklistStateFlags.BLOCKED, + BlocklistStateFlags.OUTDATED, + BlocklistStateFlags.VULNERABLE_UPDATE_AVAILABLE, + BlocklistStateFlags.VULNERABLE_NO_UPDATE + }) + public @interface BlocklistState {} + + public static class DisabledFlags { + /** The extension has been disabled by the user */ + public static final int USER = 1 << 1; + + /** + * The extension has been disabled by the blocklist. The details of why this extension was + * blocked can be found in {@link MetaData#blocklistState}. + */ + public static final int BLOCKLIST = 1 << 2; + + /** + * The extension has been disabled by the application. To enable the extension you can use + * {@link WebExtensionController#enable} passing in {@link + * WebExtensionController.EnableSource#APP} as source. + */ + public static final int APP = 1 << 3; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP}) + public @interface EnabledFlags {} + + /** Provides information about a {@link WebExtension}. */ + public class MetaData { + /** + * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying + * prompts. + */ + public final @NonNull Image icon; + + /** + * API permissions requested or granted to this extension. + * + *

    Permission identifiers match entries in the manifest, see + * API permissions . + */ + public final @NonNull String[] permissions; + + /** + * Host permissions requested or granted to this extension. + * + *

    See + * Host permissions . + */ + public final @NonNull String[] origins; + + /** + * Branding name for this extension. + * + *

    See + * manifest.json/name + */ + public final @Nullable String name; + + /** + * Branding description for this extension. This string will be localized using the current + * GeckoView language setting. + * + *

    See + * manifest.json/description + */ + public final @Nullable String description; + + /** + * Version string for this extension. + * + *

    See + * manifest.json/version + */ + public final @NonNull String version; + + /** + * Creator name as provided in the manifest. + * + *

    See + * manifest.json/developer + */ + public final @Nullable String creatorName; + + /** + * Creator url as provided in the manifest. + * + *

    See + * manifest.json/developer + */ + public final @Nullable String creatorUrl; + + /** + * Homepage url as provided in the manifest. + * + *

    See + * manifest.json/homepage_url + */ + public final @Nullable String homepageUrl; + + /** + * Options page as provided in the manifest. + * + *

    See + * manifest.json/options_ui + */ + public final @Nullable String optionsPageUrl; + + /** + * Whether the options page should be open in a Tab or not. + * + *

    See + * manifest.json/options_ui#Syntax + */ + public final boolean openOptionsPageInTab; + + /** + * Whether or not this is a recommended extension. + * + *

    See Recommended + * Extensions program + */ + public final boolean isRecommended; + + /** + * Blocklist status for this extension. + * + *

    See + * Add-ons that cause stability or security issues are put on a blocklist . + */ + public final @BlocklistState int blocklistState; + + /** + * Signed status for this extension. + * + *

    See Add-on + * signing in Firefox. . + */ + public final @SignedState int signedState; + + /** + * Disabled binary flags for this extension. + * + *

    This will be either equal to 0 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 1: + * + *

    
    +     *     boolean isUserDisabled = metaData.disabledFlags
    +     *          & DisabledFlags.USER > 0;
    +     * 
    + */ + public final @EnabledFlags int disabledFlags; + + /** + * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this + * extension. + */ + public final @NonNull String baseUrl; + + /** + * Whether this extension is allowed to run in private browsing or not. To modify this value use + * {@link WebExtensionController#setAllowedInPrivateBrowsing}. + */ + public final boolean allowedInPrivateBrowsing; + + /** Whether this extension is enabled or not. */ + public final boolean enabled; + + /** + * Whether this extension is temporary or not. Temporary extensions are not retained and will be + * uninstalled when the browser exits. + */ + public final boolean temporary; + + /** Override for testing. */ + protected MetaData() { + icon = null; + permissions = null; + origins = null; + name = null; + description = null; + version = null; + creatorName = null; + creatorUrl = null; + homepageUrl = null; + optionsPageUrl = null; + openOptionsPageInTab = false; + isRecommended = false; + blocklistState = BlocklistStateFlags.NOT_BLOCKED; + signedState = SignedStateFlags.UNKNOWN; + disabledFlags = 0; + enabled = true; + temporary = false; + baseUrl = null; + allowedInPrivateBrowsing = false; + } + + /* package */ MetaData(final GeckoBundle bundle) { + // We only expose permissions that the embedder should prompt for + permissions = bundle.getStringArray("promptPermissions"); + origins = bundle.getStringArray("origins"); + description = bundle.getString("description"); + version = bundle.getString("version"); + creatorName = bundle.getString("creatorName"); + creatorUrl = bundle.getString("creatorURL"); + homepageUrl = bundle.getString("homepageURL"); + name = bundle.getString("name"); + optionsPageUrl = bundle.getString("optionsPageURL"); + openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab"); + isRecommended = bundle.getBoolean("isRecommended"); + blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED); + enabled = bundle.getBoolean("enabled", false); + temporary = bundle.getBoolean("temporary", false); + baseUrl = bundle.getString("baseURL"); + allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false); + + final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN); + if (signedState <= SignedStateFlags.LAST) { + this.signedState = signedState; + } else { + Log.e(LOGTAG, "Unrecognized signed state: " + signedState); + this.signedState = SignedStateFlags.UNKNOWN; + } + + int disabledFlags = 0; + final String[] disabledFlagsString = bundle.getStringArray("disabledFlags"); + + for (final String flag : disabledFlagsString) { + if (flag.equals("userDisabled")) { + disabledFlags |= DisabledFlags.USER; + } else if (flag.equals("blocklistDisabled")) { + disabledFlags |= DisabledFlags.BLOCKLIST; + } else if (flag.equals("appDisabled")) { + disabledFlags |= DisabledFlags.APP; + } else { + Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag); + } + } + this.disabledFlags = disabledFlags; + + if (bundle.containsKey("icons")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icons")); + } else { + icon = null; + } + } + } + + // TODO: make public bug 1595822 + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Context.NONE, + Context.BOOKMARK, + Context.BROWSER_ACTION, + Context.PAGE_ACTION, + Context.TAB, + Context.TOOLS_MENU + }) + public @interface ContextFlags {} + + /** + * Flags to determine which contexts a menu item should be shown in. See + * menus.ContextType. + */ + 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 menus + * 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 items; + + /** Icon for this extension. */ + final @Nullable Image icon; + + /** Title for the menu header. */ + final @Nullable String title; + + /** The extension adding this Menu to the context menu. */ + final @NonNull WebExtension extension; + + /* package */ Menu(final @NonNull WebExtension extension, final GeckoBundle bundle) { + this.extension = extension; + title = bundle.getString("title", ""); + final GeckoBundle[] items = bundle.getBundleArray("items"); + this.items = new ArrayList<>(); + if (items != null) { + for (final GeckoBundle item : items) { + this.items.add(new MenuItem(this.extension, item)); + } + } + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + } + + /** Notifies the extension that a user has opened the context menu. */ + void show() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", extension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuShow", bundle); + } + + /** Notifies the extension that a user has hidden the context menu. */ + void hide() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", extension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuHide", bundle); + } + } + + // TODO: make public bug 1595822 + /** + * Represents an item in the menu. + * + *

    If there is only one menu item in the list, the embedder should display that item as itself, + * not under a header. + */ + static class MenuItem { + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = false, + value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR}) + public @interface Type {} + + /** A set of constants that represents the display type of this menu item. */ + static class MenuType { + /** + * This represents a menu item that just displays a label. + * + *

    See + * menus.ItemType.normal + */ + static final int NORMAL = 0; + + /** + * This represents a menu item that can be selected and deselected. + * + *

    See + * menus.ItemType.checkbox + */ + 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. + * + *

    See + * menus.ItemType.radio + */ + static final int RADIO = 2; + + /** + * This represents a line separating elements. + * + *

    See + * menus.ItemType.separator + */ + static final int SEPARATOR = 3; + } + + /** + * Direct children for this menu item. These should be displayed as a sub-menu. + * + *

    See + * createProperties.parentId + */ + final @Nullable List children; + + /** One of the {@link Type} constants. Determines the type of the action. */ + final @Type int type; + + /** + * The id of this menu item. See + * createProperties.id + */ + final @Nullable String id; + + /** Determines if the menu item should be currently displayed. */ + final boolean visible; + + /** The title to be displayed for this menu item. */ + final @Nullable String title; + + /** Whether or not the menu item is initially checked. Defaults to false. */ + final boolean checked; + + /** Contexts that this menu item should be shown in. */ + final @ContextFlags int contexts; + + /** Icon for this menu item. */ + final @Nullable Image icon; + + final WebExtension mExtension; + + /** + * Creates a new menu item using a bundle and a reference to the extension that this item + * belongs to. + * + * @param extension WebExtension object. + * @param bundle GeckoBundle containing the item information. + */ + /* package */ MenuItem(final WebExtension extension, final GeckoBundle bundle) { + title = bundle.getString("title"); + mExtension = extension; + checked = bundle.getBoolean("checked", false); + visible = bundle.getBoolean("visible", true); + id = bundle.getString("id"); + contexts = bundle.getInt("contexts"); + type = bundle.getInt("type"); + children = new ArrayList<>(); + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + } + + /** Notifies the extension that the user has clicked on this menu item. */ + void click() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("menuId", this.id); + bundle.putString("extensionId", mExtension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuClick", bundle); + } + } + + public interface DownloadDelegate { + /** + * Method that is called when Web Extension requests a download (when downloads.download() is + * called in Web Extension) + * + * @param source - Web Extension that requested the download + * @param request - contains the {@link WebRequest} and additional parameters for the request + * @return {@link DownloadInitData} instance + */ + @AnyThread + @Nullable + default GeckoResult onDownload( + @NonNull final WebExtension source, @NonNull final DownloadRequest request) { + return null; + } + } + + /** + * Set the download delegate for this extension. This delegate will be invoked whenever this + * extension tries to use the `downloads` WebExtension API. + * + *

    See also WebExtensions/API/downloads. + * + * @param delegate the {@link DownloadDelegate} instance for this extension. + */ + @UiThread + public void setDownloadDelegate(final @Nullable DownloadDelegate delegate) { + mDelegateController.onDownloadDelegate(delegate); + } + + /** + * Get the download delegate for this extension. + * + *

    See also WebExtensions + * downloads API. + * + * @return The {@link DownloadDelegate} instance for this extension. + */ + @UiThread + @Nullable + public DownloadDelegate getDownloadDelegate() { + return mDelegateController.getDownloadDelegate(); + } + + /** + * Represents a download for downloads + * API Instantiate using {@link WebExtensionController#createDownload} + */ + public static class Download { + /** + * Represents a unique identifier for the downloaded item that is persistent across browser + * sessions + */ + public final int id; + + /** + * For testing. + * + * @param id - integer id for the download item + */ + protected Download(final int id) { + this.id = id; + } + + /* package */ void setDelegate(final Delegate delegate) {} + + /** + * Updates the download state. This will trigger a call to downloads.onChanged + * event to the corresponding `DownloadItem` on the extension side. + * + * @param data - current metadata associated with the download. {@link Download.Info} + * implementation instance + * @return GeckoResult with nothing or error inside + */ + @Nullable + @UiThread + public GeckoResult update(final @NonNull Download.Info data) { + final GeckoBundle bundle = new GeckoBundle(12); + + bundle.putInt("downloadItemId", this.id); + + bundle.putString("filename", data.filename()); + bundle.putString("mime", data.mime()); + bundle.putString("startTime", String.valueOf(data.startTime())); + bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime())); + bundle.putInt("state", data.state()); + bundle.putBoolean("canResume", data.canResume()); + bundle.putBoolean("paused", data.paused()); + final Integer error = data.error(); + if (error != null) { + bundle.putInt("error", error); + } + bundle.putLong("totalBytes", data.totalBytes()); + bundle.putLong("fileSize", data.fileSize()); + bundle.putBoolean("exists", data.fileExists()); + + return EventDispatcher.getInstance() + .queryVoid("GeckoView:WebExtension:DownloadChanged", bundle) + .map( + null, + e -> { + if (e instanceof EventDispatcher.QueryException) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) e; + if (queryException.data instanceof String) { + return new IllegalArgumentException((String) queryException.data); + } + } + return e; + }); + } + + /* package */ interface Delegate { + + default GeckoResult onPause( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult onResume( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult onCancel( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult onErase( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult onOpen( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult onRemoveFile( + final WebExtension source, final WebExtension.Download download) { + return null; + } + } + + /** + * Represents a download in progress where the app is currently receiving data from the server. + * See also {@link Info#state()}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_IN_PROGRESS, STATE_INTERRUPTED, STATE_COMPLETE}) + public @interface DownloadState {} + + /** Download is in progress. Default state */ + public static final int STATE_IN_PROGRESS = 0; + + /** An error broke the connection with the server. */ + public static final int STATE_INTERRUPTED = 1; + + /** The download completed successfully. */ + public static final int STATE_COMPLETE = 2; + + /** + * Represents a possible reason why a download was interrupted. See also {@link Info#error()}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + INTERRUPT_REASON_NO_INTERRUPT, + INTERRUPT_REASON_FILE_FAILED, + INTERRUPT_REASON_FILE_ACCESS_DENIED, + INTERRUPT_REASON_FILE_NO_SPACE, + INTERRUPT_REASON_FILE_NAME_TOO_LONG, + INTERRUPT_REASON_FILE_TOO_LARGE, + INTERRUPT_REASON_FILE_VIRUS_INFECTED, + INTERRUPT_REASON_FILE_TRANSIENT_ERROR, + INTERRUPT_REASON_FILE_BLOCKED, + INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED, + INTERRUPT_REASON_FILE_TOO_SHORT, + INTERRUPT_REASON_NETWORK_FAILED, + INTERRUPT_REASON_NETWORK_TIMEOUT, + INTERRUPT_REASON_NETWORK_DISCONNECTED, + INTERRUPT_REASON_NETWORK_SERVER_DOWN, + INTERRUPT_REASON_NETWORK_INVALID_REQUEST, + INTERRUPT_REASON_SERVER_FAILED, + INTERRUPT_REASON_SERVER_NO_RANGE, + INTERRUPT_REASON_SERVER_BAD_CONTENT, + INTERRUPT_REASON_SERVER_UNAUTHORIZED, + INTERRUPT_REASON_SERVER_CERT_PROBLEM, + INTERRUPT_REASON_SERVER_FORBIDDEN, + INTERRUPT_REASON_USER_CANCELED, + INTERRUPT_REASON_USER_SHUTDOWN, + INTERRUPT_REASON_CRASH + }) + public @interface DownloadInterruptReason {} + + // File-related errors + public static final int INTERRUPT_REASON_NO_INTERRUPT = 0; + public static final int INTERRUPT_REASON_FILE_FAILED = 1; + public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2; + public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3; + public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4; + public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5; + public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6; + public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7; + public static final int INTERRUPT_REASON_FILE_BLOCKED = 8; + public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9; + public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10; + // Network-related errors + public static final int INTERRUPT_REASON_NETWORK_FAILED = 11; + public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12; + public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13; + public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14; + public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15; + // Server-related errors + public static final int INTERRUPT_REASON_SERVER_FAILED = 16; + public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17; + public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18; + public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19; + public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20; + public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21; + // User-related errors + public static final int INTERRUPT_REASON_USER_CANCELED = 22; + public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23; + // Miscellaneous + public static final int INTERRUPT_REASON_CRASH = 24; + + /** + * Interface for communicating the state of downloads to Web Extensions. See also WebExtensions/API/downloads/DownloadItem + */ + public interface Info { + + /** + * @return A number representing the number of bytes received so far from the host during the + * download This does not take file compression into consideration + */ + @UiThread + default long bytesReceived() { + return 0; + } + + /** + * @return boolean indicating whether a currently-interrupted (e.g. paused) download can be + * resumed from the point where it was interrupted + */ + @UiThread + default boolean canResume() { + return false; + } + + /** + * @return A number representing the time when this download ended. This is null if the + * download has not yet finished. + */ + @Nullable + @UiThread + default Long endTime() { + return null; + } + + /** + * @return One of Interrupt + * Reason constants denoting the error reason. + */ + @Nullable + @UiThread + default @DownloadInterruptReason Integer error() { + return null; + } + + /** + * @return the estimated number of milliseconds between the UNIX epoch and when this download + * is estimated to be completed. This is null if it is not known. + */ + @Nullable + @UiThread + default Long estimatedEndTime() { + return null; + } + + /** + * @return boolean indicating whether a downloaded file still exists + */ + @UiThread + default boolean fileExists() { + return false; + } + + /** + * @return the filename. + */ + @NonNull + @UiThread + default String filename() { + return ""; + } + + /** + * @return the total number of bytes in the whole file, after decompression. A value of -1 + * means that the total file size is unknown. + */ + @UiThread + default long fileSize() { + return -1; + } + + /** + * @return the downloaded file's MIME type + */ + @NonNull + @UiThread + default String mime() { + return ""; + } + + /** + * @return boolean indicating whether the download is paused i.e. if the download has stopped + * reading data from the host but has kept the connection open + */ + @UiThread + default boolean paused() { + return false; + } + + /** + * @return String representing the downloaded file's referrer + */ + @NonNull + @UiThread + default String referrer() { + return ""; + } + + /** + * @return the number of milliseconds between the UNIX epoch and when this download began + */ + @UiThread + default long startTime() { + return -1; + } + + /** + * @return a new state; one of the state constants to indicate whether the download is in + * progress, interrupted or complete + */ + @UiThread + default @DownloadState int state() { + return STATE_IN_PROGRESS; + } + + /** + * @return total number of bytes in the file being downloaded. This does not take file + * compression into consideration. A value of -1 here means that the total number of bytes + * is unknown + */ + @UiThread + default long totalBytes() { + return -1; + } + } + + @NonNull + /* package */ static GeckoBundle downloadInfoToBundle(final @NonNull Info data) { + final GeckoBundle dataBundle = new GeckoBundle(); + + dataBundle.putLong("bytesReceived", data.bytesReceived()); + dataBundle.putBoolean("canResume", data.canResume()); + dataBundle.putBoolean("exists", data.fileExists()); + dataBundle.putString("filename", data.filename()); + dataBundle.putLong("fileSize", data.fileSize()); + dataBundle.putString("mime", data.mime()); + dataBundle.putBoolean("paused", data.paused()); + dataBundle.putString("referrer", data.referrer()); + dataBundle.putString("startTime", String.valueOf(data.startTime())); + dataBundle.putInt("state", data.state()); + dataBundle.putLong("totalBytes", data.totalBytes()); + + final Long endTime = data.endTime(); + if (endTime != null) { + dataBundle.putString("endTime", endTime.toString()); + } + final Integer error = data.error(); + if (error != null) { + dataBundle.putInt("error", error); + } + final Long estimatedEndTime = data.estimatedEndTime(); + if (estimatedEndTime != null) { + dataBundle.putString("estimatedEndTime", estimatedEndTime.toString()); + } + + return dataBundle; + } + } + + /** Represents Web Extension API specific download request */ + public static class DownloadRequest { + /** Regular GeckoView {@link WebRequest} object */ + public final @NonNull WebRequest request; + + /** Optional fetch flags for {@link GeckoWebExecutor} */ + public final @GeckoWebExecutor.FetchFlags int downloadFlags; + + /** A file path relative to the default downloads directory */ + public final @Nullable String filename; + + /** + * The action you want taken if there is a filename conflict, as defined here + */ + public final @ConflictActionFlags int conflictActionFlag; + + /** + * Specifies whether to provide a file chooser dialog to allow the user to select a filename + * (true), or not (false) + */ + public final boolean saveAs; + + /** + * Flag that enables downloads to continue even if they encounter HTTP errors. When false, the + * download is canceled when it encounters an HTTP error. When true, the download continues when + * an HTTP error is encountered and the HTTP server error is not reported. However, if the + * download fails due to file-related, network-related, user-related, or other error, that error + * is reported. + */ + public final boolean allowHttpErrors; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT}) + public @interface ConflictActionFlags {} + + /** The app should modify the filename to make it unique */ + public static final int CONFLICT_ACTION_UNIQUIFY = 0; + + /** The app should overwrite the old file with the newly-downloaded file */ + public static final int CONFLICT_ACTION_OVERWRITE = 1; + + /** The app should prompt the user, asking them to choose whether to uniquify or overwrite */ + public static final int CONFLICT_ACTION_PROMPT = 1 << 1; + + protected DownloadRequest(final DownloadRequest.Builder builder) { + this.request = builder.mRequest; + this.downloadFlags = builder.mDownloadFlags; + this.filename = builder.mFilename; + this.conflictActionFlag = builder.mConflictActionFlag; + this.saveAs = builder.mSaveAs; + this.allowHttpErrors = builder.mAllowHttpErrors; + } + + /** + * Convenience method to convert a GeckoBundle to a DownloadRequest. + * + * @param optionsBundle - in the shape of the options object browser.downloads.download() + * accepts + * @return request - a DownloadRequest instance + */ + /* package */ static DownloadRequest fromBundle(final GeckoBundle optionsBundle) { + final String uri = optionsBundle.getString("url"); + + final WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri); + + final String method = optionsBundle.getString("method"); + if (method != null) { + mainRequestBuilder.method(method); + + if (method.equals("POST")) { + final String body = optionsBundle.getString("body"); + mainRequestBuilder.body(body); + } + } + + final GeckoBundle[] headers = optionsBundle.getBundleArray("headers"); + if (headers != null) { + for (final GeckoBundle header : headers) { + String value = header.getString("value"); + if (value == null) { + value = header.getString("binaryValue"); + } + mainRequestBuilder.addHeader(header.getString("name"), value); + } + } + + final WebRequest mainRequest = mainRequestBuilder.build(); + + int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE; + final boolean incognito = optionsBundle.getBoolean("incognito"); + if (incognito) { + downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE; + } + + final boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors"); + + int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY; + final String conflictActionString = optionsBundle.getString("conflictAction"); + if (conflictActionString != null) { + switch (conflictActionString.toLowerCase(Locale.ROOT)) { + case "overwrite": + conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_OVERWRITE; + break; + case "prompt": + conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_PROMPT; + break; + } + } + + final boolean saveAs = optionsBundle.getBoolean("saveAs"); + + final WebExtension.DownloadRequest request = + new WebExtension.DownloadRequest.Builder(mainRequest) + .filename(optionsBundle.getString("filename")) + .downloadFlags(downloadFlags) + .conflictAction(conflictActionFlags) + .saveAs(saveAs) + .allowHttpErrors(allowHttpErrors) + .build(); + + return request; + } + + /* package */ static class Builder { + private final WebRequest mRequest; + private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0; + private String mFilename = null; + private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY; + private boolean mSaveAs = false; + private boolean mAllowHttpErrors = false; + + /* package */ Builder(final WebRequest request) { + this.mRequest = request; + } + + /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) { + this.mDownloadFlags = flags; + return this; + } + + /* package */ Builder filename(final String filename) { + this.mFilename = filename; + return this; + } + + /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) { + this.mConflictActionFlag = conflictActionFlag; + return this; + } + + /* package */ Builder saveAs(final boolean saveAs) { + this.mSaveAs = saveAs; + return this; + } + + /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) { + this.mAllowHttpErrors = allowHttpErrors; + return this; + } + + /* package */ DownloadRequest build() { + return new DownloadRequest(this); + } + } + } + + /** Represents initial information on a download provided to Web Extension */ + public static class DownloadInitData { + @NonNull public final WebExtension.Download download; + @NonNull public final Download.Info initData; + + public DownloadInitData(final Download download, final Download.Info initData) { + this.download = download; + this.initData = initData; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java new file mode 100644 index 0000000000..9e55b79a0e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java @@ -0,0 +1,1577 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.util.Log; +import android.util.SparseArray; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.json.JSONException; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.MultiMap; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +public class WebExtensionController { + private static final String LOGTAG = "WebExtension"; + + private AddonManagerDelegate mAddonManagerDelegate; + private DebuggerDelegate mDebuggerDelegate; + private PromptDelegate mPromptDelegate; + private final WebExtension.Listener mListener; + + // Map [ (extensionId, nativeApp, session) -> message ] + private final MultiMap mPendingMessages; + private final MultiMap mPendingNewTab; + private final MultiMap mPendingBrowsingData; + private final MultiMap mPendingDownload; + + private final SparseArray mDownloads; + + private static class Message { + final GeckoBundle bundle; + final EventCallback callback; + final String event; + final GeckoSession session; + + public Message( + final String event, + final GeckoBundle bundle, + final EventCallback callback, + final GeckoSession session) { + this.bundle = bundle; + this.callback = callback; + this.event = event; + this.session = session; + } + } + + private static class ExtensionStore { + private final Map mData = new HashMap<>(); + private Observer mObserver; + + interface Observer { + /** + * * This event is fired every time a new extension object is created by the store. + * + * @param extension the newly-created extension object + */ + WebExtension onNewExtension(final GeckoBundle extension); + } + + public GeckoResult 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 pending = + EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Get", bundle) + .map( + extensionBundle -> { + final WebExtension ext = mObserver.onNewExtension(extensionBundle); + mData.put(ext.id, ext); + return ext; + }); + + return pending; + } + + public void setObserver(final Observer observer) { + mObserver = observer; + } + + public void remove(final String id) { + mData.remove(id); + } + + /** + * Add this extension to the store and update it's current value if it's already present. + * + * @param id the {@link WebExtension} id. + * @param extension the {@link WebExtension} to add to the store. + */ + public void update(final String id, final WebExtension extension) { + mData.put(id, extension); + } + } + + private ExtensionStore mExtensions = new ExtensionStore(); + + private Internals mInternals = new Internals(); + + // Avoids exposing listeners to the API + private class Internals implements BundleEventListener, ExtensionStore.Observer { + @Override + // BundleEventListener + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + WebExtensionController.this.handleMessage(event, message, callback, null); + } + + @Override + public WebExtension onNewExtension(final GeckoBundle bundle) { + return WebExtension.fromBundle(mDelegateControllerProvider, bundle); + } + } + + /* package */ void releasePendingMessages( + final WebExtension extension, final String nativeApp, final GeckoSession session) { + Log.i( + LOGTAG, + "releasePendingMessages:" + + " extension=" + + extension.id + + " nativeApp=" + + nativeApp + + " session=" + + session); + final List messages = + mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session)); + if (messages == null) { + return; + } + + for (final Message message : messages) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + } + + private class DelegateController implements WebExtension.DelegateController { + private final WebExtension mExtension; + + public DelegateController(final WebExtension extension) { + mExtension = extension; + } + + @Override + public void onMessageDelegate( + final String nativeApp, final WebExtension.MessageDelegate delegate) { + mListener.setMessageDelegate(mExtension, delegate, nativeApp); + } + + @Override + public void onActionDelegate(final WebExtension.ActionDelegate delegate) { + mListener.setActionDelegate(mExtension, delegate); + } + + @Override + public WebExtension.ActionDelegate getActionDelegate() { + return mListener.getActionDelegate(mExtension); + } + + @Override + public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) { + mListener.setBrowsingDataDelegate(mExtension, delegate); + + for (final Message message : mPendingBrowsingData.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingBrowsingData.remove(mExtension.id); + } + + @Override + public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() { + return mListener.getBrowsingDataDelegate(mExtension); + } + + @Override + public void onTabDelegate(final WebExtension.TabDelegate delegate) { + mListener.setTabDelegate(mExtension, delegate); + + for (final Message message : mPendingNewTab.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingNewTab.remove(mExtension.id); + } + + @Override + public WebExtension.TabDelegate getTabDelegate() { + return mListener.getTabDelegate(mExtension); + } + + @Override + public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) { + mListener.setDownloadDelegate(mExtension, delegate); + + for (final Message message : mPendingDownload.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingDownload.remove(mExtension.id); + } + + @Override + public WebExtension.DownloadDelegate getDownloadDelegate() { + return mListener.getDownloadDelegate(mExtension); + } + } + + final WebExtension.DelegateControllerProvider mDelegateControllerProvider = + new WebExtension.DelegateControllerProvider() { + @Override + public WebExtension.DelegateController controllerFor(final WebExtension extension) { + return new DelegateController(extension); + } + }; + + /** + * This delegate will be called whenever an extension is about to be installed or it needs new + * permissions, e.g during an update or because it called permissions.request + */ + @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 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 onUpdatePrompt( + @NonNull final WebExtension currentlyInstalled, + @NonNull final WebExtension updatedExtension, + @NonNull final String[] newPermissions, + @NonNull final String[] newOrigins) { + return null; + } + + /** + * Called whenever permissions are requested. This is intended as an opportunity for the app to + * prompt the user for the permissions required by this extension at runtime. + * + * @param extension The {@link WebExtension} that is about to be installed. You can use {@link + * WebExtension#metaData} to gather information about this extension when building the user + * prompt dialog. + * @param permissions The permissions that are requested. + * @param origins The requested host permissions. + * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the + * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be + * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}. + */ + @Nullable + default GeckoResult onOptionalPrompt( + final @NonNull WebExtension extension, + final @NonNull String[] permissions, + final @NonNull String[] origins) { + return null; + } + } + + public interface DebuggerDelegate { + /** + * Called whenever the list of installed extensions has been modified using the debugger with + * tools like web-ext. + * + *

    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 + * Getting started with web-ext + */ + @UiThread + default void onExtensionListUpdated() {} + } + + /** This delegate will be called whenever the state of an extension has changed. */ + public interface AddonManagerDelegate { + /** + * Called whenever an extension is being disabled. + * + * @param extension The {@link WebExtension} that is being disabled. + */ + @UiThread + default void onDisabling(@NonNull WebExtension extension) {} + + /** + * Called whenever an extension has been disabled. + * + * @param extension The {@link WebExtension} that is being disabled. + */ + @UiThread + default void onDisabled(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension is being enabled. + * + * @param extension The {@link WebExtension} that is being enabled. + */ + @UiThread + default void onEnabling(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension has been enabled. + * + * @param extension The {@link WebExtension} that is being enabled. + */ + @UiThread + default void onEnabled(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension is being uninstalled. + * + * @param extension The {@link WebExtension} that is being uninstalled. + */ + @UiThread + default void onUninstalling(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension has been uninstalled. + * + * @param extension The {@link WebExtension} that is being uninstalled. + */ + @UiThread + default void onUninstalled(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension is being installed. + * + * @param extension The {@link WebExtension} that is being installed. + */ + @UiThread + default void onInstalling(final @NonNull WebExtension extension) {} + + /** + * Called whenever an extension has been installed. + * + * @param extension The {@link WebExtension} that is being installed. + */ + @UiThread + default void onInstalled(final @NonNull WebExtension extension) {} + } + + /** + * @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; + } + + /** + * Set the {@link AddonManagerDelegate} for this instance. This delegate will be used to be + * notified whenever the state of an extension has changed. + * + * @param delegate the delegate instance + * @see AddonManagerDelegate + */ + @UiThread + public void setAddonManagerDelegate(final @Nullable AddonManagerDelegate delegate) { + if (delegate == null && mAddonManagerDelegate != null) { + EventDispatcher.getInstance() + .unregisterUiThreadListener( + mInternals, + "GeckoView:WebExtension:OnDisabling", + "GeckoView:WebExtension:OnDisabled", + "GeckoView:WebExtension:OnEnabling", + "GeckoView:WebExtension:OnEnabled", + "GeckoView:WebExtension:OnUninstalling", + "GeckoView:WebExtension:OnUninstalled", + "GeckoView:WebExtension:OnInstalling", + "GeckoView:WebExtension:OnInstalled"); + } else if (delegate != null && mAddonManagerDelegate == null) { + EventDispatcher.getInstance() + .registerUiThreadListener( + mInternals, + "GeckoView:WebExtension:OnDisabling", + "GeckoView:WebExtension:OnDisabled", + "GeckoView:WebExtension:OnEnabling", + "GeckoView:WebExtension:OnEnabled", + "GeckoView:WebExtension:OnUninstalling", + "GeckoView:WebExtension:OnUninstalled", + "GeckoView:WebExtension:OnInstalling", + "GeckoView:WebExtension:OnInstalled"); + } + + mAddonManagerDelegate = delegate; + } + + private static class InstallCanceller implements GeckoResult.CancellationDelegate { + public final String installId; + + public InstallCanceller() { + installId = UUID.randomUUID().toString(); + } + + @Override + public GeckoResult 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 + * Distributing your add-on . + * + *

    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 .xpi package. This can be a remote https: + * URI or a local file: or resource: 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 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 result = + EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Install", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + result.setCancellationDelegate(canceller); + return result; + } + + /** + * Set whether an extension should be allowed to run in private browsing or not. + * + * @param extension the {@link WebExtension} instance to modify. + * @param allowed true if this extension should be allowed to run in private browsing pages, false + * otherwise. + * @return the updated {@link WebExtension} instance. + */ + @NonNull + @AnyThread + public GeckoResult setAllowedInPrivateBrowsing( + final @NonNull WebExtension extension, final boolean allowed) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("extensionId", extension.id); + bundle.putBoolean("allowed", allowed); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + /** + * Install a built-in extension. + * + *

    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: + * + *

    + * controller.installBuiltIn("resource://android/assets/example/"); + * Will install the built-in extension located at /assets/example/ in the + * app's APK. + * + * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only + * resource://android URIs are allowed. + * @see WebExtension.MessageDelegate + * @return A {@link GeckoResult} that completes with the extension once it's installed. + */ + @NonNull + @AnyThread + public GeckoResult installBuiltIn(final @NonNull String uri) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("locationUri", uri); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /** + * Ensure that a built-in extension is installed. + * + *

    Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already + * present and it has the same version. + * + *

    Example: + * + *

    + * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com"); + * Will install the built-in extension located at /assets/example/ in the + * app's APK. + * + * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only + * resource://android 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 ensureBuiltIn( + final @NonNull String uri, final @Nullable String id) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("locationUri", uri); + bundle.putString("webExtensionId", id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /** + * Uninstall an extension. + * + *

    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 uninstall(final @NonNull WebExtension extension) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("webExtensionId", extension.id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Uninstall", bundle) + .accept(result -> unregisterWebExtension(extension)); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({EnableSource.USER, EnableSource.APP}) + public @interface EnableSources {} + + /** + * Contains the possible values for the source parameter in {@link #enable} and + * {@link #disable}. + */ + public static class EnableSource { + /** Action has been requested by the user. */ + public static final int USER = 1; + + /** + * Action requested by the app itself, e.g. to disable an extension that is not supported in + * this version of the app. + */ + public static final int APP = 2; + + static String toString(final @EnableSources int flag) { + if (flag == USER) { + return "user"; + } else if (flag == APP) { + return "app"; + } else { + throw new IllegalArgumentException("Value provided in flags is not valid."); + } + } + } + + /** + * Enable an extension that has been disabled. If the extension is already enabled, this method + * has no effect. + * + * @param extension The {@link WebExtension} to be enabled. + * @param source The agent that initiated this action, e.g. if the action has been initiated by + * the user,use {@link EnableSource#USER}. + * @return the new {@link WebExtension} instance, updated to reflect the enablement. + */ + @AnyThread + @NonNull + public GeckoResult enable( + final @NonNull WebExtension extension, final @EnableSources int source) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("webExtensionId", extension.id); + bundle.putString("source", EnableSource.toString(source)); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Enable", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + /** + * Disable an extension that is enabled. If the extension is already disabled, this method has no + * effect. + * + * @param extension The {@link WebExtension} to be disabled. + * @param source The agent that initiated this action, e.g. if the action has been initiated by + * the user, use {@link EnableSource#USER}. + * @return the new {@link WebExtension} instance, updated to reflect the disablement. + */ + @AnyThread + @NonNull + public GeckoResult disable( + final @NonNull WebExtension extension, final @EnableSources int source) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("webExtensionId", extension.id); + bundle.putString("source", EnableSource.toString(source)); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Disable", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + private List listFromBundle(final GeckoBundle response) { + final GeckoBundle[] bundles = response.getBundleArray("extensions"); + final List list = new ArrayList<>(bundles.length); + + for (final GeckoBundle bundle : bundles) { + final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle); + list.add(registerWebExtension(extension)); + } + + return list; + } + + /** + * List installed extensions for this {@link GeckoRuntime}. + * + *

    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() { + 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 browser_specific_settings.gecko.update_url. + * 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 here. + * + * @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 update(final @NonNull WebExtension extension) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("webExtensionId", extension.id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Update", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /* package */ WebExtensionController(final GeckoRuntime runtime) { + mListener = new WebExtension.Listener<>(runtime); + mPendingMessages = new MultiMap<>(); + mPendingNewTab = new MultiMap<>(); + mPendingBrowsingData = new MultiMap<>(); + mPendingDownload = new MultiMap<>(); + mExtensions.setObserver(mInternals); + mDownloads = new SparseArray<>(); + } + + /* package */ WebExtension registerWebExtension(final WebExtension webExtension) { + if (webExtension != null) { + mExtensions.update(webExtension.id, webExtension); + } + return webExtension; + } + + /* package */ void handleMessage( + final String event, + final GeckoBundle bundle, + final EventCallback callback, + final GeckoSession session) { + final Message message = new Message(event, bundle, callback, session); + + Log.d(LOGTAG, "handleMessage " + event); + + if ("GeckoView:WebExtension:InstallPrompt".equals(event)) { + installPrompt(bundle, callback); + return; + } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) { + updatePrompt(bundle, callback); + return; + } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) { + if (mDebuggerDelegate != null) { + mDebuggerDelegate.onExtensionListUpdated(); + } + return; + } else if ("GeckoView:WebExtension:OnDisabling".equals(event)) { + onDisabling(bundle); + return; + } else if ("GeckoView:WebExtension:OnDisabled".equals(event)) { + onDisabled(bundle); + return; + } else if ("GeckoView:WebExtension:OnEnabling".equals(event)) { + onEnabling(bundle); + return; + } else if ("GeckoView:WebExtension:OnEnabled".equals(event)) { + onEnabled(bundle); + return; + } else if ("GeckoView:WebExtension:OnUninstalling".equals(event)) { + onUninstalling(bundle); + return; + } else if ("GeckoView:WebExtension:OnUninstalled".equals(event)) { + onUninstalled(bundle); + return; + } else if ("GeckoView:WebExtension:OnInstalling".equals(event)) { + onInstalling(bundle); + return; + } else if ("GeckoView:WebExtension:OnInstalled".equals(event)) { + onInstalled(bundle); + return; + } + + extensionFromBundle(bundle) + .accept( + extension -> { + if ("GeckoView:WebExtension:NewTab".equals(event)) { + newTab(message, extension); + return; + } else if ("GeckoView:WebExtension:UpdateTab".equals(event)) { + updateTab(message, extension); + return; + } else if ("GeckoView:WebExtension:CloseTab".equals(event)) { + closeTab(message, extension); + return; + } else if ("GeckoView:BrowserAction:Update".equals(event)) { + actionUpdate(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION); + return; + } else if ("GeckoView:PageAction:Update".equals(event)) { + actionUpdate(message, extension, WebExtension.Action.TYPE_PAGE_ACTION); + return; + } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) { + openPopup(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION); + return; + } else if ("GeckoView:PageAction:OpenPopup".equals(event)) { + openPopup(message, extension, WebExtension.Action.TYPE_PAGE_ACTION); + return; + } else if ("GeckoView:WebExtension:OpenOptionsPage".equals(event)) { + openOptionsPage(message, extension); + return; + } else if ("GeckoView:BrowsingData:GetSettings".equals(event)) { + getSettings(message, extension); + return; + } else if ("GeckoView:BrowsingData:Clear".equals(event)) { + browsingDataClear(message, extension); + return; + } else if ("GeckoView:WebExtension:Download".equals(event)) { + download(message, extension); + return; + } else if ("GeckoView:WebExtension:OptionalPrompt".equals(event)) { + optionalPrompt(message, extension); + return; + } + + // GeckoView:WebExtension:Connect and GeckoView:WebExtension:Message + // are handled below. + final String nativeApp = bundle.getString("nativeApp"); + if (nativeApp == null) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing required nativeApp message parameter."); + } + callback.sendError("Missing nativeApp parameter."); + return; + } + + final GeckoBundle senderBundle = bundle.getBundle("sender"); + final WebExtension.MessageSender sender = + fromBundle(extension, senderBundle, session); + if (sender == null) { + if (callback != null) { + if (BuildConfig.DEBUG_BUILD) { + try { + Log.e( + LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject()); + } catch (final JSONException ex) { + } + } + callback.sendError("Could not find recipient for " + bundle.getBundle("sender")); + } + return; + } + + if ("GeckoView:WebExtension:Connect".equals(event)) { + connect(nativeApp, bundle.getLong("portId", -1), message, sender); + } else if ("GeckoView:WebExtension:Message".equals(event)) { + message(nativeApp, message, sender); + } + }); + } + + private void installPrompt(final GeckoBundle message, final EventCallback callback) { + final GeckoBundle extensionBundle = message.getBundle("extension"); + if (extensionBundle == null + || !extensionBundle.containsKey("webExtensionId") + || !extensionBundle.containsKey("locationURI")) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing webExtensionId or locationURI"); + } + + Log.e(LOGTAG, "Missing webExtensionId or locationURI"); + return; + } + + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + + if (mPromptDelegate == null) { + Log.e( + LOGTAG, "Tried to install extension " + extension.id + " but no delegate is registered"); + return; + } + + final GeckoResult promptResponse = mPromptDelegate.onInstallPrompt(extension); + if (promptResponse == null) { + return; + } + + callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + private void updatePrompt(final GeckoBundle message, final EventCallback callback) { + final GeckoBundle currentBundle = message.getBundle("currentlyInstalled"); + final GeckoBundle updatedBundle = message.getBundle("updatedExtension"); + final String[] newPermissions = message.getStringArray("newPermissions"); + final String[] newOrigins = message.getStringArray("newOrigins"); + if (currentBundle == null || updatedBundle == null) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing bundle"); + } + + Log.e(LOGTAG, "Missing bundle"); + return; + } + + final WebExtension currentExtension = + new WebExtension(mDelegateControllerProvider, currentBundle); + + final WebExtension updatedExtension = + new WebExtension(mDelegateControllerProvider, updatedBundle); + + if (mPromptDelegate == null) { + Log.e( + LOGTAG, + "Tried to update extension " + currentExtension.id + " but no delegate is registered"); + return; + } + + final GeckoResult promptResponse = + mPromptDelegate.onUpdatePrompt( + currentExtension, updatedExtension, newPermissions, newOrigins); + if (promptResponse == null) { + return; + } + + callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + private void optionalPrompt(final Message message, final WebExtension extension) { + if (mPromptDelegate == null) { + Log.e( + LOGTAG, + "Tried to request optional permissions for extension " + + extension.id + + " but no delegate is registered"); + return; + } + + final String[] permissions = + message.bundle.getBundle("permissions").getStringArray("permissions"); + final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins"); + final GeckoResult promptResponse = + mPromptDelegate.onOptionalPrompt(extension, permissions, origins); + if (promptResponse == null) { + return; + } + + message.callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + private void onDisabling(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onDisabling(extension); + } + + private void onDisabled(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onDisabled(extension); + } + + private void onEnabling(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onEnabling(extension); + } + + private void onEnabled(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onEnabled(extension); + } + + private void onUninstalling(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onUninstalling(extension); + } + + private void onUninstalled(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onUninstalled(extension); + } + + private void onInstalling(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onInstalling(extension); + } + + private void onInstalled(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + mAddonManagerDelegate.onInstalled(extension); + } + + @SuppressLint("WrongThread") // for .toGeckoBundle + private void getSettings(final Message message, final WebExtension extension) { + final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension); + if (delegate == null) { + mPendingBrowsingData.add(extension.id, message); + return; + } + + final GeckoResult 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 response; + if ("downloads".equals(dataType)) { + response = delegate.onClearDownloads(unixTimestamp); + } else if ("formData".equals(dataType)) { + response = delegate.onClearFormData(unixTimestamp); + } else if ("history".equals(dataType)) { + response = delegate.onClearHistory(unixTimestamp); + } else if ("passwords".equals(dataType)) { + response = delegate.onClearPasswords(unixTimestamp); + } else { + throw new IllegalStateException("Illegal clear data type: " + dataType); + } + + message.callback.resolveTo(response); + } + + /* package */ void download(final Message message, final WebExtension extension) { + final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension); + if (delegate == null) { + mPendingDownload.add(extension.id, message); + return; + } + + final GeckoBundle optionsBundle = message.bundle.getBundle("options"); + + final WebExtension.DownloadRequest request = + WebExtension.DownloadRequest.fromBundle(optionsBundle); + + final GeckoResult result = + delegate.onDownload(extension, request); + if (result == null) { + message.callback.sendError("downloads.download is not supported"); + return; + } + + message.callback.resolveTo( + result.map( + value -> { + if (value == null) { + Log.e(LOGTAG, "onDownload returned invalid null value"); + throw new IllegalArgumentException("downloads.download is not supported"); + } + + final GeckoBundle returnMessage = + WebExtension.Download.downloadInfoToBundle(value.initData); + returnMessage.putInt("id", value.download.id); + + return returnMessage; + })); + } + + /* package */ void openOptionsPage(final Message message, final WebExtension extension) { + final GeckoBundle bundle = message.bundle; + final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension); + + if (delegate != null) { + delegate.onOpenOptionsPage(extension); + } else { + message.callback.sendError("runtime.openOptionsPage is not supported"); + } + + message.callback.sendSuccess(null); + } + + /* package */ + @SuppressLint("WrongThread") // for .isOpen + void newTab(final Message message, final WebExtension extension) { + final GeckoBundle bundle = message.bundle; + + final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension); + final WebExtension.CreateTabDetails details = + new WebExtension.CreateTabDetails(bundle.getBundle("createProperties")); + + final GeckoResult result; + if (delegate != null) { + result = delegate.onNewTab(extension, details); + } else { + mPendingNewTab.add(extension.id, message); + return; + } + + if (result == null) { + message.callback.sendSuccess(false); + return; + } + + final String newSessionId = message.bundle.getString("newSessionId"); + message.callback.resolveTo( + result.map( + session -> { + if (session == null) { + return false; + } + + if (session.isOpen()) { + throw new IllegalArgumentException("Must use an unopened GeckoSession instance"); + } + + session.open(mListener.runtime, newSessionId); + return true; + })); + } + + /* package */ void updateTab(final Message message, final WebExtension extension) { + final WebExtension.SessionTabDelegate delegate = + message.session.getWebExtensionController().getTabDelegate(extension); + final EventCallback callback = message.callback; + + if (delegate == null) { + callback.sendError("tabs.update is not supported"); + return; + } + + final WebExtension.UpdateTabDetails details = + new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties")); + callback.resolveTo( + delegate + .onUpdateTab(extension, message.session, details) + .map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return null; + } else { + throw new Exception("tabs.update is not supported"); + } + })); + } + + /* package */ void closeTab(final Message message, final WebExtension extension) { + final WebExtension.SessionTabDelegate delegate = + message.session.getWebExtensionController().getTabDelegate(extension); + + final GeckoResult result; + if (delegate != null) { + result = delegate.onCloseTab(extension, message.session); + } else { + result = GeckoResult.fromValue(AllowOrDeny.DENY); + } + + message.callback.resolveTo( + result.map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return null; + } else { + throw new Exception("tabs.remove is not supported"); + } + })); + } + + /** + * Notifies extensions about a active tab change over the `tabs.onActivated` event. + * + * @param session The {@link GeckoSession} of the newly selected session/tab. + * @param active true if the tab became active, false if the tab became inactive. + */ + @AnyThread + public void setTabActive(@NonNull final GeckoSession session, final boolean active) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putBoolean("active", active); + session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle); + } + + /* package */ void unregisterWebExtension(final WebExtension webExtension) { + mExtensions.remove(webExtension.id); + mListener.unregisterWebExtension(webExtension); + } + + private WebExtension.MessageSender fromBundle( + final WebExtension extension, final GeckoBundle sender, final GeckoSession session) { + if (extension == null) { + // All senders should have an extension + return null; + } + + final String envType = sender.getString("envType"); + @WebExtension.MessageSender.EnvType final int environmentType; + + if ("content_child".equals(envType)) { + environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT; + } else if ("addon_child".equals(envType)) { + // TODO Bug 1554277: check that this message is coming from the right process + environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION; + } else { + environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN; + } + + if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing or unknown envType: " + envType); + } + + return null; + } + + final String url = sender.getString("url"); + final boolean isTopLevel; + if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) { + // This message is coming from the background page, a popup, or an extension page + isTopLevel = true; + } else { + // If session is present we are either receiving this message from a content script or + // an extension page, let's make sure we have the proper identification so that + // embedders can check the origin of this message. + // -1 is an invalid frame id + final boolean hasFrameId = + sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1; + final boolean hasUrl = sender.containsKey("url"); + if (!hasFrameId || !hasUrl) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException( + "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl); + } + + // This message does not have the proper identification and may be compromised, + // let's ignore it. + return null; + } + + isTopLevel = sender.getInt("frameId", -1) == 0; + } + + return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel); + } + + private WebExtension.MessageDelegate getDelegate( + final String nativeApp, + final WebExtension.MessageSender sender, + final EventCallback callback) { + if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0 + && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) { + callback.sendError("This NativeApp can't receive messages from Content Scripts."); + return null; + } + + WebExtension.MessageDelegate delegate = null; + + if (sender.session != null) { + delegate = + sender + .session + .getWebExtensionController() + .getMessageDelegate(sender.webExtension, nativeApp); + } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) { + delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp); + } + + return delegate; + } + + private static class MessageRecipient { + public final String webExtensionId; + public final String nativeApp; + public final GeckoSession session; + + public MessageRecipient( + final String webExtensionId, final String nativeApp, final GeckoSession session) { + this.webExtensionId = webExtensionId; + this.nativeApp = nativeApp; + this.session = session; + } + + private static boolean equals(final Object a, final Object b) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return Objects.equals(a, b); + } + + return (a == b) || (a != null && a.equals(b)); + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof MessageRecipient)) { + return false; + } + + final MessageRecipient o = (MessageRecipient) other; + return equals(webExtensionId, o.webExtensionId) + && equals(nativeApp, o.nativeApp) + && equals(session, o.session); + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session}); + } + } + + private void connect( + final String nativeApp, + final long portId, + final Message message, + final WebExtension.MessageSender sender) { + if (portId == -1) { + message.callback.sendError("Missing portId."); + return; + } + + final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender); + + final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback); + if (delegate == null) { + mPendingMessages.add( + new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message); + return; + } + + delegate.onConnect(port); + message.callback.sendSuccess(true); + } + + private void message( + final String nativeApp, final Message message, final WebExtension.MessageSender sender) { + final EventCallback callback = message.callback; + + final Object content; + try { + content = message.bundle.toJSONObject().get("data"); + } catch (final JSONException ex) { + callback.sendError(ex.getMessage()); + return; + } + + final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback); + if (delegate == null) { + mPendingMessages.add( + new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message); + return; + } + + final GeckoResult response = delegate.onMessage(nativeApp, content, sender); + if (response == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo(response); + } + + private GeckoResult extensionFromBundle(final GeckoBundle message) { + final String extensionId = message.getString("extensionId"); + return mExtensions.get(extensionId); + } + + private void openPopup( + final Message message, + final WebExtension extension, + final @WebExtension.Action.ActionType int actionType) { + if (extension == null) { + return; + } + + final WebExtension.Action action = + new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension); + final String popupUri = message.bundle.getString("popupUri"); + + final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session); + if (delegate == null) { + return; + } + + final GeckoResult popup = delegate.onOpenPopup(extension, action); + action.openPopup(popup, popupUri); + } + + private WebExtension.ActionDelegate actionDelegateFor( + final WebExtension extension, final GeckoSession session) { + if (session == null) { + return mListener.getActionDelegate(extension); + } + + return session.getWebExtensionController().getActionDelegate(extension); + } + + private void actionUpdate( + final Message message, + final WebExtension extension, + final @WebExtension.Action.ActionType int actionType) { + if (extension == null) { + return; + } + + final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session); + if (delegate == null) { + return; + } + + final WebExtension.Action action = + new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension); + if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) { + delegate.onBrowserAction(extension, message.session, action); + } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) { + delegate.onPageAction(extension, message.session, action); + } + } + + // TODO: implement bug 1595822 + /* package */ static GeckoResult> getMenu( + final GeckoBundle menuArrayBundle) { + return null; + } + + @Nullable + @UiThread + public WebExtension.Download createDownload(final int id) { + if (mDownloads.indexOfKey(id) >= 0) { + throw new IllegalArgumentException("Download with this id already exists"); + } else { + final WebExtension.Download download = new WebExtension.Download(id); + mDownloads.put(id, download); + + return download; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java new file mode 100644 index 0000000000..520cb9faa0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java @@ -0,0 +1,117 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** This is an abstract base class for HTTP request and response types. */ +@WrapForJNI +@AnyThread +public abstract class WebMessage { + + /** The URI for the request or response. */ + public final @NonNull String uri; + + /** An unmodifiable Map of headers. Defaults to an empty instance. */ + public final @NonNull Map headers; + + protected WebMessage(final @NonNull Builder builder) { + uri = builder.mUri; + headers = Collections.unmodifiableMap(builder.mHeaders); + } + + // This is only used via JNI. + private String[] getHeaderKeys() { + final String[] keys = new String[headers.size()]; + headers.keySet().toArray(keys); + return keys; + } + + // This is only used via JNI. + private String[] getHeaderValues() { + final String[] values = new String[headers.size()]; + headers.values().toArray(values); + return values; + } + + /** This is a Builder used by subclasses of {@link WebMessage}. */ + @AnyThread + public abstract static class Builder { + /* package */ String mUri; + /* package */ Map 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..c2de231f80 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; +import android.os.ParcelFormatException; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * This class represents a single Web Notification. These + * can be received by connecting a {@link WebNotificationDelegate} to {@link GeckoRuntime} via + * {@link GeckoRuntime#setWebNotificationDelegate(WebNotificationDelegate)}. + */ +public class WebNotification implements Parcelable { + + /** + * Title is shown at the top of the notification window. + * + * @see Web + * Notification - title + */ + public final @Nullable String title; + + /** + * Tag is the ID of the notification. + * + * @see Web + * Notification - tag + */ + public final @NonNull String tag; + + private final @Nullable String mCookie; + + /** + * Text represents the body of the notification. + * + * @see Web + * Notification - text + */ + public final @Nullable String text; + + /** + * ImageURL contains the URL of an icon to be displayed as part of the notification. + * + * @see Web + * Notification - icon + */ + 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 Web + * Notification - dir + */ + public final @Nullable String textDirection; + + /** + * Lang indicates the notification's language, as specified using a DOMString representing a BCP + * 47 language tag. + * + * @see DOM String + * @see BCP 47 + * @see Web + * Notification - lang + */ + 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 Web + * Notification - requireInteraction + */ + 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; + + /** + * When set, indicates that no sounds or vibrations should be made. + * + * @see Web + * Notification - silent + */ + public final boolean silent; + + /** indicates whether the notification came from private browsing mode or not. */ + public final boolean privateBrowsing; + + /** + * A vibration pattern to run with the display of the notification. A vibration pattern can be an + * array with as few as one member. The values are times in milliseconds where the even indices + * (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause. + * For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms. + * + * @see Web + * Notification - vibrate + */ + public final @NonNull int[] vibrate; + + @WrapForJNI + /* package */ WebNotification( + @Nullable final String title, + @NonNull final String tag, + @Nullable final String cookie, + @Nullable final String text, + @Nullable final String imageUrl, + @Nullable final String textDirection, + @Nullable final String lang, + @NonNull final boolean requireInteraction, + @NonNull final String source, + final boolean silent, + final boolean privateBrowsing, + @NonNull final int[] vibrate) { + this.tag = tag; + this.mCookie = cookie; + this.title = title; + this.text = text; + this.imageUrl = imageUrl; + this.textDirection = textDirection; + this.lang = lang; + this.requireInteraction = requireInteraction; + this.source = "".equals(source) ? null : source; + this.silent = silent; + this.vibrate = vibrate; + this.privateBrowsing = privateBrowsing; + } + + /** + * This should be called when the user taps or clicks a notification. Note that this does not + * automatically dismiss the notification as far as Web Content is concerned. For that, see {@link + * #dismiss()}. + */ + @UiThread + public void click() { + ThreadUtils.assertOnUiThread(); + GeckoAppShell.onNotificationClick(tag, mCookie); + } + + /** + * This should be called when the app stops showing the notification. This is important, as there + * may be a limit to the number of active notifications each site can display. + */ + @UiThread + public void dismiss() { + ThreadUtils.assertOnUiThread(); + GeckoAppShell.onNotificationClose(tag, mCookie); + } + + // Increment this value whenever anything changes in the parcelable representation. + private static final int VERSION = 1; + + // To avoid TransactionTooLargeException, we only store small imageUrls + private static final int IMAGE_URL_LENGTH_MAX = 150; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeInt(VERSION); + dest.writeString(title); + dest.writeString(tag); + dest.writeString(mCookie); + dest.writeString(text); + if (imageUrl.length() < IMAGE_URL_LENGTH_MAX) { + dest.writeString(imageUrl); + } else { + dest.writeString(""); + } + dest.writeString(textDirection); + dest.writeString(lang); + dest.writeInt(requireInteraction ? 1 : 0); + dest.writeString(source); + dest.writeInt(silent ? 1 : 0); + dest.writeInt(privateBrowsing ? 1 : 0); + dest.writeIntArray(vibrate); + } + + private WebNotification(final Parcel in) { + title = in.readString(); + tag = in.readString(); + mCookie = in.readString(); + text = in.readString(); + imageUrl = in.readString(); + textDirection = in.readString(); + lang = in.readString(); + requireInteraction = in.readInt() == 1; + source = in.readString(); + silent = in.readInt() == 1; + privateBrowsing = in.readInt() == 1; + vibrate = in.createIntArray(); + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public WebNotification createFromParcel(final Parcel in) { + final int version = in.readInt(); + if (version != VERSION) { + throw new ParcelFormatException( + "Mismatched version: " + version + " expected: " + VERSION); + } + return new WebNotification(in); + } + + @Override + public WebNotification[] newArray(final int size) { + return new WebNotification[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java new file mode 100644 index 0000000000..40db55fa3c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.annotation.WrapForJNI; + +public interface WebNotificationDelegate { + /** + * This is called when a new notification is created. + * + * @param notification The WebNotification received. + */ + @AnyThread + @WrapForJNI + default void onShowNotification(@NonNull final WebNotification notification) {} + + /** + * This is called when an existing notification is closed. + * + * @param notification The WebNotification received. + */ + @AnyThread + @WrapForJNI + default void onCloseNotification(@NonNull final WebNotification notification) {} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java new file mode 100644 index 0000000000..f5ea153bfe --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java @@ -0,0 +1,165 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public class WebPushController { + private static final String LOGTAG = "WebPushController"; + + private WebPushDelegate mDelegate; + private BundleEventListener mEventListener; + + /* package */ WebPushController() { + mEventListener = new EventListener(); + EventDispatcher.getInstance() + .registerUiThreadListener( + mEventListener, + "GeckoView:PushSubscribe", + "GeckoView:PushUnsubscribe", + "GeckoView:PushGetSubscription"); + } + + /** + * Sets the {@link WebPushDelegate} for this instance. + * + * @param delegate The {@link WebPushDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable WebPushDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Gets the {@link WebPushDelegate} for this instance. + * + * @return delegate The {@link WebPushDelegate} instance. + */ + @UiThread + @Nullable + public WebPushDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + return mDelegate; + } + + /** + * Send a push event for a given subscription. + * + * @param scope The Service Worker scope associated with this subscription. + */ + @UiThread + public void onPushEvent(final @NonNull String scope) { + ThreadUtils.assertOnUiThread(); + onPushEvent(scope, null); + } + + /** + * Send a push event with a payload for a given subscription. + * + * @param scope The Service Worker scope associated with this subscription. + * @param data The unencrypted payload. + */ + @UiThread + public void onPushEvent(final @NonNull String scope, final @Nullable byte[] data) { + ThreadUtils.assertOnUiThread(); + + GeckoThread.waitForState(GeckoThread.State.JNI_READY) + .accept( + val -> { + final GeckoBundle msg = new GeckoBundle(2); + msg.putString("scope", scope); + msg.putString("data", Base64Utils.encode(data)); + EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg); + }, + e -> Log.e(LOGTAG, "Unable to deliver Web Push message", e)); + } + + /** + * Notify that a given subscription has changed. This is normally a signal to the content that it + * needs to re-subscribe. + * + * @param scope The Service Worker scope associated with this subscription. + */ + @UiThread + public void onSubscriptionChanged(final @NonNull String scope) { + ThreadUtils.assertOnUiThread(); + + final GeckoBundle msg = new GeckoBundle(1); + msg.putString("scope", scope); + EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg); + } + + private class EventListener implements BundleEventListener { + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (mDelegate == null) { + callback.sendError("Not allowed"); + return; + } + + switch (event) { + case "GeckoView:PushSubscribe": + { + byte[] appServerKey = null; + if (message.containsKey("appServerKey")) { + appServerKey = Base64Utils.decode(message.getString("appServerKey")); + } + + final GeckoResult 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 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 result = + mDelegate.onGetSubscription(message.getString("scope")); + if (result == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo( + result.map(subscription -> subscription != null ? subscription.toBundle() : null)); + break; + } + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java new file mode 100644 index 0000000000..d9e9c39274 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java @@ -0,0 +1,62 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +public interface WebPushDelegate { + /** + * Creates a push subscription for the given service worker scope. A scope uniquely identifies a + * service worker. `appServerKey` optionally creates a restricted subscription. + * + *

    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 subscribe() + * @see Application + * server key + */ + @UiThread + default @Nullable GeckoResult onSubscribe( + @NonNull final String scope, @Nullable final byte[] appServerKey) { + return null; + } + + /** + * Retrieves a subscription for the given service worker scope. + * + * @param scope The scope for the requested {@link WebPushSubscription}. + * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription} + * @see getSubscription() + */ + @UiThread + default @Nullable GeckoResult onGetSubscription( + @NonNull final String scope) { + return null; + } + + /** + * Removes a push subscription. If this fails, apps should resolve the returned {@link + * GeckoResult} with an exception. + * + * @param scope The Service Worker scope for the subscription. + * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing. + * @see unsubscribe() + */ + @UiThread + default @Nullable GeckoResult onUnsubscribe(@NonNull final String scope) { + return null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java new file mode 100644 index 0000000000..7ce9a3d60c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java @@ -0,0 +1,180 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Arrays; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * This class represents a single Web Push subscription, as described in the Web Push API 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 RFC 8291. + * + *

    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 ServiceWorker + * registration + */ + @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 RFC 8030 + */ + @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 RFC 8292. + * + * @see applicationServerKey + * @see Message Encryption for Web Push + */ + @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 PushEncryptionKeyName + * - p256dh + * @see RFC 8291 section 3.1 + */ + @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 PushEncryptionKeyName + * - auth + * @see RFC 8291, section 3.2 + */ + @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 CREATOR = + new Parcelable.Creator() { + @Override + @AnyThread + public WebPushSubscription createFromParcel(final Parcel parcel) { + return new WebPushSubscription(parcel); + } + + @Override + @AnyThread + public WebPushSubscription[] newArray(final int size) { + return new WebPushSubscription[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java new file mode 100644 index 0000000000..30ee5451aa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java @@ -0,0 +1,248 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this + * class via {@link WebRequest.Builder}, and fetch responses via {@link + * GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +@AnyThread +public class WebRequest extends WebMessage { + /** The HTTP method for the request. Defaults to "GET". */ + public final @NonNull String method; + + /** The body of the request. Must be a directly-allocated ByteBuffer. May be null. */ + public final @Nullable ByteBuffer body; + + /** + * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. These modes match those from + * the DOM Fetch API. + * + * @see DOM Fetch API + * cache modes + */ + public final @CacheMode int cacheMode; + + /** + * If true, do not use newer protocol features that might have interop problems on the Internet. + * Intended only for use with critical infrastructure. + */ + public final boolean beConservative; + + /** The value of the Referer header for this request. */ + public final @Nullable String referrer; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CACHE_MODE_DEFAULT, + CACHE_MODE_NO_STORE, + CACHE_MODE_RELOAD, + CACHE_MODE_NO_CACHE, + CACHE_MODE_FORCE_CACHE, + CACHE_MODE_ONLY_IF_CACHED + }) + public @interface CacheMode {}; + + /** Default cache mode. Normal caching rules apply. */ + public static final int CACHE_MODE_DEFAULT = 1; + + /** + * The response will be fetched from the server without looking in the cache, and will not update + * the cache with the downloaded response. + */ + public static final int CACHE_MODE_NO_STORE = 2; + + /** + * The response will be fetched from the server without looking in the cache. The cache will be + * updated with the downloaded response. + */ + public static final int CACHE_MODE_RELOAD = 3; + + /** Forces a conditional request to the server if there is a cache match. */ + public static final int CACHE_MODE_NO_CACHE = 4; + + /** + * If a response is found in the cache, it will be returned, whether it's fresh or not. If there + * is no match, a normal request will be made and the cache will be updated with the downloaded + * response. + */ + public static final int CACHE_MODE_FORCE_CACHE = 5; + + /** + * If a response is found in the cache, it will be returned, whether it's fresh or not. If there + * is no match from the cache, 504 Gateway Timeout will be returned. + */ + public static final int CACHE_MODE_ONLY_IF_CACHED = 6; + + /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT; + /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED; + + /** + * Constructs a WebRequest with the specified URI. + * + * @param uri A URI String, e.g. https://mozilla.org + */ + public WebRequest(final @NonNull String uri) { + this(new Builder(uri)); + } + + /** Constructs a new WebRequest from a {@link WebRequest.Builder}. */ + /* package */ WebRequest(final @NonNull Builder builder) { + super(builder); + method = builder.mMethod; + cacheMode = builder.mCacheMode; + referrer = builder.mReferrer; + beConservative = builder.mBeConservative; + + if (builder.mBody != null) { + body = builder.mBody.asReadOnlyBuffer(); + } else { + body = null; + } + } + + /** Builder offers a convenient way for constructing {@link WebRequest} instances. */ + @AnyThread + public static class Builder extends WebMessage.Builder { + /* package */ String mMethod = "GET"; + /* package */ int mCacheMode = CACHE_MODE_DEFAULT; + /* package */ String mReferrer; + /* package */ boolean mBeConservative; + + /** + * Construct a Builder instance with the specified URI. + * + * @param uri A URI String. + */ + public Builder(final @NonNull String uri) { + super(uri); + } + + @Override + public @NonNull Builder uri(final @NonNull String uri) { + super.uri(uri); + return this; + } + + @Override + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + super.header(key, value); + return this; + } + + @Override + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + super.addHeader(key, value); + return this; + } + + /** + * Set the body. + * + * @param buffer A {@link ByteBuffer} with the data. Must be allocated directly via {@link + * ByteBuffer#allocateDirect(int)}. + * @return This Builder instance. + */ + public @NonNull Builder body(final @Nullable ByteBuffer buffer) { + if (buffer != null && !buffer.isDirect()) { + throw new IllegalArgumentException("body must be directly allocated"); + } + mBody = buffer; + return this; + } + + /** + * Set the body. + * + * @param bodyString A {@link String} with the data. + * @return This Builder instance. + */ + public @NonNull Builder body(final @Nullable String bodyString) { + if (bodyString == null) { + mBody = null; + return this; + } + final CharBuffer chars = CharBuffer.wrap(bodyString); + final ByteBuffer buffer = ByteBuffer.allocateDirect(bodyString.length()); + Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true); + + mBody = buffer; + return this; + } + + /** + * Set the HTTP method. + * + * @param method The HTTP method String. + * @return This Builder instance. + */ + public @NonNull Builder method(final @NonNull String method) { + mMethod = method; + return this; + } + + /** + * Set the cache mode. + * + * @param mode One of the {@link #CACHE_MODE_DEFAULT CACHE_*} flags. + * @return This Builder instance. + */ + public @NonNull Builder cacheMode(final @CacheMode int mode) { + if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) { + throw new IllegalArgumentException("Unknown cache mode"); + } + mCacheMode = mode; + return this; + } + + /** + * Set the HTTP Referer header. + * + * @param referrer A URI String + * @return This Builder instance. + */ + public @NonNull Builder referrer(final @Nullable String referrer) { + mReferrer = referrer; + return this; + } + + /** + * Set the beConservative property. + * + * @param beConservative If true, do not use newer protocol features that might have interop + * problems on the Internet. Intended only for use with critical infrastructure. + * @return This Builder instance. + */ + public @NonNull Builder beConservative(final boolean beConservative) { + mBeConservative = beConservative; + return this; + } + + /** + * @return A {@link WebRequest} constructed with the values from this Builder instance. + */ + public @NonNull WebRequest build() { + if (mUri == null) { + throw new IllegalStateException("Must set URI"); + } + return new WebRequest(this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java new file mode 100644 index 0000000000..455078feb7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java @@ -0,0 +1,380 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.XPCOMError; + +/** + * WebRequestError is simply a container for error codes and categories used by {@link + * GeckoSession.NavigationDelegate#onLoadError(GeckoSession, String, WebRequestError)}. + */ +@AnyThread +public class WebRequestError extends Exception { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ERROR_CATEGORY_UNKNOWN, + ERROR_CATEGORY_SECURITY, + ERROR_CATEGORY_NETWORK, + ERROR_CATEGORY_CONTENT, + ERROR_CATEGORY_URI, + ERROR_CATEGORY_PROXY, + ERROR_CATEGORY_SAFEBROWSING + }) + public @interface ErrorCategory {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ERROR_UNKNOWN, + ERROR_SECURITY_SSL, + ERROR_SECURITY_BAD_CERT, + ERROR_NET_RESET, + ERROR_NET_INTERRUPT, + ERROR_NET_TIMEOUT, + ERROR_CONNECTION_REFUSED, + ERROR_UNKNOWN_PROTOCOL, + ERROR_UNKNOWN_HOST, + ERROR_UNKNOWN_SOCKET_TYPE, + ERROR_UNKNOWN_PROXY_HOST, + ERROR_MALFORMED_URI, + ERROR_REDIRECT_LOOP, + ERROR_SAFEBROWSING_PHISHING_URI, + ERROR_SAFEBROWSING_MALWARE_URI, + ERROR_SAFEBROWSING_UNWANTED_URI, + ERROR_SAFEBROWSING_HARMFUL_URI, + ERROR_CONTENT_CRASHED, + ERROR_OFFLINE, + ERROR_PORT_BLOCKED, + ERROR_PROXY_CONNECTION_REFUSED, + ERROR_FILE_NOT_FOUND, + ERROR_FILE_ACCESS_DENIED, + ERROR_INVALID_CONTENT_ENCODING, + ERROR_UNSAFE_CONTENT_TYPE, + ERROR_CORRUPTED_CONTENT, + ERROR_DATA_URI_TOO_LONG, + ERROR_HTTPS_ONLY, + ERROR_BAD_HSTS_CERT + }) + public @interface Error {} + + /** + * This is normally used for error codes that don't currently fit into any of the other + * categories. + */ + public static final int ERROR_CATEGORY_UNKNOWN = 0x1; + + /** This is used for error codes that relate to SSL certificate validation. */ + public static final int ERROR_CATEGORY_SECURITY = 0x2; + + /** This is used for error codes relating to network problems. */ + public static final int ERROR_CATEGORY_NETWORK = 0x3; + + /** This is used for error codes relating to invalid or corrupt web pages. */ + public static final int ERROR_CATEGORY_CONTENT = 0x4; + + public static final int ERROR_CATEGORY_URI = 0x5; + public static final int ERROR_CATEGORY_PROXY = 0x6; + public static final int ERROR_CATEGORY_SAFEBROWSING = 0x7; + + /** An unknown error occurred */ + public static final int ERROR_UNKNOWN = 0x11; + + // Security + /** This is used for a variety of SSL negotiation problems. */ + public static final int ERROR_SECURITY_SSL = 0x22; + + /** This is used to indicate an untrusted or otherwise invalid SSL certificate. */ + public static final int ERROR_SECURITY_BAD_CERT = 0x32; + + // Network + /** The network connection was interrupted. */ + public static final int ERROR_NET_INTERRUPT = 0x23; + + /** The network request timed out. */ + public static final int ERROR_NET_TIMEOUT = 0x33; + + /** The network request was refused by the server. */ + public static final int ERROR_CONNECTION_REFUSED = 0x43; + + /** The network request tried to use an unknown socket type. */ + public static final int ERROR_UNKNOWN_SOCKET_TYPE = 0x53; + + /** A redirect loop was detected. */ + public static final int ERROR_REDIRECT_LOOP = 0x63; + + /** This device does not have a network connection. */ + public static final int ERROR_OFFLINE = 0x73; + + /** The request tried to use a port that is blocked by either the OS or Gecko. */ + public static final int ERROR_PORT_BLOCKED = 0x83; + + /** The connection was reset. */ + public static final int ERROR_NET_RESET = 0x93; + + /** + * GeckoView could not connect to this website in HTTPS-only mode. Call + * document.reloadWithHttpsOnlyException() in the error page to temporarily disable HTTPS only + * mode for this request. + * + *

    See also {@link GeckoSession.NavigationDelegate#onLoadError} + */ + public static final int ERROR_HTTPS_ONLY = 0xA3; + + /** + * A certificate validation error occurred when connecting to a site that does not allow error + * overrides. + */ + public static final int ERROR_BAD_HSTS_CERT = 0xB3; + + // Content + /** A content type was returned which was deemed unsafe. */ + public static final int ERROR_UNSAFE_CONTENT_TYPE = 0x24; + + /** The content returned was corrupted. */ + public static final int ERROR_CORRUPTED_CONTENT = 0x34; + + /** The content process crashed. */ + public static final int ERROR_CONTENT_CRASHED = 0x44; + + /** The content has an invalid encoding. */ + public static final int ERROR_INVALID_CONTENT_ENCODING = 0x54; + + // URI + /** The host could not be resolved. */ + public static final int ERROR_UNKNOWN_HOST = 0x25; + + /** An invalid URL was specified. */ + public static final int ERROR_MALFORMED_URI = 0x35; + + /** An unknown protocol was specified. */ + public static final int ERROR_UNKNOWN_PROTOCOL = 0x45; + + /** A file was not found (usually used for file:// URIs). */ + public static final int ERROR_FILE_NOT_FOUND = 0x55; + + /** The OS blocked access to a file. */ + public static final int ERROR_FILE_ACCESS_DENIED = 0x65; + + /** A data:// URI is too long to load at the top level. */ + public static final int ERROR_DATA_URI_TOO_LONG = 0x75; + + // Proxy + /** The proxy server refused the connection. */ + public static final int ERROR_PROXY_CONNECTION_REFUSED = 0x26; + + /** The host name of the proxy server could not be resolved. */ + public static final int ERROR_UNKNOWN_PROXY_HOST = 0x36; + + // Safebrowsing + /** The requested URI was present in the "malware" blocklist. */ + public static final int ERROR_SAFEBROWSING_MALWARE_URI = 0x27; + + /** The requested URI was present in the "unwanted" blocklist. */ + public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 0x37; + + /** The requested URI was present in the "harmful" blocklist. */ + public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 0x47; + + /** The requested URI was present in the "phishing" blocklist. */ + public static final int ERROR_SAFEBROWSING_PHISHING_URI = 0x57; + + /** The error code, e.g. {@link #ERROR_MALFORMED_URI}. */ + public final int code; + + /** The error category, e.g. {@link #ERROR_CATEGORY_URI}. */ + public final int category; + + /** + * The server certificate used. This can be useful if the error code is is e.g. {@link + * #ERROR_SECURITY_BAD_CERT}. + */ + public final @Nullable X509Certificate certificate; + + /** + * Construct a new WebRequestError with the specified code and category. + * + * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI} + * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI} + */ + public WebRequestError(final @Error int code, final @ErrorCategory int category) { + this(code, category, null); + } + + /** + * Construct a new WebRequestError with the specified code and category. + * + * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI} + * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI} + * @param certificate The X509Certificate server certificate used, if applicable. + */ + public WebRequestError( + final @Error int code, final @ErrorCategory int category, final X509Certificate certificate) { + super(String.format("Request failed, error=0x%x, category=0x%x", code, category)); + this.code = code; + this.category = category; + this.certificate = certificate; + } + + @Override + public boolean equals(final Object other) { + if (other == null || !(other instanceof WebRequestError)) { + return false; + } + + final WebRequestError otherError = (WebRequestError) other; + + // We don't compare the certificate here because it's almost never what you want. + return otherError.code == this.code && otherError.category == this.category; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {category, code}); + } + + @WrapForJNI + /* package */ static WebRequestError fromGeckoError( + final long geckoError, + final int geckoErrorModule, + final int geckoErrorClass, + final byte[] certificateBytes) { + // XXX: the geckoErrorModule argument is redundant + assert geckoErrorModule == XPCOMError.getErrorModule(geckoError); + final int code = convertGeckoError(geckoError, geckoErrorClass); + final int category = getErrorCategory(XPCOMError.getErrorModule(geckoError), code); + X509Certificate certificate = null; + if (certificateBytes != null) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + certificate = + (X509Certificate) + factory.generateCertificate(new ByteArrayInputStream(certificateBytes)); + } catch (final CertificateException e) { + throw new IllegalArgumentException("Unable to parse DER certificate"); + } + } + + return new WebRequestError(code, category, certificate); + } + + @SuppressLint("WrongConstant") + @WrapForJNI + /* package */ static @ErrorCategory int getErrorCategory( + final long errorModule, final @Error int error) { + if (errorModule == XPCOMError.NS_ERROR_MODULE_SECURITY) { + return ERROR_CATEGORY_SECURITY; + } + return error & 0xF; + } + + @WrapForJNI + /* package */ static @Error int convertGeckoError( + final long geckoError, final int geckoErrorClass) { + // safebrowsing + if (geckoError == XPCOMError.NS_ERROR_PHISHING_URI) { + return ERROR_SAFEBROWSING_PHISHING_URI; + } + if (geckoError == XPCOMError.NS_ERROR_MALWARE_URI) { + return ERROR_SAFEBROWSING_MALWARE_URI; + } + if (geckoError == XPCOMError.NS_ERROR_UNWANTED_URI) { + return ERROR_SAFEBROWSING_UNWANTED_URI; + } + if (geckoError == XPCOMError.NS_ERROR_HARMFUL_URI) { + return ERROR_SAFEBROWSING_HARMFUL_URI; + } + // content + if (geckoError == XPCOMError.NS_ERROR_CONTENT_CRASHED) { + return ERROR_CONTENT_CRASHED; + } + if (geckoError == XPCOMError.NS_ERROR_INVALID_CONTENT_ENCODING) { + return ERROR_INVALID_CONTENT_ENCODING; + } + if (geckoError == XPCOMError.NS_ERROR_UNSAFE_CONTENT_TYPE) { + return ERROR_UNSAFE_CONTENT_TYPE; + } + if (geckoError == XPCOMError.NS_ERROR_CORRUPTED_CONTENT) { + return ERROR_CORRUPTED_CONTENT; + } + // network + if (geckoError == XPCOMError.NS_ERROR_NET_RESET) { + return ERROR_NET_RESET; + } + if (geckoError == XPCOMError.NS_ERROR_NET_RESET) { + return ERROR_NET_INTERRUPT; + } + if (geckoError == XPCOMError.NS_ERROR_NET_TIMEOUT) { + return ERROR_NET_TIMEOUT; + } + if (geckoError == XPCOMError.NS_ERROR_CONNECTION_REFUSED) { + return ERROR_CONNECTION_REFUSED; + } + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_SOCKET_TYPE) { + return ERROR_UNKNOWN_SOCKET_TYPE; + } + if (geckoError == XPCOMError.NS_ERROR_REDIRECT_LOOP) { + return ERROR_REDIRECT_LOOP; + } + if (geckoError == XPCOMError.NS_ERROR_HTTPS_ONLY) { + return ERROR_HTTPS_ONLY; + } + if (geckoError == XPCOMError.NS_ERROR_BAD_HSTS_CERT) { + return ERROR_BAD_HSTS_CERT; + } + if (geckoError == XPCOMError.NS_ERROR_OFFLINE) { + return ERROR_OFFLINE; + } + if (geckoError == XPCOMError.NS_ERROR_PORT_ACCESS_NOT_ALLOWED) { + return ERROR_PORT_BLOCKED; + } + // uri + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROTOCOL) { + return ERROR_UNKNOWN_PROTOCOL; + } + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_HOST) { + return ERROR_UNKNOWN_HOST; + } + if (geckoError == XPCOMError.NS_ERROR_MALFORMED_URI) { + return ERROR_MALFORMED_URI; + } + if (geckoError == XPCOMError.NS_ERROR_FILE_NOT_FOUND) { + return ERROR_FILE_NOT_FOUND; + } + if (geckoError == XPCOMError.NS_ERROR_FILE_ACCESS_DENIED) { + return ERROR_FILE_ACCESS_DENIED; + } + // proxy + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROXY_HOST) { + return ERROR_UNKNOWN_PROXY_HOST; + } + if (geckoError == XPCOMError.NS_ERROR_PROXY_CONNECTION_REFUSED) { + return ERROR_PROXY_CONNECTION_REFUSED; + } + + if (XPCOMError.getErrorModule(geckoError) == XPCOMError.NS_ERROR_MODULE_SECURITY) { + if (geckoErrorClass == 1) { + return ERROR_SECURITY_SSL; + } + if (geckoErrorClass == 2) { + return ERROR_SECURITY_BAD_CERT; + } + } + + return ERROR_UNKNOWN; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java new file mode 100644 index 0000000000..8c224ed2e3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java @@ -0,0 +1,227 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * WebResponse represents an HTTP[S] response. It is normally created by {@link + * GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +@AnyThread +public class WebResponse extends WebMessage { + /** The default read timeout for the {@link #body} stream. */ + public static final long DEFAULT_READ_TIMEOUT_MS = 30000; + + /** The HTTP status code for the response, e.g. 200. */ + public final int statusCode; + + /** A boolean indicating whether or not this response is the result of a redirection. */ + public final boolean redirected; + + /** Whether or not this response was delivered via a secure connection. */ + public final boolean isSecure; + + /** The server certificate used with this response, if any. */ + public final @Nullable X509Certificate certificate; + + /** + * An {@link InputStream} containing the response body, if available. Attention: the stream must + * be closed whenever the app is done with it, even when the body is ignored. Otherwise the + * connection will not be closed until the stream is garbage collected + */ + public final @Nullable InputStream body; + + /** + * Specifies that the contents should request to be opened in another Android application. For + * example, provide PDF content and set this to true to request that Android opens the PDF in a + * system PDF viewer (if possible and allowed by the user). + */ + public final @Nullable boolean requestExternalApp; + + /** + * Specifies that the app may skip requesting the download in the UI. A confirmation of the + * download will still be shown. + */ + public final @Nullable boolean skipConfirmation; + + protected WebResponse(final @NonNull Builder builder) { + super(builder); + this.statusCode = builder.mStatusCode; + this.redirected = builder.mRedirected; + this.body = builder.mBody; + this.requestExternalApp = builder.mRequestExternalApp; + this.skipConfirmation = builder.mSkipConfirmation; + 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 mRequestExternalApp = false; + /* package */ boolean mSkipConfirmation = false; + /* 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; + } + + /** + * Requests that the content be passed to an external Android application. The default is false. + * For example, set to true to request that the user have the option to open the content in + * another Android application. + * + * @param requestExternalApp request that the content be opened in another application. + * @return This Builder instance. + */ + public @NonNull Builder requestExternalApp(final boolean requestExternalApp) { + mRequestExternalApp = requestExternalApp; + return this; + } + + /** + * Specifies if a confirmation to begin downloading is necessary or not. (The confirmation that + * a download occurred will still be shown.) The default is false, which is to request a + * download confirmation. Skipping the confirmation is only advisable if the user has already + * opted to download. + * + * @param skipConfirmation whether to skip or show the confirm download flow + * @return This Builder instance. + */ + public @NonNull Builder skipConfirmation(final boolean skipConfirmation) { + mSkipConfirmation = skipConfirmation; + return this; + } + + /** + * @param isSecure Whether or not this response is secure. + * @return This Builder instance. + */ + public @NonNull Builder isSecure(final boolean isSecure) { + mIsSecure = isSecure; + return this; + } + + /** + * @param certificate The certificate used. + * @return This Builder instance. + */ + public @NonNull Builder certificate(final @NonNull X509Certificate certificate) { + mCertificate = certificate; + return this; + } + + /** + * @param encodedCert The certificate used, encoded via DER. Only used via JNI. + */ + @WrapForJNI(exceptionMode = "nsresult") + private void certificateBytes(final @NonNull byte[] encodedCert) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + final X509Certificate cert = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encodedCert)); + certificate(cert); + } catch (final CertificateException e) { + throw new IllegalArgumentException("Unable to parse DER certificate"); + } + } + + /** + * Set the HTTP status code, e.g. 200. + * + * @param code A int representing the HTTP status code. + * @return This Builder instance. + */ + public @NonNull Builder statusCode(final int code) { + mStatusCode = code; + return this; + } + + /** + * Set whether or not this response was the result of a redirect. + * + * @param redirected A boolean representing whether or not the request was redirected. + * @return This Builder instance. + */ + public @NonNull Builder redirected(final boolean redirected) { + mRedirected = redirected; + return this; + } + + /** + * @return A {@link WebResponse} constructed with the values from this Builder instance. + */ + public @NonNull WebResponse build() { + return new WebResponse(this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md new file mode 100644 index 0000000000..cb316a5264 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md @@ -0,0 +1,1379 @@ +--- +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 + +## v115 +- Changed [`SessionPdfFileSaver.createResponse`][115.1] to response of saving PDF to accept two additional + arguments: `skipConfirmation` and `requestExternalApp`. +- Added [`GeckoDisplay.NewSurfaceProvider`][115.2] interface, which allows Gecko to request a new rendering Surface from the application. + ([bug 1824083]({{bugzilla}}1824083)) +- Add [`onPrintWithStatus`][115.3] to retrieve additional printing status information. +- Added new [`GeckoPrintException`][115.4] errors of `ERROR_NO_ACTIVITY_CONTEXT` and `ERROR_NO_ACTIVITY_CONTEXT_DELEGATE` +- Added [`GeckoSession.ContentDelegate.onGetNimbusFeature`][115.5] +- Added [`textContent`][115.6] to [`ContentDelegate.ContextElement`][65.21] and a new [`constructor`][115.7] to [`ContentDelegate.ContextElement`][65.21] +- Changed [`SessionPdfFileSaver.createResponse`][115.8] to response of saving PDF to accept an url and return a [`GeckoResult`]. +- ⚠️ Deprecated [`GeckoSession.PdfSaveResult`][111.7] + +[115.1]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(byte[], String, String, boolean, boolean) +[115.2]: {{javadoc_uri}}/GeckoDisplay.NewSurfaceProvider.html +[115.3]: {{javadoc_uri}}/GeckoSession.PrintDelegate.html#onPrintWithStatus +[115.4]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html +[115.5]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onGetNimbusFeature(org.mozilla.geckoview.GeckoSession) +[115.6]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#textContent +[115.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#(java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String,java.lang.String) +[115.8]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(GeckoSession, String, String, String, boolean, boolean) + +## v114 +- Add [`SessionPdfFileSaver.createResponse`][114.1] to response of saving PDF. +- Added [`requestExternalApp`][114.2] and [`skipConfirmation`][114.3] with builder fields on a WebResponse to request that a downloaded file be opened in an external application or to skip a confirmation, respectively. +- ⚠️ Removed deprecated [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][111.1] + +[114.1]: {{javadoc_uri}}/SessionPdfFileSaver.html#createResponse(byte[], String, String) +[114.2]: {{javadoc_uri}}/WebResponse.html#requestExternalApp +[114.3]: {{javadoc_uri}}/WebResponse.html#skipConfirmation + +## v113 +- Add `DisplayMdoe` annotation to [`displayMode`][113.1], [`getDisplayMode`][113.2] and [`setDisplayMode`][113.3]. + ([bug 1820567]({{bugzilla}}1820567)) +- Add `UserAgentMode` annotation to [`userAgentMode`][113.4], [`getUserAgentMode`][113.5] and [`setUserAgentMode`][113.6]. + ([bug 1820567]({{bugzilla}}1820567)) +- Add `ViewportMode` annotation to [`viewportMode`][113.7], [`getViewportMode`][113.8] and [`setViewportMode`][113.9]. + ([bug 1820567]({{bugzilla}}1820567)) +- Add [`WebExtensionController.AddonManagerDelegate`][113.10] ([bug 1822763]({{bugzilla}}1822763), [bug 1826739]({{bugzilla}}1826739)) + +[113.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#displayMode(int) +[113.2]: {{javadoc_uri}}/GeckoSessionSettings.html#getDisplayMode() +[113.3]: {{javadoc_uri}}/GeckoSessionSettings.html#setDisplayMode(int) +[113.4]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#userAgentMode(int) +[113.5]: {{javadoc_uri}}/GeckoSessionSettings.html#getUserAgentMode() +[113.6]: {{javadoc_uri}}/GeckoSessionSettings.html#setUserAgentMode(int) +[113.7]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#userViewportMode(int) +[113.8]: {{javadoc_uri}}/GeckoSessionSettings.html#getViewportMode() +[113.9]: {{javadoc_uri}}/GeckoSessionSettings.html#setViewportMode(int) +[113.10]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html + +## v112 +- Added `GeckoSession.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE`, see ([bug 1809269]({{bugzilla}}1809269)). +- Added [`GeckoSession.hasCookieBannerRuleForBrowsingContextTree`][112.1] to expose Gecko API nsICookieBannerService::hasRuleForBrowsingContextTree see ([bug 1806740]({{bugzilla}}1806740)) +- Removed deprecated [`Autofill.Node.getDimensions`][110.6] + ([bug 1815830]({{bugzilla}}1815830)) + +[112.1]: {{javadoc_uri}}/GeckoSession.html#hasCookieBannerRuleForBrowsingContextTree() + +## v111 + +- Removed deprecated [`SelectionActionDelegate.Selection.clientRect`][111.10], [`BasicSelectionActionDelegate.mTempMatrix`][111.11] and [`BasicSelectionActionDelegate.mTempRect`][111.12], ([bug 1801615]({{bugzilla}}1801615)) +- Added [`GeckoSession.ContentDelegate.cookieBannerHandlingDetectOnlyMode`][111.2] see ([bug 1810742]({{bugzilla}}1810742)) +- ⚠️ Deprecated [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][111.1] +- Added [`GeckoView.ActivityContextDelegate`][111.3], `setActivityContextDelegate`, and `getActivityContextDelegate` to `GeckoView` +- Added [`GeckoSession.PrintDelegate`][111.4], a [`PrintDocumentAdapter`][111.5], getters and setters for the `PrintDelegate`, and [`printPageContent`] to print [`session content`][111.6] +- Added [`GeckoSession.PdfSaveResult`][111.7], a [`SessionPdfFileSaver`][111.8] and [`isPdfJs`][111.9], see ([bug 1810761]({{bugzilla}}1810761)) + +[111.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY +[111.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingDetectOnlyMode(boolean) +[111.3]: {{javadoc_uri}}/GeckoView.ActivityContextDelegate.html +[111.4]: {{javadoc_uri}}/GeckoSession.PrintDelegate.html +[111.5]: {{javadoc_uri}}/GeckoViewPrintDocumentAdapter.html +[111.6]: {{javadoc_uri}}/GeckoSession.html#printPageContent-- +[111.7]: {{javadoc_uri}}/GeckoSession.PdfSaveResult.html +[111.8]: {{javadoc_uri}}/SessionPdfFileSaver.html +[111.9]: {{javadoc_uri}}/GeckoSession.html#isPdfJs-- +[111.10]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect +[111.11]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix +[111.12]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect + +## v110 +- Added [`GeckoSession.ContentDelegate.onCookieBannerDetected`][110.1] and [`GeckoSession.ContentDelegate.onCookieBannerHandled`][110.2] +- Added [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][110.3], for detecting cookie banners but not handle them, see ([bug 1797581]({{bugzilla}}1806188)) +- Added [`StorageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain`][110.4] see ([bug 1804747]({{bugzilla}}1804747)) +- Added [`Autofill.Node.getScreenRect`][110.5] for fission compatible. +- ⚠️ Deprecated [`Autofill.Node.getDimensions`][110.6]. + ([bug 1803733]({{bugzilla}}1803733)) +- Added [`ColorPrompt.predefinedValues`][110.7] to expose predefined values by [`datalist`][110.8] element in the color prompt. + ([bug 1805616]({{bugzilla}}1805616)) + +[110.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerDetected(org.mozilla.geckoview.GeckoSession) +[110.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerHandled(org.mozilla.geckoview.GeckoSession) +[110.3]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY +[110.4]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeAndPersistInPrivateBrowsingForDomain(java.lang.String,int) +[110.5]: {{javadoc_uri}}/Autofill.Node.html#getScreenRect() +[110.6]: {{javadoc_uri}}/Autofill.Node.html#getDimensions() +[110.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ColorPrompt.html#predefinedValues +[110.8]: https://developer.mozilla.org/en/docs/Web/HTML/Element/datalist + +## v109 +- Added [`SelectionActionDelegate.Selection.screenRect`][109.1] for fission compatible. +- ⚠️ Deprecated [`SelectionActionDelegate.Selection.clientRect`][109.2], + [`BasicSelectionActionDelegate.mTempMatrix`][109.3] and + [`BasicSelectionActionDelegate.mTempRect`][109.4]. + ([bug 1785759]({{bugzilla}}1785759)) +- Added [`StorageController.setCookieBannerModeForDomain`][109.5], [`StorageController.getCookieBannerModeForDomain`][109.6] and [`StorageController.removeCookieBannerModeForDomain`][109.7] see ([bug 1797581]({{bugzilla}}1797581)) + +[109.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#screenRect +[109.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect +[109.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix +[109.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect +[109.5]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeForDomain(java.lang.String,int,boolean) +[109.6]: {{javadoc_uri}}/StorageController.html#getCookieBannerModeForDomain(java.lang.String,boolean) +[109.7]: {{javadoc_uri}}/StorageController.html#removeCookieBannerModeForDomain(java.lang.String,boolean) + +## v108 +- Added [`ContentBlocking.CookieBannerMode`][108.1]; [`cookieBannerHandlingMode`][108.2] and [`cookieBannerHandlingModePrivateBrowsing`][108.3] to [`ContentBlocking.Settings.Builder`][81.1]; + [`getCookieBannerMode`][108.4], [`setCookieBannerMode`][108.5], [`getCookieBannerModePrivateBrowsing`][108.6] and [`setCookieBannerModePrivateBrowsing`][108.7] to [`ContentBlocking.Settings`][81.2] + ([bug 1790724]({{bugzilla}}1790724)) +- Added [`GeckoSession.GeckoPrintException`][108.9] to improver error reporting while generating a PDF from website, ([bug 1798402]({{bugzilla}}1798402)). +- Added [`GeckoSession.containsFormData`][108.10] that returns a `GeckoResult` for whether or not a session has form data, ([bug 1777506]({{bugzilla}}1777506)). + +[108.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html +[108.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingMode(int) +[108.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingModePrivateBrowsing(int) +[108.4]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerMode() +[108.5]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerMode(int) +[108.6]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerModePrivateBrowsing() +[108.7]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerModePrivateBrowsing(int) +[108.9]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html +[108.10]: {{javadoc_uri}}/GeckoSession.html#containsFormData() + +## v107 +- Removed deprecated [`cookieLifetime`][103.2] +- Removed deprecated `setPermission`, see deprecation note in [v90](#v90) + +## v106 +- Added [`SelectionActionDelegate.onShowClipboardPermissionRequest`][106.1], + [`SelectionActionDelegate.onDismissClipboardPermissionRequest`][106.2], + [`BasicSelectionActionDelegate.onShowClipboardPermissionRequest`][106.3], + [`BasicSelectionActionDelegate.onDismissCancelClipboardPermissionRequest`][106.4] and + [`SelectionActionDelegate.ClipboardPermission`][106.5] to handle permission + request for reading clipboard data by [`clipboard.readText`][106.6]. + ([bug 1776829]({{bugzilla}}1776829)) + +[106.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onDismissClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession) +[106.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onDismissClipboardPermission(org.mozilla.geckoview.GeckoSession) +[106.5]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.ClipboardPermission.html +[106.6]: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText + +## v104 +- Removed deprecated Autofill.Delegate `onAutofill`, Autofill.Node `fillViewStructure`, `getFocused`, `getId`, `getValue`, `getVisible`, Autofill.NodeData `Autofill.Notify`, Autofill.Session `surfaceChanged`. + ([bug 1781180]({{bugzilla}}1781180)) +- Removed deprecated `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5] +- Removed deprecated [`GeckoSession.autofill`][102.18]. + ([bug 1781180]({{bugzilla}}1781180)) +- Removed deprecated [`onLocationChange(2)`][102.3] + ([bug 1781180]({{bugzilla}}1781180)) + +## v103 +- Added [`GeckoSession.saveAsPdf`][103.1] that returns a `GeckoResult` that contains a PDF of the current session's page. +- Added missing `@Deprecated` tag for `setPermission`, see deprecation note in [v90](#v90). +- ⚠️ Deprecated [`cookieLifetime`][103.2], this feature is not available anymore. + +[103.1]: {{javadoc_uri}}/GeckoSession.html#saveAsPdf() +[103.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieLifetime(int) + +## v102 +- Added [`DateTimePrompt.stepValue`][102.1] to export [`step`][102.2] attribute of input element. + ([bug 1499635]({{bugzilla}}1499635)) +- Deprecated [`onLocationChange(2)`][102.3], please use [`onLocationChange(3)`][102.4]. +- Added [`GeckoSession.setPriorityHint`][102.5] function to set the session to either high priority or default. +- [`WebRequestError.ERROR_HTTPS_ONLY`][102.6] now has error category + `ERROR_CATEGORY_NETWORK` rather than `ERROR_CATEGORY_SECURITY`. +- ⚠️ The Autofill.Delegate API now receives a [`AutofillNode`][102.7] object instead of + the entire [`Node`][102.8] structure. The `onAutofill` delegate method is now split + into several methods: [`onNodeAdd`][102.9], [`onNodeBlur`][102.10], + [`onNodeFocus`][102.11], [`onNodeRemove`][102.12], [`onNodeUpdate`][102.13], + [`onSessionCancel`][102.14], [`onSessionCommit`][102.15], + [`onSessionStart`][102.16]. +- Added [`PromptInstanceDelegate.onPromptUpdate`][102.17] to allow GeckoView to update current prompts. + ([bug 1758800]({{bugzilla}}1758800)) +- Deprecated [`GeckoSession.autofill`][102.18], use [`Autofill.Session.autofill`][102.19] instead. + ([bug 1770010]({{bugzilla}}1770010)) +- Added [`WebRequestError.ERROR_BAD_HSTS_CERT`][102.20] error code to notify the app of a connection to a site that does not allow error overrides. + ([bug 1721220]({{bugzilla}}1721220)) + +[102.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.DateTimePrompt.html#stepValue +[102.2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#step +[102.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[102.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List) +[102.5]: {{javadoc_uri}}/GeckoSession.html#setPriorityHint(int) +[102.6]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY +[102.7]: {{javadoc_uri}}/Autofill.AutofillNode.html +[102.8]: {{javadoc_uri}}/Autofill.Node.html +[102.9]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeAdd(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.10]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeBlur(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.11]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeFocus(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.12]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeRemove(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.13]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeUpdate(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.14]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCancel(org.mozilla.geckoview.GeckoSession) +[102.15]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCommit(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.16]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionStart(org.mozilla.geckoview.GeckoSession) +[102.17]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html#onPromptUpdate(org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt) +[102.18]: {{javadoc_uri}}/GeckoSession.html#autofill(android.util.SparseArray) +[102.19]: {{javadoc_uri}}/Autofill.Session.html#autofill(android.util.SparseArray) +[102.20]: {{javadoc_uri}}/WebRequestError.html#ERROR_BAD_HSTS_CERT + +## v101 +- Added [`GeckoDisplay.surfaceChanged`][101.1] function taking new type [`GeckoDisplay.SurfaceInfo`][101.2]. + This allows the caller to provide a [`SurfaceControl`][101.3] object, which must be set on SDK level 29 and + above when rendering in to a `SurfaceView`. + ([bug 1762424]({{bugzilla}}1762424)) +- ⚠️ Deprecated old `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5]. +- Add [`WebExtensionController.optionalPrompt`][101.6] to allow handling of optional permission requests from extensions. + +[101.1]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(org.mozilla.geckoview.GeckoDisplay.SurfaceInfo) +[101.2]: {{javadoc_uri}}/GeckoDisplay.SurfaceInfo.html +[101.3]: https://developer.android.com/reference/android/view/SurfaceControl +[101.4]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int) +[101.5]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int,int,int) +[101.6]: {{javadoc_uri}}/WebExtensionController.html#optionalPrompt(org.mozilla.geckoview.WebExtension.Message,org.mozilla.geckoview.WebExtension) + +## v100 +- ⚠️ Changed [`GeckoSession.isOpen`][100.1] to `@UiThread`. +- [`WebNotification`][100.2] now implements [`Parcelable`][100.3] to support + persisting notifications and responding to them while the browser is not + running. +- Removed deprecated `GeckoRuntime.EXTRA_CRASH_FATAL` +- Removed deprecated `MediaSource.rawId` + +[100.1]: {{javadoc_uri}}/GeckoSession.html#isOpen() +[100.2]: {{javadoc_uri}}/WebNotification.html +[100.3]: https://developer.android.com/reference/android/os/Parcelable + +## v99 +- Removed deprecated `GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`. + ([bug 1754244]({{bugzilla}}1754244)) + +## v98 +- Add [`WebRequest.beConservative`][98.1] to allow critical infrastructure to + avoid using bleeding-edge network features. + ([bug 1750231]({{bugzilla}}1750231)) + +[98.1]: {{javadoc_uri}}/WebRequest.html#beConservative + +## v97 +- ⚠️ Deprecated [`MediaSource.rawId`][97.1], + which now provides the same string as [`id`][97.2]. + ([bug 1744346]({{bugzilla}}1744346)) +- Added [`EXTRA_CRASH_PROCESS_TYPE`][97.3] field to `ACTION_CRASHED` intents, + and corresponding [`CRASHED_PROCESS_TYPE_*`][97.4] constants, indicating which + type of process a crash occured in. + ([bug 1743454]({{bugzilla}}1743454)) +- ⚠️ Deprecated [`EXTRA_CRASH_FATAL`][97.5]. Use `EXTRA_CRASH_PROCESS_TYPE` instead. + ([bug 1743454]({{bugzilla}}1743454)) +- Added [`OrientationController`][97.6] to allow GeckoView to handle orientation locking. + ([bug 1697647]({{bugzilla}}1697647)) +- Added [GeckoSession.goBack][97.7] and [GeckoSession.goForward][97.8] with a + `userInteraction` parameter. Updated the default goBack/goForward behaviour + to also be considered as a user interaction. + ([bug 1644595]({{bugzilla}}1644595)) + +[97.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#rawId +[97.2]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#id +[97.3]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_PROCESS_TYPE +[97.4]: {{javadoc_uri}}/GeckoRuntime.html#CRASHED_PROCESS_TYPE_MAIN +[97.5]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_FATAL +[97.6]: {{javadoc_uri}}/OrientationController.html +[97.7]: {{javadoc_uri}}/GeckoSession.html#goBack(boolean) +[97.8]: {{javadoc_uri}}/GeckoSession.html#goForward(boolean) + +## v96 +- Added [`onLoginFetch`][96.1] which allows apps to provide all saved logins to + GeckoView. + ([bug 1733423]({{bugzilla}}1733423)) +- Added [`GeckoResult.finally_`][96.2] to unconditionally run an action after + the GeckoResult has been completed. + ([bug 1736433]({{bugzilla}}1736433)) +- Added [`ERROR_INVALID_DOMAIN`][96.3] to `WebExtension.InstallException.ErrorCodes`. + ([bug 1740634]({{bugzilla}}1740634)) +- Added [`Selection.pasteAsPlainText`][96.4] to paste HTML content as plain + text. + ([bug 1740414]({{bugzilla}}1740414)) +- Removed deprecated Content Blocking APIs. + ([bug 1743706]({{bugzilla}}1743706)) + +[96.1]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html#onLoginFetch() +[96.2]: {{javadoc_uri}}/GeckoResult.html#finally_(java.lang.Runnable) +[96.3]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INVALID_DOMAIN +[96.4]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#pasteAsPlainText() + +## v95 +- Added [`GeckoSession.ContentDelegate.onPointerIconChange()`][95.1] to notify + the application of changing pointer icon. If the application wants to handle + pointer icon, it should override this. + ([bug 1672609]({{bugzilla}}1672609)) +- Deprecated [`ContentBlockingController`][95.2], use + [`StorageController`][95.3] instead. A [`PERMISSION_TRACKING`][95.4] + permission is now present in [`onLocationChange`][95.5] for every page load, + which can be used to set tracking protection exceptions. + ([bug 1714945]({{bugzilla}}1714945)) +- Added [`setPrivateBrowsingPermanentPermission`][95.6], which allows apps to set + permanent permissions in private browsing (e.g. to set permanent tracking + protection permissions in private browsing). + ([bug 1714945]({{bugzilla}}1714945)) +- Deprecated [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7] due to typo. + ([bug 1708815]({{bugzilla}}1708815)) +- Added [`GeckoRuntimeSettings.Builder.enterpriseRootsEnabled`][95.8] to replace [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7]. + ([bug 1708815]({{bugzilla}}1708815)) +- Added [`GeckoSession.ContentDelegate.onPreviewImage`][95.9] to notify + the application of a preview image URL. + ([bug 1732219]({{bugzilla}}1732219)) + +[95.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPointerIconChange(org.mozilla.geckoview.GeckoSession,android.view.PointerIcon) +[95.2]: {{javadoc_uri}}/ContentBlockingController.html +[95.3]: {{javadoc_uri}}/StorageController.java +[95.4]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING +[95.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List) +[95.6]: {{javadoc_uri}}/StorageController.html#setPrivateBrowsingPermanentPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int) +[95.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpiseRootsEnabled(boolean) +[95.8]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean) +[95.9]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPreviewImage(org.mozilla.geckoview.GeckoSession,java.lang.String) + +## v94 +- Extended [`Autocomplete`][78.7] API to support credit card saving. + ([bug 1703976]({{bugzilla}}1703976)) + +## v93 +- Removed deprecated [`Autocomplete.LoginStorageDelegate`][78.8]. + ([bug 1725469]({{bugzilla}}1725469)) +- Removed deprecated [`GeckoRuntime.getProfileDir`][90.5]. + ([bug 1725469]({{bugzilla}}1725469)) +- Added [`PromptInstanceDelegate`][93.1] to allow GeckoView to dismiss stale prompts. + ([bug 1710668]({{bugzilla}}1710668)) +- Added [`WebRequestError.ERROR_HTTPS_ONLY`][93.2] error code to allow GeckoView display custom HTTPS-only error pages and bypass them. + ([bug 1697866]({{bugzilla}}1697866)) + +[93.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html +[93.2]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY + +## v92 +- Added [`PermissionDelegate.PERMISSION_STORAGE_ACCESS`][92.1] to + control the allowing of third-party frames to access first-party cookies and + storage. ([bug 1543720]({{bugzilla}}1543720)) +- Added [`ContentDelegate.onShowDynamicToolbar`][92.2] to notify + the app that it must fully-expand its dynamic toolbar ([bug 1690296]({{bugzilla}}1690296)) +- Removed deprecated `GeckoResult.ALLOW` and `GeckoResult.DENY`. + Use [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9] instead. + +[92.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_STORAGE_ACCESS +[92.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onShowDynamicToolbar(org.mozilla.geckoview.GeckoSession) + +## v91 +- Extended [`Autocomplete`][78.7] API to support addresses. + ([bug 1699794]({{bugzilla}}1699794)). +- Added [`clearDataFromBaseDomain`][91.1] to [`StorageController`][90.2] for + clearing site data by base domain. This includes data of associated subdomains + and data partitioned via [`State Partitioning`][91.3]. +- Removed deprecated `MediaElement` API. + +[91.1]: {{javadoc_uri}}/StorageController.html#clearDataFromBaseDomain(java.lang.String,long) +[91.2]: {{javadoc_uri}}/StorageController.html +[91.3]: https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning + +## v90 +- Added [`WebNotification.silent`][90.1] and [`WebNotification.vibrate`][90.2] + support. See also [Web/API/Notification/silent][90.3] and + [Web/API/Notification/vibrate][90.4]. + ([bug 1696145]({{bugzilla}}1696145)) +- ⚠️ Deprecated [`GeckoRuntime.getProfileDir`][90.5], the API is being kept for + compatibility but it always returns null. +- Added [`forceEnableAccessibility`][90.6] runtime setting to enable + accessibility during testing. + ([bug 1701269]({{bugzilla}}1701269)) +- Removed deprecated [`GeckoView.onTouchEventForResult`][88.4]. + ([bug 1706403]({{bugzilla}}1706403)) +- ⚠️ Updated [`onContentPermissionRequest`][90.7] to use [`ContentPermission`][90.8]; added + [`setPermission`][90.9] to [`StorageController`][90.10] for modifying existing permissions, and + allowed Gecko to handle persisting permissions. +- ⚠️ Added a deprecation schedule to most existing content blocking exception functionality; + other than [`addException`][90.11], content blocking exceptions should be treated as content + permissions going forward. + +[90.1]: {{javadoc_uri}}/WebNotification.html#silent +[90.2]: {{javadoc_uri}}/WebNotification.html#vibrate +[90.3]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent +[90.4]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate +[90.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfileDir() +[90.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setForceEnableAccessibility(boolean) +[90.7]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission) +[90.8]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html +[90.9]: {{javadoc_uri}}/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int) +[90.10]: {{javadoc_uri}}/StorageController.html +[90.11]: {{javadoc_uri}}/ContentBlockingController.html#addException(org.mozilla.geckoview.GeckoSession) + +## v89 +- Added [`ContentPermission`][89.1], which is used to report what permissions content + is loaded with in `onLocationChange`. +- Added [`StorageController.getPermissions`][89.2] and [`StorageController.getAllPermissions`][89.3], + allowing inspection of what permissions have been set for a given URI and for all URIs. +- ⚠️ Deprecated [`NavigationDelegate.onLocationChange`][89.4], to be removed in v92. The + new `onLocationChange` callback simply adds permissions information, migration of existing + functionality should only require updating the function signature. +- Added [`GeckoRuntimeSettings.setEnterpriseRootsEnabled`][89.5] which allows + GeckoView to add third party certificate roots from the Android OS CA store. + ([bug 1678191]({{bugzilla}}1678191)). +- ⚠️ [`GeckoSession.load`][89.6] now throws `IllegalArgumentException` if the + session has no [`GeckoSession.NavigationDelegate`][89.7] and the request's `data` URI is too long. + If a `GeckoSession` *does* have a `GeckoSession.NavigationDelegate` and `GeckoSession.load` is called + with a top-level `data` URI that is too long, [`NavigationDelgate.onLoadError`][89.8] will be called + with a [`WebRequestError`][89.9] containing error code [`WebRequestError.ERROR_DATA_URI_TOO_LONG`][89.10]. + ([bug 1668952]({{bugzilla}}1668952)) +- Extended [`Autocomplete`][78.7] API to support credit cards. + ([bug 1691819]({{bugzilla}}1691819)). +- ⚠️ Deprecated [`Autocomplete.LoginStorageDelegate`][78.8] with the intention + of removing it in GeckoView v93. Please use + [`Autocomplete.StorageDelegate`][89.11] instead. + ([bug 1691819]({{bugzilla}}1691819)). +- Added [`ALLOWED_TRACKING_CONTENT`][89.12] to content blocking API to indicate + when unsafe content is allowed by a shim. + ([bug 1661330]({{bugzilla}}1661330)) +- ⚠️ Added [`setCookieBehaviorPrivateMode`][89.13] to control cookie behavior for private browsing + mode independently of normal browsing mode. To maintain current behavior, set this to the same + value as [`setCookieBehavior`][89.14] is set to. + +[89.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html +[89.2]: {{javadoc_uri}}/StorageController.html#getPermissions(java.lang.String) +[89.3]: {{javadoc_uri}}/StorageController.html#getAllPermissions() +[89.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[89.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setEnterpriseRootsEnabled(boolean) +[89.6]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader) +[89.7]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html +[89.8]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadError(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.WebRequestError) +[89.9]: {{javadoc_uri}}/WebRequestError.html +[89.10]: {{javadoc_uri}}/WebRequestError.html#ERROR_DATA_URI_TOO_LONG +[89.11]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html +[89.12]: {{javadoc_uri}}/ContentBlockingController.Event.html#ALLOWED_TRACKING_CONTENT +[89.13]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehaviorPrivateMode(int) +[89.14]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehavior(int) + +## v88 +- Added [`WebExtension.Download#update`][88.1] that can be used to + implement the WebExtension `downloads` API. This method is used to communicate + updates in the download status to the Web Extension +- Added [`PanZoomController.onTouchEventForDetailResult`][88.2] and + [`GeckoView.onTouchEventForDetailResult`][88.3] to tell information + that the website doesn't expect browser apps to react the event, + also and deprecated [`PanZoomController.onTouchEventForResult`][88.4] + and [`GeckoView.onTouchEventForResult`][88.5]. With these new methods + browser apps can differentiate cases where the browser can do something + the browser's specific behavior in response to the event (e.g. + pull-to-refresh) and cases where the browser should not react to the event + because the event was consumed in the web site (e.g. in canvas like + web apps). + ([bug 1678505]({{bugzilla}}1678505)). +- ⚠️ Deprecate the [`MediaElement`][65.11] API to be removed in v91. + Please use [`MediaSession`][81.6] for media events and control. + ([bug 1693584]({{bugzilla}}1693584)). +- ⚠️ Deprecate [`GeckoResult.ALLOW`][89.6] and [`GeckoResult.DENY`][89.7] in + favor of [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9]. + ([bug 1697270]({{bugzilla}}1697270)). +- ⚠️ Update [`SessionState`][88.10] to handle null states/strings more gracefully. + ([bug 1685486]({{bugzilla}}1685486)). + +[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update(org.mozilla.geckoview.WebExtension.Download.Info) +[88.2]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForDetailResult +[88.3]: {{javadoc_uri}}/GeckoView.html#onTouchEventForDetailResult +[88.4]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForResult +[88.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult +[88.6]: {{javadoc_uri}}/GeckoResult.html#ALLOW +[88.7]: {{javadoc_uri}}/GeckoResult.html#DENY +[88.8]: {{javadoc_uri}}/GeckoResult.html#allow() +[88.9]: {{javadoc_uri}}/GeckoResult.html#deny() +[88.10]: {{javadoc_uri}}/GeckoSession.SessionState.html + +## v87 +- ⚠️ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to + implement the WebExtension `downloads` API. This class represents initial state of a download. +- Added [`WebExtension.Download.Info`][87.2] interface that can be used to + implement the WebExtension `downloads` API. This interface allows communicating + download's state to Web Extension. +- [`Image#getBitmap`][87.3] now throws [`ImageProcessingException`][87.4] if + the image cannot be processed. + ([bug 1689745]({{bugzilla}}1689745)) +- Added support for HTTPS-only mode to [`GeckoRuntimeSettings`][87.5] via + [`setAllowInsecureConnections`][87.6]. +- Removed `JSONException` throws from [`SessionState.fromString`][87.7], fixed annotations, + and clarified null-handling a bit. + +[87.1]: {{javadoc_uri}}/WebExtension.DownloadInitData.html +[87.2]: {{javadoc_uri}}/WebExtension.Download.Info.html +[87.3]: {{javadoc_uri}}/Image.html#getBitmap(int) +[87.4]: {{javadoc_uri}}/Image.ImageProcessingException.html +[87.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html +[87.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAllowInsecureConnections(int) +[87.7]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String) + +## v86 +- Removed deprecated `ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`. + Use [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] instead. + ([bug 1665157]({{bugzilla}}1665157)) +- Added [`WebExtension.DownloadDelegate`][86.1] and that can be used to + implement the WebExtension `downloads` API. + ([bug 1656336]({{bugzilla}}1656336)) +- Added [`WebRequest.Builder#body(@Nullable String)`][86.2] which converts a string to direct byte buffer. +- Removed deprecated `REPLACED_UNSAFE_CONTENT`. + ([bug 1667471]({{bugzilla}}1667471)) +- Removed deprecated [`GeckoSession#loadUri`][83.6] variants in favor of + [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8]. + ([bug 1667471]({{bugzilla}}1667471)) +- Added [`GeckoResult#map`][86.3] to synchronously map a GeckoResult value. +- Added [`PanZoomController#INPUT_RESULT_IGNORED`][86.4]. + ([bug 1687430]({{bugzilla}}1687430)) + +[86.1]: {{javadoc_uri}}/WebExtension.DownloadDelegate.html +[86.2]: {{javadoc_uri}}/WebRequest.Builder#body(java.lang.String) +[86.3]: {{javadoc_uri}}/GeckoResult.html#map(org.mozilla.geckoview.GeckoResult.OnValueMapper) +[86.4]: {{javadoc_uri}}/PanZoomController.html#INPUT_RESULT_IGNORED + +## v85 +- Added [`WebExtension.BrowsingDataDelegate`][85.1] that can be used to + implement the WebExtension `browsingData` API. + +[85.1]: {{javadoc_uri}}/WebExtension.BrowsingDataDelegate.html + +## v84 +- ⚠️ Removed deprecated `GeckoRuntimeSettings.Builder.useMultiprocess` and + [`GeckoRuntimeSettings.getUseMultiprocess`]. Single-process GeckoView is no + longer supported. ([bug 1650118]({{bugzilla}}1650118)) +- Deprecated members now have an additional [`@DeprecationSchedule`][84.1] annotation which + includes the `version` that we expect to remove the member and an `id` that + can be used to group annotation notices in tooling. + ([bug 1671460]({{bugzilla}}1671460)) +- ⚠️ Removed deprecated `ContentBlockingController.ExceptionList` and + `ContentBlockingController.restoreExceptionList`. ([bug 1674500]({{bugzilla}}1674500)) + +[84.1]: {{javadoc_uri}}/DeprecationSchedule.html + +## v83 +- Added [`WebExtension.MetaData.temporary`][83.1] which exposes whether an extension + has been installed temporarily, e.g. when using web-ext. + ([bug 1624410]({{bugzilla}}1624410)) +- ⚠️ Removing unsupported `MediaSession.Delegate.onPictureInPicture` for now. + Also, [`MediaSession.Delegate.onMetadata`][83.2] is no longer dispatched for + plain media elements. + ([bug 1658937]({{bugzilla}}1658937)) +- Replaced android.util.ArrayMap with java.util.TreeMap in [`WebMessage`][65.13] to enable case-insensitive handling of the HTTP headers. + ([bug 1666013]({{bugzilla}}1666013)) +- Added [`ContentBlocking.SafeBrowsingProvider`][83.3] to configure Safe + Browsing providers. + ([bug 1660241]({{bugzilla}}1660241)) +- Added [`GeckoRuntime.ActivityDelegate`][83.4] which allows applications to handle + starting external Activities on behalf of GeckoView. Currently this is used to integrate + FIDO support for WebAuthn. +- Added [`GeckoWebExecutor#FETCH_FLAG_PRIVATE`][83.5]. This new flag allows for private browsing downloads using WebExecutor. + ([bug 1665426]({{bugzilla}}1665426)) +- ⚠️ Deprecated [`GeckoSession#loadUri`][83.6] variants in favor of + [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8]. + ([bug 1667471]({{bugzilla}}1667471)) +- Added [`Loader#headerFilter`][83.9] to override the default header filtering + behavior. + ([bug 1667471]({{bugzilla}}1667471)) + +[83.1]: {{javadoc_uri}}/WebExtension.MetaData.html#temporary +[83.2]: {{javadoc_uri}}/MediaSession.Delegate.html#onMetadata(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.MediaSession,org.mozilla.geckoview.MediaSession.Metadata) +[83.3]: {{javadoc_uri}}/ContentBlocking.SafeBrowsingProvider.html +[83.4]: {{javadoc_uri}}/GeckoRuntime.ActivityDelegate.html +[83.5]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAG_PRIVATE +[83.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int,java.util.Map) +[83.7]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader) +[83.8]: {{javadoc_uri}}/GeckoSession.Loader.html +[83.9]: {{javadoc_uri}}/GeckoSession.Loader.html#headerFilter(int) + +## v82 +- ⚠️ [`WebNotification.source`][79.2] is now `@Nullable` to account for + WebExtension notifications which don't have a `source` field. +- ⚠️ Deprecated [`ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`][82.1] with the intention of removing + them in GeckoView v85. + ([bug 1530022]({{bugzilla}}1530022)) +- Added [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] to eliminate the need + to make a second request for downloads and ensure more efficient and reliable downloads in a single request. The second + parameter is now a [`WebResponse`][65.15] + ([bug 1530022]({{bugzilla}}1530022)) +- Added [`Image`][82.3] support for size-dependent bitmap retrieval from image resources. + ([bug 1658456]({{bugzilla}}1658456)) +- ⚠️ Use [`Image`][82.3] for [`MediaSession`][81.6] artwork and [`WebExtension`][69.5] icon support. + ([bug 1662508]({{bugzilla}}1662508)) +- Added [`RepostConfirmPrompt`][82.4] to prompt the user for cofirmation before + resending POST requests. + ([bug 1659073]({{bugzilla}}1659073)) +- Removed `Parcelable` support in `GeckoSession`. Use [`ProgressDelegate#onSessionStateChange`][68.29] and [`ProgressDelegate#restoreState`][82.5] instead. + ([bug 1650108]({{bugzilla}}1650108)) +- ⚠️ Use AndroidX instead of the Android support library. For the public API this only changes + the thread and nullable annotation types. +- Added [`REPLACED_TRACKING_CONTENT`][82.6] to content blocking API to indicate when unsafe content is shimmed. + ([bug 1663756]({{bugzilla}}1663756)) + +[82.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.WebResponseInfo) +[82.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoResult) +[82.3]: {{javadoc_uri}}/Image.html +[82.4]: {{javadoc_uri}}/GeckoSession.PromptDelegate.RepostConfirmPrompt.html +[82.5]: {{javadoc_uri}}/GeckoSession.html#restoreState(org.mozilla.geckoview.GeckoSession.SessionState) +[82.6]: {{javadoc_uri}}/ContentBlockingController.Event.html#REPLACED_TRACKING_CONTENT + +## v81 +- Added `cookiePurging` to [`ContentBlocking.Settings.Builder`][81.1] and `getCookiePurging` and `setCookiePurging` + to [`ContentBlocking.Settings`][81.2]. +- Added [`GeckoSession.ContentDelegate.onPaintStatusReset()`][81.3] callback which notifies when valid content is no longer being rendered. +- Made [`GeckoSession.ContentDelegate.onFirstContentfulPaint()`][81.4] additionally be called for the first contentful paint following a `onPaintStatusReset()` event, rather than just the first contentful paint of the session. +- Removed deprecated `GeckoRuntime.registerWebExtension`. Use [`WebExtensionController.install`][73.1] instead. +⚠️ - Changed [`GeckoView.onTouchEventForResult`][81.5] to return a `GeckoResult`, as it now +makes a round-trip to Gecko. The result will be more accurate now, since how content treats +the event is now considered. +- Added [`MediaSession`][81.6] API for session-based media events and control. + +[81.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html +[81.2]: {{javadoc_uri}}/ContentBlocking.Settings.html +[81.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPaintStatusReset(org.mozilla.geckoview.GeckoSession) +[81.4]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession) +[81.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent) +[81.6]: {{javadoc_uri}}/MediaSession.html + +## v80 +- Removed `GeckoSession.hashCode` and `GeckoSession.equals` overrides in favor + of the default implementations. ([bug 1647883]({{bugzilla}}1647883)) +- Added `strictSocialTrackingProtection` to [`ContentBlocking.Settings.Builder`][80.1] and `getStrictSocialTrackingProtection` + to [`ContentBlocking.Settings`][80.2]. + +[80.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html +[80.2]: {{javadoc_uri}}/ContentBlocking.Settings.html + +## v79 +- Added `runtime.openOptionsPage` support. For `options_ui.open_in_new_tab == + false`, [`TabDelegate.onOpenOptionsPage`][79.1] is called. + ([bug 1618058]({{bugzilla}}1619766)) +- Added [`WebNotification.source`][79.2], which is the URL of the page + or Service Worker that created the notification. +- Removed deprecated `WebExtensionController.setTabDelegate` and `WebExtensionController.getTabDelegate` + APIs ([bug 1618987]({{bugzilla}}1618987)). +- ⚠️ [`RuntimeTelemetry#getSnapshots`][68.10] is removed after deprecation. + Use Glean to handle Gecko telemetry. + ([bug 1644447]({{bugzilla}}1644447)) +- Added [`ensureBuiltIn`][79.3] that ensures that a built-in extension is + installed without re-installing. + ([bug 1635564]({{bugzilla}}1635564)) +- Added [`ProfilerController`][79.4], accessible via [`GeckoRuntime.getProfilerController`][79.5] +to allow adding gecko profiler markers. +([bug 1624993]({{bugzilla}}1624993)) +- ⚠️ Deprecated `Parcelable` support in `GeckoSession` with the intention of removing + in GeckoView v82. ([bug 1649529]({{bugzilla}}1649529)) +- ⚠️ Deprecated [`GeckoRuntimeSettings.Builder.useMultiprocess`][79.6] and + [`GeckoRuntimeSettings.getUseMultiprocess`][79.7] with the intention of removing + them in GeckoView v82. ([bug 1649530]({{bugzilla}}1649530)) + +[79.1]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onOpenOptionsPage(org.mozilla.geckoview.WebExtension) +[79.2]: {{javadoc_uri}}/WebNotification.html#source +[79.3]: {{javadoc_uri}}/WebExtensionController.html#ensureBuiltIn(java.lang.String,java.lang.String) +[79.4]: {{javadoc_uri}}/ProfilerController.html +[79.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfilerController() +[79.6]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean) +[79.7]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getUseMultiprocess() + +## v78 +- Added [`WebExtensionController.installBuiltIn`][78.1] that allows installing an + extension that is bundled with the APK. This method is meant as a replacement + for [`GeckoRuntime.registerWebExtension`][67.15], ⚠️ which is now deprecated + and will be removed in GeckoView 81. +- Added [`CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS`][78.2] to allow + enabling dynamic first party isolation; this will block tracking cookies and + isolate all other third party cookies by keying them based on the first party + from which they are accessed. +- Added `cookieStoreId` field to [`WebExtension.CreateTabDetails`][78.3]. This adds the optional + ability to create a tab with a given cookie store ID for its [`contextual identity`][78.4]. + ([bug 1622500]({{bugzilla}}1622500)) +- Added [`NavigationDelegate.onSubframeLoadRequest`][78.5] to allow intercepting + non-top-level navigations. +- Added [`BeforeUnloadPrompt`][78.6] to respond to prompts from onbeforeunload. +- ⚠️ Refactored `LoginStorage` to the [`Autocomplete`][78.7] API to support + login form autocomplete delegation. + Refactored `LoginStorage.Delegate` to [`Autocomplete.LoginStorageDelegate`][78.8]. + Refactored `GeckoSession.PromptDelegate.onLoginStoragePrompt` to + [`GeckoSession.PromptDelegate.onLoginSave`][78.9]. + Added [`GeckoSession.PromptDelegate.onLoginSelect`][78.10]. + ([bug 1618058]({{bugzilla}}1618058)) +- Added [`GeckoRuntimeSettings#setLoginAutofillEnabled`][78.11] to control + whether login forms should be automatically filled in suitable situations. + +[78.1]: {{javadoc_uri}}/WebExtensionController.html#installBuiltIn(java.lang.String) +[78.2]: {{javadoc_uri}}/ContentBlocking.CookieBehavior.html#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS +[78.3]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html +[78.4]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities +[78.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onSubframeLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest) +[78.6]: {{javadoc_uri}}/GeckoSession.PromptDelegate.BeforeUnloadPrompt.html +[78.7]: {{javadoc_uri}}/Autocomplete.html +[78.8]: {{javadoc_uri}}/Autocomplete.LoginStorageDelegate.html +[78.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSave(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest) +[78.10]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSelect(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest) +[78.11]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLoginAutofillEnabled(boolean) + +## v77 +- Added [`GeckoRuntime.appendAppNotesToCrashReport`][77.1] For adding app notes to the crash report. + ([bug 1626979]({{bugzilla}}1626979)) +- ⚠️ Remove the `DynamicToolbarAnimator` API along with accesors on `GeckoView` and `GeckoSession`. + ([bug 1627716]({{bugzilla}}1627716)) + +[77.1]: {{javadoc_uri}}/GeckoRuntime.html#appendAppNotesToCrashReport(java.lang.String) + +## v76 +- Added [`GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS`][76.1] to control EME media key access. +- [`RuntimeTelemetry#getSnapshots`][68.10] is deprecated and will be removed + in 79. Use Glean to handle Gecko telemetry. + ([bug 1620395]({{bugzilla}}1620395)) +- Added `LoadRequest.isDirectNavigation` to know when calls to + [`onLoadRequest`][76.3] originate from a direct navigation made by the app + itself. + ([bug 1624675]({{bugzilla}}1624675)) + +[76.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS +[76.2]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isDirectNavigation +[76.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest) + +## v75 +- ⚠️ Remove `GeckoRuntimeSettings.Builder#useContentProcessHint`. The content + process is now preloaded by default if + [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1] is enabled. +- ⚠️ Move `GeckoSessionSettings.Builder#useMultiprocess` to + [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1]. Multiprocess state is + no longer determined per session. +- Added [`DebuggerDelegate#onExtensionListUpdated`][75.2] to notify that a temporary + extension has been installed by the debugger. + ([bug 1614295]({{bugzilla}}1614295)) +- ⚠️ Removed [`GeckoRuntimeSettings.setAutoplayDefault`][75.3], use + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13] to + control autoplay. + ([bug 1614894]({{bugzilla}}1614894)) +- Added [`GeckoSession.reload(int flags)`][75.4] That takes a [load flag][75.5] parameter. +- ⚠️ Moved [`ActionDelegate`][75.6] and [`MessageDelegate`][75.7] to + [`SessionController`][75.8]. + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`SessionTabDelegate`][75.9] to [`SessionController`][75.8] and + [`TabDelegate`][75.10] to [`WebExtension`][69.5] which receive respectively + calls for the session and the runtime. `TabDelegate` is also now + per-`WebExtension` object instead of being global. The existing global + [`TabDelegate`][75.11] is now deprecated and will be removed in GeckoView 77. + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`SessionTabDelegate#onUpdateTab`][75.12] which is called whenever an + extension calls `tabs.update` on the corresponding `GeckoSession`. + [`TabDelegate#onCreateTab`][75.13] now takes a [`CreateTabDetails`][75.14] + object which contains additional information about the newly created tab + (including the `url` which used to be passed in directly). + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`GeckoRuntimeSettings.setWebManifestEnabled`][75.15], + [`GeckoRuntimeSettings.webManifest`][75.16], and + [`GeckoRuntimeSettings.getWebManifestEnabled`][75.17] + ([bug 1614894]({{bugzilla}}1603673)), to enable or check Web Manifest support. +- Added [`GeckoDisplay.safeAreaInsetsChanged`][75.18] to notify the content of [safe area insets][75.19]. + ([bug 1503656]({{bugzilla}}1503656)) +- Added [`GeckoResult#cancel()`][75.22], [`GeckoResult#setCancellationDelegate()`][75.22], + and [`GeckoResult.CancellationDelegate`][75.23]. This adds the optional ability to cancel + an operation behind a pending `GeckoResult`. +- Added [`baseUrl`][75.24] to [`WebExtension.MetaData`][75.25] to expose the + base URL for all WebExtension pages for a given extension. + ([bug 1560048]({{bugzilla}}1560048)) +- Added [`allowedInPrivateBrowsing`][75.26] and + [`setAllowedInPrivateBrowsing`][75.27] to control whether an extension can + run in private browsing or not. Extensions installed with + [`registerWebExtension`][67.15] will always be allowed to run in private + browsing. + ([bug 1599139]({{bugzilla}}1599139)) + +[75.1]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean) +[75.2]: {{javadoc_uri}}/WebExtensionController.DebuggerDelegate.html#onExtensionListUpdated() +[75.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#autoplayDefault(boolean) +[75.4]: {{javadoc_uri}}/GeckoSession.html#reload(int) +[75.5]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_NONE +[75.6]: {{javadoc_uri}}/WebExtension.ActionDelegate.html +[75.7]: {{javadoc_uri}}/WebExtension.MessageDelegate.html +[75.8]: {{javadoc_uri}}/WebExtension.SessionController.html +[75.9]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html +[75.10]: {{javadoc_uri}}/WebExtension.TabDelegate.html +[75.11]: {{javadoc_uri}}/WebExtensionRuntime.TabDelegate.html +[75.12]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html#onUpdateTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebExtension.UpdateTabDetails) +[75.13]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onNewTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.CreateTabDetails) +[75.14]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html +[75.15]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#setWebManifestEnabled(boolean) +[75.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#webManifest(boolean) +[75.17]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#getWebManifestEnabled() +[75.18]: {{javadoc_uri}}/GeckoDisplay.html#safeAreaInsetsChanged(int,int,int,int) +[75.19]: https://developer.mozilla.org/en-US/docs/Web/CSS/env +[75.20]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_POSTPONED +[75.21]: {{javadoc_uri}}/GeckoResult.html#cancel() +[75.22]: {{javadoc_uri}}/GeckoResult.html#setCancellationDelegate(CancellationDelegate) +[75.23]: {{javadoc_uri}}/GeckoResult.CancellationDelegate.html +[75.24]: {{javadoc_uri}}/WebExtension.MetaData.html#baseUrl +[75.25]: {{javadoc_uri}}/WebExtension.MetaData.html +[75.26]: {{javadoc_uri}}/WebExtension.MetaData.html#allowedInPrivateBrowsing +[75.27]: {{javadoc_uri}}/WebExtensionController.html#setAllowedInPrivateBrowsing(org.mozilla.geckoview.WebExtension,boolean) + +## v74 +- Added [`WebExtensionController.enable`][74.1] and [`disable`][74.2] to + enable and disable extensions. + ([bug 1599585]({{bugzilla}}1599585)) +- ⚠️ Added [`GeckoSession.ProgressDelegate.SecurityInformation#certificate`][74.3], which is the + full server certificate in use, if any. The other certificate-related fields were removed. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebResponse#isSecure`][74.4], which indicates whether or not the response was + delivered over a secure connection. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebResponse#certificate`][74.5], which is the server certificate used for the + response, if any. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebRequestError#certificate`][74.6], which is the server certificate used in the + failed request, if any. + ([bug 1508730]({{bugzilla}}1508730)) +- ⚠️ Updated [`ContentBlockingController`][74.7] to use new representation for content blocking + exceptions and to add better support for removing exceptions. This deprecates [`ExceptionList`][74.8] + and [`restoreExceptionList`][74.9] with the intent to remove them in 76. + ([bug 1587552]({{bugzilla}}1587552)) +- Added [`GeckoSession.ContentDelegate.onMetaViewportFitChange`][74.10]. This exposes `viewport-fit` value that is CSS Round Display Level 1. ([bug 1574307]({{bugzilla}}1574307)) +- Extended [`LoginStorage.Delegate`][74.11] with [`onLoginUsed`][74.12] to + report when existing login entries are used for autofill. + ([bug 1610353]({{bugzilla}}1610353)) +- Added [`WebExtensionController#setTabActive`][74.13], which is used to notify extensions about + tab changes + ([bug 1597793]({{bugzilla}}1597793)) +- Added [`WebExtension.metaData.optionsUrl`][74.14] and [`WebExtension.metaData.openOptionsPageInTab`][74.15], + which is the addon metadata necessary to show their option pages. + ([bug 1598792]({{bugzilla}}1598792)) +- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581)) +- ⚠️ Replaced `subscription` argument in [`WebPushDelegate.onSubscriptionChanged`][74.17] from a [`WebPushSubscription`][74.18] to the [`String`][74.19] `scope`. + +[74.1]: {{javadoc_uri}}/WebExtensionController.html#enable(org.mozilla.geckoview.WebExtension,int) +[74.2]: {{javadoc_uri}}/WebExtensionController.html#disable(org.mozilla.geckoview.WebExtension,int) +[74.3]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html#certificate +[74.4]: {{javadoc_uri}}/WebResponse.html#isSecure +[74.5]: {{javadoc_uri}}/WebResponse.html#certificate +[74.6]: {{javadoc_uri}}/WebRequestError.html#certificate +[74.7]: {{javadoc_uri}}/ContentBlockingController.html +[74.8]: {{javadoc_uri}}/ContentBlockingController.ExceptionList.html +[74.9]: {{javadoc_uri}}/ContentBlockingController.html#restoreExceptionList(org.mozilla.geckoview.ContentBlockingController.ExceptionList) +[74.10]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onMetaViewportFitChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[74.11]: {{javadoc_uri}}/LoginStorage.Delegate.html +[74.12]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginUsed(org.mozilla.geckoview.LoginStorage.LoginEntry,int) +[74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive +[74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl +[74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab +[74.16]: {{javadoc_uri}}/WebExtensionController.html#update(org.mozilla.geckoview.WebExtension,int) +[74.17]: {{javadoc_uri}}/WebPushController.html#onSubscriptionChange(org.mozilla.geckoview.WebPushSubscription,byte[]) +[74.18]: {{javadoc_uri}}/WebPushSubscription.html +[74.19]: https://developer.android.com/reference/java/lang/String + +## v73 +- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to + manage installed extensions +- ⚠️ Renamed `ScreenLength.VIEWPORT_WIDTH`, `ScreenLength.VIEWPORT_HEIGHT`, + `ScreenLength.fromViewportWidth` and `ScreenLength.fromViewportHeight` to + [`ScreenLength.VISUAL_VIEWPORT_WIDTH`][73.3], + [`ScreenLength.VISUAL_VIEWPORT_HEIGHT`][73.4], + [`ScreenLength.fromVisualViewportWidth`][73.5] and + [`ScreenLength.fromVisualViewportHeight`][73.6] respectively. +- Added the [`LoginStorage`][73.7] API. Apps may handle login fetch requests now by + attaching a [`LoginStorage.Delegate`][73.8] via + [`GeckoRuntime#setLoginStorageDelegate`][73.9] + ([bug 1602881]({{bugzilla}}1602881)) +- ⚠️ [`WebExtension`][69.5]'s constructor now requires a `WebExtensionController` + instance. +- Added [`GeckoResult.allOf`][73.10] for consuming a list of results. +- Added [`WebExtensionController.list`][73.11] to list all installed extensions. +- Added [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13]. These control + autoplay permissions for audible and inaudible videos. + ([bug 1577596]({{bugzilla}}1577596)) +- Added [`LoginStorage.Delegate.onLoginSave`][73.14] for login storage save + requests and [`GeckoSession.PromptDelegate.onLoginStoragePrompt`][73.15] for + login storage prompts. + ([bug 1599873]({{bugzilla}}1599873)) + +[73.1]: {{javadoc_uri}}/WebExtensionController.html#install(java.lang.String) +[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall(org.mozilla.geckoview.WebExtension) +[73.3]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_WIDTH +[73.4]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_HEIGHT +[73.5]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportWidth(double) +[73.6]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportHeight(double) +[73.7]: {{javadoc_uri}}/LoginStorage.html +[73.8]: {{javadoc_uri}}/LoginStorage.Delegate.html +[73.9]: {{javadoc_uri}}/GeckoRuntime.html#setLoginStorageDelegate(org.mozilla.geckoview.LoginStorage.Delegate) +[73.10]: {{javadoc_uri}}/GeckoResult.html#allOf(java.util.List) +[73.11]: {{javadoc_uri}}/WebExtensionController.html#list() +[73.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE +[73.13]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE +[73.14]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginSave(org.mozilla.geckoview.LoginStorage.LoginEntry) +[73.15]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginStoragePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.LoginStoragePrompt) + +## v72 +- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates + if a load was requested while a user gesture was active (e.g., a tap). + ([bug 1555337]({{bugzilla}}1555337)) +- ⚠️ Refactored `AutofillElement` and `AutofillSupport` into the + [`Autofill`][72.2] API. + ([bug 1591462]({{bugzilla}}1591462)) +- Make `read()` in the `InputStream` returned from [`WebResponse#body`][72.3] timeout according + to [`WebResponse#setReadTimeoutMillis()`][72.4]. The default timeout value is reflected in + [`WebResponse#DEFAULT_READ_TIMEOUT_MS`][72.5], currently 30s. + ([bug 1595145]({{bugzilla}}1595145)) +- ⚠️ Removed `GeckoResponse` + ([bug 1581161]({{bugzilla}}1581161)) +- ⚠️ Removed `actions` and `response` arguments from [`SelectionActionDelegate.onShowActionRequest`][72.6] + and [`BasicSelectionActionDelegate.onShowActionRequest`][72.7] + ([bug 1581161]({{bugzilla}}1581161)) +- Added text selection action methods to [`SelectionActionDelegate.Selection`][72.8] + ([bug 1581161]({{bugzilla}}1581161)) +- Added [`BasicSelectionActionDelegate.getSelection`][72.9] + ([bug 1581161]({{bugzilla}}1581161)) +- Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public. + ([bug 1581161]({{bugzilla}}1581161)) +- Added `Autofill` commit support. + ([bug 1577005]({{bugzilla}}1577005)) +- Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be + backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13]. + ([bug 1530402]({{bugzilla}}1530402)) +- Added support for Browser and Page Action from the WebExtension API. + See [`WebExtension.Action`][72.14]. + ([bug 1530402]({{bugzilla}}1530402)) +- ⚠️ Split [`ContentBlockingController.Event.LOADED_TRACKING_CONTENT`][72.15] into + [`ContentBlockingController.Event.LOADED_LEVEL_1_TRACKING_CONTENT`][72.16] and + [`ContentBlockingController.Event.LOADED_LEVEL_2_TRACKING_CONTENT`][72.17]. +- Replaced `subscription` argument in [`WebPushDelegate.onPushEvent`][72.18] from a [`WebPushSubscription`][72.19] to the [`String`][72.20] `scope`. +- ⚠️ Renamed `WebExtension.ActionIcon` to [`Icon`][72.21]. +- Added [`GeckoWebExecutor#FETCH_FLAGS_STREAM_FAILURE_TEST`][72.22], which is a new + flag used to immediately fail when reading a `WebResponse` body. + ([bug 1594905]({{bugzilla}}1594905)) +- Changed [`CrashReporter#sendCrashReport(Context, File, JSONObject)`][72.23] to + accept a JSON object instead of a Map. Said object also includes the + application name that was previously passed as the fourth argument to the + method, which was thus removed. +- Added WebXR device access permission support, [`PERMISSION_PERSISTENT_XR`][72.24]. + ([bug 1599927]({{bugzilla}}1599927)) + +[72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture +[72.2]: {{javadoc_uri}}/Autofill.html +[72.3]: {{javadoc_uri}}/WebResponse.html#body +[72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis(long) +[72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS +[72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection) +[72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection) +[72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html +[72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection +[72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection +[72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend(int) +[72.12]: https://developer.android.com/reference/android/view/TextureView +[72.13]: https://developer.android.com/reference/android/view/SurfaceView +[72.14]: {{javadoc_uri}}/WebExtension.Action.html +[72.15]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_TRACKING_CONTENT +[72.16]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_1_TRACKING_CONTENT +[72.17]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_2_TRACKING_CONTENT +[72.18]: {{javadoc_uri}}/WebPushController.html#onPushEvent(org.mozilla.geckoview.WebPushSubscription,byte[]) +[72.19]: {{javadoc_uri}}/WebPushSubscription.html +[72.20]: https://developer.android.com/reference/java/lang/String +[72.21]: {{javadoc_uri}}/WebExtension.Icon.html +[72.22]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_STREAM_FAILURE_TEST +[72.23]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,org.json.JSONObject) +[72.24]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_XR + +## v71 +- Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17]. + ([bug 1584479]({{bugzilla}}1584479)) +- Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2], + [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support + scalars in streaming telemetry. ⚠️ As part of this change, + `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and + [`Metric`][71.5] now takes a type parameter. + ([bug 1576730]({{bugzilla}}1576730)) +- Added overloads of [`GeckoSession.loadUri`][71.6] that accept a map of + additional HTTP request headers. + ([bug 1567549]({{bugzilla}}1567549)) +- Added support for exposing the content blocking log in [`ContentBlockingController`][71.7]. + ([bug 1580201]({{bugzilla}}1580201)) +- ⚠️ Added `nativeApp` to [`WebExtension.MessageDelegate.onMessage`][71.8] which + exposes the native application identifier that was used to send the message. + ([bug 1546445]({{bugzilla}}1546445)) +- Added [`GeckoRuntime.ServiceWorkerDelegate`][71.9] set via + [`setServiceWorkerDelegate`][71.10] to support [`ServiceWorkerClients.openWindow`][71.11] + ([bug 1511033]({{bugzilla}}1511033)) +- Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or + not `about:config` should be available. + ([bug 1540065]({{bugzilla}}1540065)) +- Added [`GeckoSession.ContentDelegate.onFirstContentfulPaint`][71.13] + ([bug 1578947]({{bugzilla}}1578947)) +- Added `setEnhancedTrackingProtectionLevel` to [`ContentBlocking.Settings`][71.14]. + ([bug 1580854]({{bugzilla}}1580854)) +- ⚠️ Added [`GeckoView.onTouchEventForResult`][71.15] and modified + [`PanZoomController.onTouchEvent`][71.16] to return how the touch event was handled. This + allows apps to know if an event is handled by touch event listeners in web content. The methods in `PanZoomController` now return `int` instead of `boolean`. +- Added [`GeckoSession.purgeHistory`][71.17] allowing apps to clear a session's history. + ([bug 1583265]({{bugzilla}}1583265)) +- Added [`GeckoRuntimeSettings.Builder#forceUserScalableEnabled`][71.18] to control whether or + not to force user scalable zooming. + ([bug 1540615]({{bugzilla}}1540615)) +- ⚠️ Moved Autofill related methods from `SessionTextInput` and `GeckoSession.TextInputDelegate` + into `GeckoSession` and `AutofillDelegate`. +- Added [`GeckoSession.getAutofillElements()`][71.19], which is a new method for getting + an autofill virtual structure without using `ViewStructure`. It relies on a new class, + [`AutofillElement`][71.20], for representing the virtual tree. +- Added [`GeckoView.setAutofillEnabled`][71.21] for controlling whether or not the `GeckoView` + instance participates in Android autofill. When enabled, this connects an `AutofillDelegate` + to the session it holds. +- Changed [`AutofillElement.children`][71.20] interface to `Collection` to provide + an efficient way to pre-allocate memory when filling `ViewStructure`. +- Added [`GeckoSession.PromptDelegate.onSharePrompt`][71.22] to support the WebShare API. + ([bug 1402369]({{bugzilla}}1402369)) +- Added [`GeckoDisplay.screenshot`][71.23] allowing apps finer grain control over screenshots. + ([bug 1577192]({{bugzilla}}1577192)) +- Added `GeckoView.setDynamicToolbarMaxHeight` to make ICB size static, ICB doesn't include the dynamic toolbar region. + ([bug 1586144]({{bugzilla}}1586144)) + +[71.1]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onBooleanScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.2]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onLongScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.3]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onStringScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.4]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onHistogram(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.5]: {{javadoc_uri}}/RuntimeTelemetry.Metric.html +[71.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,java.io.File,java.util.Map) +[71.7]: {{javadoc_uri}}/ContentBlockingController.html +[71.8]: {{javadoc_uri}}/WebExtension.MessageDelegate.html#onMessage(java.lang.String,java.lang.Object,org.mozilla.geckoview.WebExtension.MessageSender) +[71.9]: {{javadoc_uri}}/GeckoRuntime.ServiceWorkerDelegate.html +[71.10]: {{javadoc_uri}}/GeckoRuntime#setServiceWorkerDelegate(org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate) +[71.11]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow +[71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean) +[71.13]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession) +[71.15]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent) +[71.16]: {{javadoc_uri}}/PanZoomController.html#onTouchEvent(android.view.MotionEvent) +[71.17]: {{javadoc_uri}}/GeckoSession.html#purgeHistory() +[71.18]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#forceUserScalableEnabled(boolean) +[71.19]: {{javadoc_uri}}/GeckoSession.html#getAutofillElements() +[71.20]: {{javadoc_uri}}/AutofillElement.html +[71.21]: {{javadoc_uri}}/GeckoView.html#setAutofillEnabled(boolean) +[71.22]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSharePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt) +[71.23]: {{javadoc_uri}}/GeckoDisplay.html#screenshot() + +## v70 +- Added API for session context assignment + [`GeckoSessionSettings.Builder.contextId`][70.1] and deletion of data related + to a session context [`StorageController.clearDataForSessionContext`][70.2]. + ([bug 1501108]({{bugzilla}}1501108)) +- Removed `setSession(session, runtime)` from [`GeckoView`][70.5]. With this + change, `GeckoView` will no longer manage opening/closing of the + [`GeckoSession`][70.6] and instead leave that up to the app. It's also now + allowed to call [`setSession`][70.10] with a closed `GeckoSession`. + ([bug 1510314]({{bugzilla}}1510314)) +- Added an overload of [`GeckoSession.loadUri()`][70.8] that accepts a + referring [`GeckoSession`][70.6]. This should be used when the URI we're + loading originates from another page. A common example of this would be long + pressing a link and then opening that in a new `GeckoSession`. + ([bug 1561079]({{bugzilla}}1561079)) +- Added capture parameter to [`onFilePrompt`][70.9] and corresponding + [`CAPTURE_TYPE_*`][70.7] constants. + ([bug 1553603]({{bugzilla}}1553603)) +- Removed the obsolete `success` parameter from + [`CrashReporter#sendCrashReport(Context, File, File, String)`][70.3] and + [`CrashReporter#sendCrashReport(Context, File, Map, String)`][70.4]. + ([bug 1570789]({{bugzilla}}1570789)) +- Add `GeckoSession.LOAD_FLAGS_REPLACE_HISTORY`. + ([bug 1571088]({{bugzilla}}1571088)) +- Complete rewrite of [`PromptDelegate`][70.11]. + ([bug 1499394]({{bugzilla}}1499394)) +- Added [`RuntimeTelemetry.Delegate`][70.12] that receives streaming telemetry + data from GeckoView. + ([bug 1566367]({{bugzilla}}1566367)) +- Updated [`ContentBlocking`][70.13] to better report blocked and allowed ETP events. + ([bug 1567268]({{bugzilla}}1567268)) +- Added API for controlling Gecko logging [`GeckoRuntimeSettings.debugLogging`][70.14] + ([bug 1573304]({{bugzilla}}1573304)) +- Added [`WebNotification`][70.15] and [`WebNotificationDelegate`][70.16] for handling Web Notifications. + ([bug 1533057]({{bugzilla}}1533057)) +- Added Social Tracking Protection support to [`ContentBlocking`][70.17]. + ([bug 1568295]({{bugzilla}}1568295)) +- Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle + [`browser.tabs.create`][70.20] calls by WebExtensions. + ([bug 1539144]({{bugzilla}}1539144)) +- Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle + [`browser.tabs.remove`][70.22] calls by WebExtensions. + ([bug 1565782]({{bugzilla}}1565782)) +- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts. + ([bug 1621094]({{bugzilla}}1621094)) +- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and + [`WebPushSubscription`][70.26]. +- Added [`ContentBlockingController`][70.27], accessible via [`GeckoRuntime.getContentBlockingController`][70.28] + to allow modification and inspection of a content blocking exception list. + +[70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId(java.lang.String) +[70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext(java.lang.String) +[70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.io.File,java.lang.String) +[70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.util.Map,java.lang.String) +[70.5]: {{javadoc_uri}}/GeckoView.html +[70.6]: {{javadoc_uri}}/GeckoSession.html +[70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE +[70.8]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int) +[70.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onFilePrompt(org.mozilla.geckoview.GeckoSession,java.lang.String,int,java.lang.String[],int,org.mozilla.geckoview.GeckoSession.PromptDelegate.FileCallback) +[70.10]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession) +[70.11]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html +[70.12]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html +[70.13]: {{javadoc_uri}}/ContentBlocking.html +[70.14]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#debugLogging(boolean) +[70.15]: {{javadoc_uri}}/WebNotification.html +[70.16]: {{javadoc_uri}}/WebNotificationDelegate.html +[70.17]: {{javadoc_uri}}/ContentBlocking.html +[70.18]: {{javadoc_uri}}/WebExtensionController.html +[70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html +[70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create +[70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession) +[70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove +[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html +[70.24]: {{javadoc_uri}}/WebPushController.html +[70.25]: {{javadoc_uri}}/WebPushDelegate.html +[70.26]: {{javadoc_uri}}/WebPushSubscription.html +[70.27]: {{javadoc_uri}}/ContentBlockingController.html +[70.28]: {{javadoc_uri}}/GeckoRuntime.html#getContentBlockingController() + +## v69 +- Modified behavior of [`setAutomaticFontSizeAdjustment`][69.1] so that it no + longer has any effect on [`setFontInflationEnabled`][69.2] +- Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14] +- Added [`GeckoResult.accept`][69.3] for consuming a result without + transforming it. +- [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the + [`WebExtension`][69.5] that the [`MessageDelegate`][69.4] will receive + messages from. +- Created [`onKill`][69.7] to [`ContentDelegate`][69.11] to differentiate from crashes. + +[69.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean) +[69.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontInflationEnabled(boolean) +[69.3]: {{javadoc_uri}}/GeckoResult.html#accept(org.mozilla.geckoview.GeckoResult.Consumer) +[69.4]: {{javadoc_uri}}/WebExtension.MessageDelegate.html +[69.5]: {{javadoc_uri}}/WebExtension.html +[69.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onKill(org.mozilla.geckoview.GeckoSession) +[69.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html +[69.13]: {{javadoc_uri}}/GeckoSession.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String) +[69.14]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_FORCE_ALLOW_DATA_URI + +## v68 +- Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device + configuration has changed. +- Added [`onSessionStateChange`][68.29] to [`ProgressDelegate`][68.2] and removed `saveState`. +- Added [`ContentBlocking#AT_CRYPTOMINING`][68.3] for cryptocurrency miner blocking. +- Added [`ContentBlocking#AT_DEFAULT`][68.4], [`ContentBlocking#AT_STRICT`][68.5], + [`ContentBlocking#CB_DEFAULT`][68.6] and [`ContentBlocking#CB_STRICT`][68.7] + for clearer app default selections. +- Added [`GeckoSession.SessionState.fromString`][68.8]. This can be used to + deserialize a `GeckoSession.SessionState` instance previously serialized to + a `String` via `GeckoSession.SessionState.toString`. +- Added [`GeckoRuntimeSettings#setPreferredColorScheme`][68.9] to override + the default color theme for web content ("light" or "dark"). +- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all fields. +- [`RuntimeTelemetry#getSnapshots`][68.10] returns a [`JSONObject`][68.30] now. +- Removed all `org.mozilla.gecko` references in the API. +- Added [`ContentBlocking#AT_FINGERPRINTING`][68.11] to block fingerprinting trackers. +- Added [`HistoryItem`][68.31] and [`HistoryList`][68.32] interfaces and [`onHistoryStateChange`][68.34] to + [`HistoryDelegate`][68.12] and added [`gotoHistoryIndex`][68.33] to [`GeckoSession`][68.13]. +- [`GeckoView`][70.5] will not create a [`GeckoSession`][65.9] anymore when + attached to a window without a session. +- Added [`GeckoRuntimeSettings.Builder#configFilePath`][68.16] to set + a path to a configuration file from which GeckoView will read + configuration options such as Gecko process arguments, environment + variables, and preferences. +- Added [`unregisterWebExtension`][68.17] to unregister a web extension. +- Added messaging support for WebExtension. [`setMessageDelegate`][68.18] + allows embedders to listen to messages coming from a WebExtension. + [`Port`][68.19] allows bidirectional communication between the embedder and + the WebExtension. +- Expose the following prefs in [`GeckoRuntimeSettings`][67.3]: + [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21], + [`setGlMsaaLevel`][68.22]. +- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.35] +- Added `setVerticalClipping` to [`GeckoDisplay`][68.24] and + [`GeckoView`][68.23] to tell Gecko how much of its vertical space is clipped. +- Added [`StorageController`][68.25] API for clearing data. +- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices. +- Removed redundant constants in [`MediaSource`][68.28] + +[68.1]: {{javadoc_uri}}/GeckoRuntime.html#configurationChanged(android.content.res.Configuration) +[68.2]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html +[68.3]: {{javadoc_uri}}/ContentBlocking.html#AT_CRYPTOMINING +[68.4]: {{javadoc_uri}}/ContentBlocking.html#AT_DEFAULT +[68.5]: {{javadoc_uri}}/ContentBlocking.html#AT_STRICT +[68.6]: {{javadoc_uri}}/ContentBlocking.html#CB_DEFAULT +[68.7]: {{javadoc_uri}}/ContentBlocking.html#CB_STRICT +[68.8]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String) +[68.9]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setPreferredColorScheme(int) +[68.10]: {{javadoc_uri}}/RuntimeTelemetry.html#getSnapshots(boolean) +[68.11]: {{javadoc_uri}}/ContentBlocking.html#AT_FINGERPRINTING +[68.12]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html +[68.13]: {{javadoc_uri}}/GeckoSession.html +[68.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String) +[68.17]: {{javadoc_uri}}/GeckoRuntime.html#unregisterWebExtension(org.mozilla.geckoview.WebExtension) +[68.18]: {{javadoc_uri}}/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String) +[68.19]: {{javadoc_uri}}/WebExtension.Port.html +[68.20]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoZoomEnabled(boolean) +[68.21]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled(boolean) +[68.22]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setGlMsaaLevel(int) +[68.23]: {{javadoc_uri}}/GeckoView.html#setVerticalClipping(int) +[68.24]: {{javadoc_uri}}/GeckoDisplay.html#setVerticalClipping(int) +[68.25]: {{javadoc_uri}}/StorageController.html +[68.26]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html#onRecordingStatusChanged(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice[]) +[68.27]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html +[68.28]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html +[68.29]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html#onSessionStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SessionState) +[68.30]: https://developer.android.com/reference/org/json/JSONObject +[68.31]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryItem.html +[68.32]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryList.html +[68.33]: {{javadoc_uri}}/GeckoSession.html#gotoHistoryIndex(int) +[68.34]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html#onHistoryStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.HistoryDelegate.HistoryList) +[68.35]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE + +## v67 +- Added [`setAutomaticFontSizeAdjustment`][67.23] to + [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings + depending on the OS-level font size setting. +- Added [`setFontSizeFactor`][67.4] to [`GeckoRuntimeSettings`][67.3] for + setting a font size scaling factor, and for enabling font inflation for + non-mobile-friendly pages. +- Updated video autoplay API to reflect changes in Gecko. Instead of being a + per-video permission in the [`PermissionDelegate`][67.5], it is a [runtime + setting][67.6] that either allows or blocks autoplay videos. +- Change [`ContentBlocking.AT_AD`][67.7] and [`ContentBlocking.SB_ALL`][67.8] + values to mirror the actual constants they encompass. +- Added nested [`ContentBlocking`][67.9] runtime settings. +- Added [`RuntimeSettings`][67.10] base class to support nested settings. +- Added [`baseUri`][67.11] to [`ContentDelegate.ContextElement`][65.21] and + changed [`linkUri`][67.12] to absolute form. +- Added [`scrollBy`][67.13] and [`scrollTo`][67.14] to [`PanZoomController`][65.4]. +- Added [`GeckoSession.getDefaultUserAgent`][67.1] to expose the build-time + default user agent synchronously. +- Changed [`WebResponse.body`][67.24] from a [`ByteBuffer`][67.25] to an [`InputStream`][67.26]. Apps that want access + to the entire response body will now need to read the stream themselves. +- Added [`GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`][67.27], which will cause [`GeckoWebExecutor.fetch()`][67.28] to not + automatically follow [HTTP redirects][67.29] (e.g., 302). +- Moved [`GeckoVRManager`][67.2] into the org.mozilla.geckoview package. +- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15] + allows embedders to register a local web extension. +- Added API to [`GeckoView`][70.5] to take screenshot of the visible page. Calling [`capturePixels`][67.16] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.17] of the current [`Surface`][67.18] contents, or an [`IllegalStateException`][67.19] if the [`GeckoSession`][65.9] is not ready to render content. +- Added API to capture a screenshot to [`GeckoDisplay`][67.20]. [`capturePixels`][67.21] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.16] of the current [`Surface`][67.17] contents, or an [`IllegalStateException`][67.18] if the [`GeckoSession`][65.9] is not ready to render content. +- Add missing [`@Nullable`][66.2] annotation to return value for + [`GeckoSession.PromptDelegate.ChoiceCallback.onPopupResult()`][67.30] +- Added `default` implementations for all non-functional `interface`s. +- Added [`ContentDelegate.onWebAppManifest`][67.22], which will deliver the contents of a parsed + and validated Web App Manifest on pages that contain one. + +[67.1]: {{javadoc_uri}}/GeckoSession.html#getDefaultUserAgent() +[67.2]: {{javadoc_uri}}/GeckoVRManager.html +[67.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html +[67.4]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontSizeFactor(float) +[67.5]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html +[67.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoplayDefault(int) +[67.7]: {{javadoc_uri}}/ContentBlocking.html#AT_AD +[67.8]: {{javadoc_uri}}/ContentBlocking.html#SB_ALL +[67.9]: {{javadoc_uri}}/ContentBlocking.html +[67.10]: {{javadoc_uri}}/RuntimeSettings.html +[67.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#baseUri +[67.12]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#linkUri +[67.13]: {{javadoc_uri}}/PanZoomController.html#scrollBy(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength) +[67.14]: {{javadoc_uri}}/PanZoomController.html#scrollTo(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength) +[67.15]: {{javadoc_uri}}/GeckoRuntime.html#registerWebExtension(org.mozilla.geckoview.WebExtension) +[67.16]: {{javadoc_uri}}/GeckoView.html#capturePixels() +[67.17]: https://developer.android.com/reference/android/graphics/Bitmap +[67.18]: https://developer.android.com/reference/android/view/Surface +[67.19]: https://developer.android.com/reference/java/lang/IllegalStateException +[67.20]: {{javadoc_uri}}/GeckoDisplay.html +[67.21]: {{javadoc_uri}}/GeckoDisplay.html#capturePixels() +[67.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onWebAppManifest(org.mozilla.geckoview.GeckoSession,org.json.JSONObject) +[67.23]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean) +[67.24]: {{javadoc_uri}}/WebResponse.html#body +[67.25]: https://developer.android.com/reference/java/nio/ByteBuffer +[67.26]: https://developer.android.com/reference/java/io/InputStream +[67.27]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_NO_REDIRECTS +[67.28]: {{javadoc_uri}}/GeckoWebExecutor.html#fetch(org.mozilla.geckoview.WebRequest,int) +[67.29]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections +[67.30]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ChoiceCallback.html + +## v66 +- Removed redundant field `trackingMode` from [`SecurityInformation`][66.6]. + Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked + elements during page load. +- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs. +- Added methods for each setting in [`GeckoSessionSettings`][66.3] +- Added [`GeckoSessionSettings`][66.4] for enabling desktop viewport. Desktop + viewport is no longer set by [`USER_AGENT_MODE_DESKTOP`][66.5] and must be set + separately. +- Added [`@UiThread`][65.6] to [`GeckoSession.releaseSession`][66.7] and + [`GeckoSession.setSession`][66.8] + +[66.1]: https://developer.android.com/reference/android/support/annotation/NonNull +[66.2]: https://developer.android.com/reference/android/support/annotation/Nullable +[66.3]: {{javadoc_uri}}/GeckoSessionSettings.html +[66.4]: {{javadoc_uri}}/GeckoSessionSettings.html +[66.5]: {{javadoc_uri}}/GeckoSessionSettings.html#USER_AGENT_MODE_DESKTOP +[66.6]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html +[66.7]: {{javadoc_uri}}/GeckoView.html#releaseSession() +[66.8]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession) + +## v65 +- Added experimental ad-blocking category to `GeckoSession.TrackingProtectionDelegate`. +- Moved [`CompositorController`][65.1], [`DynamicToolbarAnimator`][65.2], + [`OverscrollEdgeEffect`][65.3], [`PanZoomController`][65.4] from + `org.mozilla.gecko.gfx` to [`org.mozilla.geckoview`][65.5] +- Added [`@UiThread`][65.6], [`@AnyThread`][65.7] annotations to all APIs +- Changed `GeckoRuntimeSettings#getLocale` to [`getLocales`][65.8] and related + APIs. +- Merged `org.mozilla.gecko.gfx.LayerSession` into [`GeckoSession`][65.9] +- Added [`GeckoSession.MediaDelegate`][65.10] and [`MediaElement`][65.11]. This + allow monitoring and control of web media elements (play, pause, seek, etc). +- Removed unused `access` parameter from + [`GeckoSession.PermissionDelegate#onContentPermissionRequest`][65.12] +- Added [`WebMessage`][65.13], [`WebRequest`][65.14], [`WebResponse`][65.15], + and [`GeckoWebExecutor`][65.16]. This exposes Gecko networking to apps. It + includes speculative connections, name resolution, and a Fetch-like HTTP API. +- Added [`GeckoSession.HistoryDelegate`][65.17]. This allows apps to implement + their own history storage system and provide visited link status. +- Added [`ContentDelegate#onFirstComposite`][65.18] to get first composite + callback after a compositor start. +- Changed `LoadRequest.isUserTriggered` to [`isRedirect`][65.19]. +- Added [`GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER`][65.20] to bypass the URI + classifier. +- Added a `protected` empty constructor to all field-only classes so that apps + can mock these classes in tests. +- Added [`ContentDelegate.ContextElement`][65.21] to extend the information + passed to [`ContentDelegate#onContextMenu`][65.22]. Extended information + includes the element's title and alt attributes. +- Changed [`ContentDelegate.ContextElement`][65.21] `TYPE_` constants to public + access. +- Changed [`ContentDelegate.ContextElement`][65.21], + [`GeckoSession.FinderResult`][65.23] to non-final class. +- Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a + [`GeckoResult`][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]: aa4d7a44b1bdd7687884196affc6af0555ac7253 diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java new file mode 100644 index 0000000000..4394d27f72 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java @@ -0,0 +1,40 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This package contains the public interfaces for the library. + * + *

      + *
    • {@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. + *
    • {@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. + *
    • {@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. + *
    + * + *

    Permissions + * + *

    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: + * + *

      + *
    • {@link android.Manifest.permission#ACCESS_COARSE_LOCATION} + *
    • {@link android.Manifest.permission#ACCESS_FINE_LOCATION} + *
    • {@link android.Manifest.permission#READ_EXTERNAL_STORAGE} + *
    • {@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} + *
    • {@link android.Manifest.permission#CAMERA} + *
    • {@link android.Manifest.permission#RECORD_AUDIO} + *
    + * + * For a detailed change log of the API see: CHANGELOG. + */ +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 @@ + + + + + + + + diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/GeckoBundleTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/GeckoBundleTest.java new file mode 100644 index 0000000000..8ef19ca696 --- /dev/null +++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/GeckoBundleTest.java @@ -0,0 +1,745 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.gecko.util; + +import static org.junit.Assert.*; + +import android.os.Parcel; +import android.test.suitebuilder.annotation.SmallTest; +import java.util.Arrays; +import java.util.List; +import org.json.JSONException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +@SmallTest +public class GeckoBundleTest { + private static final int INNER_BUNDLE_SIZE = 28; + private static final int OUTER_BUNDLE_SIZE = INNER_BUNDLE_SIZE + 6; + + private static GeckoBundle createInnerBundle() { + final GeckoBundle bundle = new GeckoBundle(); + + bundle.putBoolean("boolean", true); + bundle.putBooleanArray("booleanArray", new boolean[] {false, true}); + + bundle.putInt("int", 1); + bundle.putIntArray("intArray", new int[] {2, 3}); + + bundle.putDouble("double", 0.5); + bundle.putDoubleArray("doubleArray", new double[] {1.5, 2.5}); + + bundle.putLong("long", 1L); + bundle.putLongArray("longArray", new long[] {2L, 3L}); + + bundle.putString("string", "foo"); + bundle.putString("nullString", null); + bundle.putString("emptyString", ""); + bundle.putStringArray("stringArray", new String[] {"bar", "baz"}); + bundle.putStringArray("stringArrayOfNull", new String[2]); + + bundle.putBooleanArray("emptyBooleanArray", new boolean[0]); + bundle.putIntArray("emptyIntArray", new int[0]); + bundle.putDoubleArray("emptyDoubleArray", new double[0]); + bundle.putLongArray("emptyLongArray", new long[0]); + bundle.putStringArray("emptyStringArray", new String[0]); + + bundle.putBooleanArray("nullBooleanArray", (boolean[]) null); + bundle.putIntArray("nullIntArray", (int[]) null); + bundle.putDoubleArray("nullDoubleArray", (double[]) null); + bundle.putLongArray("nullLongArray", (long[]) null); + bundle.putStringArray("nullStringArray", (String[]) null); + + bundle.putDoubleArray("mixedArray", new double[] {1.0, 1.5}); + + bundle.putInt("byte", 1); + bundle.putInt("short", 1); + bundle.putDouble("float", 0.5); + bundle.putString("char", "f"); + + return bundle; + } + + private static GeckoBundle createBundle() { + final GeckoBundle outer = createInnerBundle(); + final GeckoBundle inner = createInnerBundle(); + + outer.putBundle("object", inner); + outer.putBundle("nullObject", null); + outer.putBundleArray("objectArray", new GeckoBundle[] {null, inner}); + outer.putBundleArray("objectArrayOfNull", new GeckoBundle[2]); + outer.putBundleArray("emptyObjectArray", new GeckoBundle[0]); + outer.putBundleArray("nullObjectArray", (GeckoBundle[]) null); + + return outer; + } + + private static void checkInnerBundle(final GeckoBundle bundle, final int expectedSize) { + assertEquals(expectedSize, bundle.size()); + + assertEquals(true, bundle.getBoolean("boolean")); + assertArrayEquals(new boolean[] {false, true}, bundle.getBooleanArray("booleanArray")); + + assertEquals(1, bundle.getInt("int")); + assertArrayEquals(new int[] {2, 3}, bundle.getIntArray("intArray")); + + assertEquals(0.5, bundle.getDouble("double"), 0.0); + assertArrayEquals(new double[] {1.5, 2.5}, bundle.getDoubleArray("doubleArray"), 0.0); + + assertEquals(1L, bundle.getLong("long")); + assertArrayEquals(new long[] {2L, 3L}, bundle.getLongArray("longArray")); + + assertEquals("foo", bundle.getString("string")); + assertEquals(null, bundle.getString("nullString")); + assertEquals("", bundle.getString("emptyString")); + assertArrayEquals(new String[] {"bar", "baz"}, bundle.getStringArray("stringArray")); + assertArrayEquals(new String[2], bundle.getStringArray("stringArrayOfNull")); + + assertArrayEquals(new boolean[0], bundle.getBooleanArray("emptyBooleanArray")); + assertArrayEquals(new int[0], bundle.getIntArray("emptyIntArray")); + assertArrayEquals(new double[0], bundle.getDoubleArray("emptyDoubleArray"), 0.0); + assertArrayEquals(new long[0], bundle.getLongArray("emptyLongArray")); + assertArrayEquals(new String[0], bundle.getStringArray("emptyStringArray")); + + assertArrayEquals(null, bundle.getBooleanArray("nullBooleanArray")); + assertArrayEquals(null, bundle.getIntArray("nullIntArray")); + assertArrayEquals(null, bundle.getDoubleArray("nullDoubleArray"), 0.0); + assertArrayEquals(null, bundle.getLongArray("nullLongArray")); + assertArrayEquals(null, bundle.getStringArray("nullStringArray")); + + assertArrayEquals(new double[] {1.0, 1.5}, bundle.getDoubleArray("mixedArray"), 0.0); + + assertEquals(1, bundle.getInt("byte")); + assertEquals(1, bundle.getInt("short")); + assertEquals(0.5, bundle.getDouble("float"), 0.0); + assertEquals("f", bundle.getString("char")); + } + + private static void checkBundle(final GeckoBundle bundle) { + checkInnerBundle(bundle, OUTER_BUNDLE_SIZE); + + checkInnerBundle(bundle.getBundle("object"), INNER_BUNDLE_SIZE); + assertEquals(null, bundle.getBundle("nullObject")); + + final GeckoBundle[] array = bundle.getBundleArray("objectArray"); + assertNotNull(array); + assertEquals(2, array.length); + assertEquals(null, array[0]); + checkInnerBundle(array[1], INNER_BUNDLE_SIZE); + + assertArrayEquals(new GeckoBundle[2], bundle.getBundleArray("objectArrayOfNull")); + assertArrayEquals(new GeckoBundle[0], bundle.getBundleArray("emptyObjectArray")); + assertArrayEquals(null, bundle.getBundleArray("nullObjectArray")); + } + + private GeckoBundle reference; + + @Before + public void prepareReference() { + reference = createBundle(); + } + + @Test + public void canConstructWithCapacity() { + new GeckoBundle(0); + new GeckoBundle(1); + new GeckoBundle(42); + + try { + new GeckoBundle(-1); + fail("Should throw with -1 capacity"); + } catch (final Exception e) { + assertTrue(true); + } + } + + @Test + public void canConstructWithBundle() { + assertEquals(reference, new GeckoBundle(reference)); + + try { + new GeckoBundle(null); + fail("Should throw with null bundle"); + } catch (final Exception e) { + assertTrue(true); + } + } + + @Test + public void referenceShouldBeCorrect() { + checkBundle(reference); + } + + @Test + public void equalsShouldReturnCorrectResult() { + assertTrue(reference.equals(reference)); + assertFalse(reference.equals(null)); + + assertTrue(reference.equals(new GeckoBundle(reference))); + assertFalse(reference.equals(new GeckoBundle())); + } + + @Test + public void toStringShouldNotReturnEmptyString() { + assertNotNull(reference.toString()); + assertNotEquals("", reference.toString()); + } + + @Test + public void hashCodeShouldNotReturnZero() { + assertNotEquals(0, reference.hashCode()); + } + + private static void testRemove(final GeckoBundle bundle, final String key) { + if (bundle.get(key) != null) { + assertTrue(String.format("%s should exist", key), bundle.containsKey(key)); + } else { + assertFalse(String.format("%s should not exist", key), bundle.containsKey(key)); + } + bundle.remove(key); + assertFalse(String.format("%s should not exist", key), bundle.containsKey(key)); + } + + @Test + public void containsKeyAndRemoveShouldWork() { + final GeckoBundle test = new GeckoBundle(reference); + + testRemove(test, "nonexistent"); + testRemove(test, "boolean"); + testRemove(test, "booleanArray"); + testRemove(test, "int"); + testRemove(test, "intArray"); + testRemove(test, "double"); + testRemove(test, "doubleArray"); + testRemove(test, "long"); + testRemove(test, "longArray"); + testRemove(test, "string"); + testRemove(test, "nullString"); + testRemove(test, "emptyString"); + testRemove(test, "stringArray"); + testRemove(test, "stringArrayOfNull"); + testRemove(test, "emptyBooleanArray"); + testRemove(test, "emptyIntArray"); + testRemove(test, "emptyDoubleArray"); + testRemove(test, "emptyLongArray"); + testRemove(test, "emptyStringArray"); + testRemove(test, "nullBooleanArray"); + testRemove(test, "nullIntArray"); + testRemove(test, "nullDoubleArray"); + testRemove(test, "nullLongArray"); + testRemove(test, "nullStringArray"); + testRemove(test, "mixedArray"); + testRemove(test, "byte"); + testRemove(test, "short"); + testRemove(test, "float"); + testRemove(test, "char"); + testRemove(test, "object"); + testRemove(test, "nullObject"); + testRemove(test, "objectArray"); + testRemove(test, "objectArrayOfNull"); + testRemove(test, "emptyObjectArray"); + testRemove(test, "nullObjectArray"); + + assertEquals(0, test.size()); + } + + @Test + public void clearShouldWork() { + final GeckoBundle test = new GeckoBundle(reference); + assertNotEquals(0, test.size()); + test.clear(); + assertEquals(0, test.size()); + } + + @Test + public void keysShouldReturnCorrectResult() { + final String[] actual = reference.keys(); + final String[] expected = + new String[] { + "boolean", + "booleanArray", + "int", + "intArray", + "double", + "doubleArray", + "long", + "longArray", + "string", + "nullString", + "emptyString", + "stringArray", + "stringArrayOfNull", + "emptyBooleanArray", + "emptyIntArray", + "emptyDoubleArray", + "emptyLongArray", + "emptyStringArray", + "nullBooleanArray", + "nullIntArray", + "nullDoubleArray", + "nullLongArray", + "nullStringArray", + "mixedArray", + "byte", + "short", + "float", + "char", + "object", + "nullObject", + "objectArray", + "objectArrayOfNull", + "emptyObjectArray", + "nullObjectArray" + }; + + Arrays.sort(expected); + Arrays.sort(actual); + + assertArrayEquals(expected, actual); + } + + @Test + public void isEmptyShouldReturnCorrectResult() { + assertFalse(reference.isEmpty()); + assertTrue(new GeckoBundle().isEmpty()); + } + + @Test + public void getExistentKeysShouldNotReturnDefaultValues() { + assertNotEquals(false, reference.getBoolean("boolean", false)); + assertNotEquals(0, reference.getInt("int", 0)); + assertNotEquals(0.0, reference.getDouble("double", 0.0), 0.0); + assertNotEquals(0L, reference.getLong("long", 0L)); + assertNotEquals("", reference.getString("string", "")); + } + + private static void testDefaultValueForNull(final GeckoBundle bundle, final String key) { + // We return default values for null values. + assertEquals(true, bundle.getBoolean(key, true)); + assertEquals(1, bundle.getInt(key, 1)); + assertEquals(0.5, bundle.getDouble(key, 0.5), 0.0); + assertEquals("foo", bundle.getString(key, "foo")); + } + + @Test + public void getNonexistentKeysShouldReturnDefaultValues() { + assertEquals(null, reference.get("nonexistent")); + + assertEquals(false, reference.getBoolean("nonexistent")); + assertEquals(true, reference.getBoolean("nonexistent", true)); + assertEquals(0, reference.getInt("nonexistent")); + assertEquals(1, reference.getInt("nonexistent", 1)); + assertEquals(0.0, reference.getDouble("nonexistent"), 0.0); + assertEquals(0.5, reference.getDouble("nonexistent", 0.5), 0.0); + assertEquals(null, reference.getString("nonexistent")); + assertEquals("foo", reference.getString("nonexistent", "foo")); + assertEquals(null, reference.getBundle("nonexistent")); + + assertArrayEquals(null, reference.getBooleanArray("nonexistent")); + assertArrayEquals(null, reference.getIntArray("nonexistent")); + assertArrayEquals(null, reference.getDoubleArray("nonexistent"), 0.0); + assertArrayEquals(null, reference.getLongArray("nonexistent")); + assertArrayEquals(null, reference.getStringArray("nonexistent")); + assertArrayEquals(null, reference.getBundleArray("nonexistent")); + + // We return default values for null values. + testDefaultValueForNull(reference, "nullObject"); + testDefaultValueForNull(reference, "nullString"); + testDefaultValueForNull(reference, "nullBooleanArray"); + testDefaultValueForNull(reference, "nullIntArray"); + testDefaultValueForNull(reference, "nullDoubleArray"); + testDefaultValueForNull(reference, "nullLongArray"); + testDefaultValueForNull(reference, "nullStringArray"); + testDefaultValueForNull(reference, "nullObjectArray"); + } + + @Test + public void bundleConversionShouldWork() { + assertEquals(reference, GeckoBundle.fromBundle(reference.toBundle())); + } + + @Test + public void jsonConversionShouldWork() throws JSONException { + assertEquals(reference, GeckoBundle.fromJSONObject(reference.toJSONObject())); + } + + @Test + public void parcelConversionShouldWork() { + final Parcel parcel = Parcel.obtain(); + + reference.writeToParcel(parcel, 0); + reference.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + + assertEquals(reference, GeckoBundle.CREATOR.createFromParcel(parcel)); + + final GeckoBundle test = new GeckoBundle(); + test.readFromParcel(parcel); + assertEquals(reference, test); + + parcel.recycle(); + } + + private static void testInvalidCoercions( + final GeckoBundle bundle, final String key, final String... exceptions) { + final List allowed; + if (exceptions == null) { + allowed = Arrays.asList(key); + } else { + allowed = Arrays.asList(Arrays.copyOf(exceptions, exceptions.length + 1)); + allowed.set(exceptions.length, key); + } + + if (!allowed.contains("boolean")) { + try { + bundle.getBoolean(key); + fail(String.format("%s should not coerce to boolean", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("booleanArray") + && !allowed.contains("emptyBooleanArray") + && !allowed.contains("nullBooleanArray")) { + try { + bundle.getBooleanArray(key); + fail(String.format("%s should not coerce to boolean array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("int")) { + try { + bundle.getInt(key); + fail(String.format("%s should not coerce to int", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("intArray") + && !allowed.contains("emptyIntArray") + && !allowed.contains("nullIntArray")) { + try { + bundle.getIntArray(key); + fail(String.format("%s should not coerce to int array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("double")) { + try { + bundle.getDouble(key); + fail(String.format("%s should not coerce to double", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("doubleArray") + && !allowed.contains("emptyDoubleArray") + && !allowed.contains("nullDoubleArray")) { + try { + bundle.getDoubleArray(key); + fail(String.format("%s should not coerce to double array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("long")) { + try { + bundle.getLong(key); + fail(String.format("%s should not coerce to long", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("longArray") + && !allowed.contains("emptyLongArray") + && !allowed.contains("nullLongArray")) { + try { + bundle.getLongArray(key); + fail(String.format("%s should not coerce to long array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("string") && !allowed.contains("nullString")) { + try { + bundle.getString(key); + fail(String.format("%s should not coerce to string", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("stringArray") + && !allowed.contains("emptyStringArray") + && !allowed.contains("nullStringArray") + && !allowed.contains("stringArrayOfNull")) { + try { + bundle.getStringArray(key); + fail(String.format("%s should not coerce to string array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("object") && !allowed.contains("nullObject")) { + try { + bundle.getBundle(key); + fail(String.format("%s should not coerce to bundle", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + + if (!allowed.contains("objectArray") + && !allowed.contains("emptyObjectArray") + && !allowed.contains("nullObjectArray") + && !allowed.contains("objectArrayOfNull")) { + try { + bundle.getBundleArray(key); + fail(String.format("%s should not coerce to bundle array", key)); + } catch (final Exception e) { + assertTrue(true); + } + } + } + + @Test + public void booleanShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "boolean"); + } + + @Test + public void booleanArrayShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "booleanArray"); + } + + @Test + public void intShouldCoerceToDouble() { + assertEquals(1.0, reference.getDouble("int"), 0.0); + assertArrayEquals(new double[] {2.0, 3.0}, reference.getDoubleArray("intArray"), 0.0); + } + + @Test + public void intShouldCoerceToLong() { + assertEquals(1L, reference.getLong("int")); + assertArrayEquals(new long[] {2L, 3L}, reference.getLongArray("intArray")); + } + + @Test + public void intShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "int", /* except */ "double", "long"); + testInvalidCoercions(reference, "intArray", /* except */ "doubleArray", "longArray"); + } + + @Test + public void doubleShouldCoerceToInt() { + assertEquals(0, reference.getInt("double")); + assertArrayEquals(new int[] {1, 2}, reference.getIntArray("doubleArray")); + } + + @Test + public void doubleShouldCoerceToLong() { + assertEquals(0L, reference.getLong("double")); + assertArrayEquals(new long[] {1L, 2L}, reference.getLongArray("doubleArray")); + } + + @Test + public void doubleShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "double", /* except */ "int", "long"); + testInvalidCoercions(reference, "doubleArray", /* except */ "intArray", "longArray"); + } + + @Test + public void longShouldCoerceToInt() { + assertEquals(1, reference.getInt("long")); + assertArrayEquals(new int[] {2, 3}, reference.getIntArray("longArray")); + } + + @Test + public void longShouldCoerceToDouble() { + assertEquals(1.0, reference.getDouble("long"), 0.0); + assertArrayEquals(new double[] {2.0, 3.0}, reference.getDoubleArray("longArray"), 0.0); + } + + @Test + public void longShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "long", /* except */ "int", "double"); + testInvalidCoercions(reference, "longArray", /* except */ "intArray", "doubleArray"); + } + + @Test + public void nullStringShouldCoerceToBundle() { + assertEquals(null, reference.getBundle("nullString")); + assertArrayEquals(new GeckoBundle[2], reference.getBundleArray("stringArrayOfNull")); + } + + @Test + public void nullStringShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "stringArrayOfNull", /* except */ "objectArrayOfNull"); + } + + @Test + public void nonNullStringShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "string"); + } + + @Test + public void nullBundleShouldCoerceToString() { + assertEquals(null, reference.getString("nullObject")); + assertArrayEquals(new String[2], reference.getStringArray("objectArrayOfNull")); + } + + @Test + public void nullBundleShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "objectArrayOfNull", /* except */ "stringArrayOfNull"); + } + + @Test + public void nonNullBundleShouldNotCoerceToOtherTypes() { + testInvalidCoercions(reference, "object"); + } + + @Test + public void emptyArrayShouldCoerceToAnyArray() { + assertArrayEquals(new int[0], reference.getIntArray("emptyBooleanArray")); + assertArrayEquals(new double[0], reference.getDoubleArray("emptyBooleanArray"), 0.0); + assertArrayEquals(new long[0], reference.getLongArray("emptyBooleanArray")); + assertArrayEquals(new String[0], reference.getStringArray("emptyBooleanArray")); + assertArrayEquals(new GeckoBundle[0], reference.getBundleArray("emptyBooleanArray")); + + assertArrayEquals(new boolean[0], reference.getBooleanArray("emptyIntArray")); + assertArrayEquals(new double[0], reference.getDoubleArray("emptyIntArray"), 0.0); + assertArrayEquals(new long[0], reference.getLongArray("emptyIntArray")); + assertArrayEquals(new String[0], reference.getStringArray("emptyIntArray")); + assertArrayEquals(new GeckoBundle[0], reference.getBundleArray("emptyIntArray")); + + assertArrayEquals(new boolean[0], reference.getBooleanArray("emptyDoubleArray")); + assertArrayEquals(new int[0], reference.getIntArray("emptyDoubleArray")); + assertArrayEquals(new long[0], reference.getLongArray("emptyDoubleArray")); + assertArrayEquals(new String[0], reference.getStringArray("emptyDoubleArray")); + assertArrayEquals(new GeckoBundle[0], reference.getBundleArray("emptyDoubleArray")); + + assertArrayEquals(new boolean[0], reference.getBooleanArray("emptyLongArray")); + assertArrayEquals(new int[0], reference.getIntArray("emptyLongArray")); + assertArrayEquals(new double[0], reference.getDoubleArray("emptyLongArray"), 0.0); + assertArrayEquals(new String[0], reference.getStringArray("emptyLongArray")); + assertArrayEquals(new GeckoBundle[0], reference.getBundleArray("emptyLongArray")); + + assertArrayEquals(new boolean[0], reference.getBooleanArray("emptyStringArray")); + assertArrayEquals(new int[0], reference.getIntArray("emptyStringArray")); + assertArrayEquals(new double[0], reference.getDoubleArray("emptyStringArray"), 0.0); + assertArrayEquals(new long[0], reference.getLongArray("emptyStringArray")); + assertArrayEquals(new GeckoBundle[0], reference.getBundleArray("emptyStringArray")); + + assertArrayEquals(new boolean[0], reference.getBooleanArray("emptyObjectArray")); + assertArrayEquals(new int[0], reference.getIntArray("emptyObjectArray")); + assertArrayEquals(new double[0], reference.getDoubleArray("emptyObjectArray"), 0.0); + assertArrayEquals(new long[0], reference.getLongArray("emptyObjectArray")); + assertArrayEquals(new String[0], reference.getStringArray("emptyObjectArray")); + } + + @Test + public void emptyArrayShouldNotCoerceToOtherTypes() { + testInvalidCoercions( + reference, + "emptyBooleanArray", /* except */ + "intArray", + "doubleArray", + "longArray", + "stringArray", + "objectArray"); + testInvalidCoercions( + reference, + "emptyIntArray", /* except */ + "booleanArray", + "doubleArray", + "longArray", + "stringArray", + "objectArray"); + testInvalidCoercions( + reference, + "emptyDoubleArray", /* except */ + "booleanArray", + "intArray", + "longArray", + "stringArray", + "objectArray"); + testInvalidCoercions( + reference, + "emptyLongArray", /* except */ + "booleanArray", + "intArray", + "doubleArray", + "stringArray", + "objectArray"); + testInvalidCoercions( + reference, + "emptyStringArray", /* except */ + "booleanArray", + "intArray", + "doubleArray", + "longArray", + "objectArray"); + testInvalidCoercions( + reference, + "emptyObjectArray", /* except */ + "booleanArray", + "intArray", + "doubleArray", + "longArray", + "stringArray"); + } + + @Test + public void nullArrayShouldCoerceToAnyArray() { + assertArrayEquals(null, reference.getIntArray("nullBooleanArray")); + assertArrayEquals(null, reference.getDoubleArray("nullBooleanArray"), 0.0); + assertArrayEquals(null, reference.getLongArray("nullBooleanArray")); + assertArrayEquals(null, reference.getStringArray("nullBooleanArray")); + assertArrayEquals(null, reference.getBundleArray("nullBooleanArray")); + + assertArrayEquals(null, reference.getBooleanArray("nullIntArray")); + assertArrayEquals(null, reference.getDoubleArray("nullIntArray"), 0.0); + assertArrayEquals(null, reference.getLongArray("nullIntArray")); + assertArrayEquals(null, reference.getStringArray("nullIntArray")); + assertArrayEquals(null, reference.getBundleArray("nullIntArray")); + + assertArrayEquals(null, reference.getBooleanArray("nullDoubleArray")); + assertArrayEquals(null, reference.getIntArray("nullDoubleArray")); + assertArrayEquals(null, reference.getLongArray("nullDoubleArray")); + assertArrayEquals(null, reference.getStringArray("nullDoubleArray")); + assertArrayEquals(null, reference.getBundleArray("nullDoubleArray")); + + assertArrayEquals(null, reference.getBooleanArray("nullLongArray")); + assertArrayEquals(null, reference.getIntArray("nullLongArray")); + assertArrayEquals(null, reference.getDoubleArray("nullLongArray"), 0.0); + assertArrayEquals(null, reference.getStringArray("nullLongArray")); + assertArrayEquals(null, reference.getBundleArray("nullLongArray")); + + assertArrayEquals(null, reference.getBooleanArray("nullStringArray")); + assertArrayEquals(null, reference.getIntArray("nullStringArray")); + assertArrayEquals(null, reference.getDoubleArray("nullStringArray"), 0.0); + assertArrayEquals(null, reference.getLongArray("nullStringArray")); + assertArrayEquals(null, reference.getBundleArray("nullStringArray")); + + assertArrayEquals(null, reference.getBooleanArray("nullObjectArray")); + assertArrayEquals(null, reference.getIntArray("nullObjectArray")); + assertArrayEquals(null, reference.getDoubleArray("nullObjectArray"), 0.0); + assertArrayEquals(null, reference.getLongArray("nullObjectArray")); + assertArrayEquals(null, reference.getStringArray("nullObjectArray")); + } +} diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java new file mode 100644 index 0000000000..24315ff585 --- /dev/null +++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.gecko.util; + +import static org.junit.Assert.*; + +import android.net.Uri; +import android.test.suitebuilder.annotation.SmallTest; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +@SmallTest +public class IntentUtilsTest { + + @Test + public void shouldNormalizeUri() { + final String uri = "HTTPS://mozilla.org"; + final Uri normUri = IntentUtils.normalizeUri(uri); + assertEquals("https://mozilla.org", normUri.toString()); + } + + @Test + public void safeHttpUri() { + final String uri = "https://mozilla.org"; + assertTrue(IntentUtils.isUriSafeForScheme(uri)); + } + + @Test + public void safeIntentUri() { + final String uri = "intent:https://mozilla.org#Intent;end;"; + assertTrue(IntentUtils.isUriSafeForScheme(uri)); + } + + @Test + public void unsafeIntentUri() { + final String uri = "intent:file:///storage/emulated/0/Download#Intent;end"; + assertFalse(IntentUtils.isUriSafeForScheme(uri)); + } + + @Test + public void safeTelUri() { + final String uri = "tel:12345678"; + assertTrue(IntentUtils.isUriSafeForScheme(uri)); + } + + @Test + public void unsafeTelUri() { + final String uri = "tel:#12345678"; + assertFalse(IntentUtils.isUriSafeForScheme(uri)); + } + + @Test + public void unsafeHtmlEncodedTelUri() { + assertFalse(IntentUtils.isUriSafeForScheme("tel:*%2306%23")); + assertFalse(IntentUtils.isUriSafeForScheme("tel:%2A%2306%23")); + } + + @Test + public void intentDataWithoutScheme() { + final String uri = "intent:non_scheme_intent#Intent;end"; + assertTrue(IntentUtils.isUriSafeForScheme(uri)); + } +} diff --git a/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/NetworkUtilsTest.java b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/NetworkUtilsTest.java new file mode 100644 index 0000000000..f5033041e3 --- /dev/null +++ b/mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/NetworkUtilsTest.java @@ -0,0 +1,215 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.gecko.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.telephony.TelephonyManager; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType; +import org.mozilla.gecko.util.NetworkUtils.ConnectionType; +import org.mozilla.gecko.util.NetworkUtils.NetworkStatus; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.ShadowConnectivityManager; +import org.robolectric.shadows.ShadowNetworkInfo; + +@RunWith(RobolectricTestRunner.class) +public class NetworkUtilsTest { + private ConnectivityManager connectivityManager; + private ShadowConnectivityManager shadowConnectivityManager; + + @Before + public void setUp() { + connectivityManager = + (ConnectivityManager) + RuntimeEnvironment.application.getSystemService(Context.CONNECTIVITY_SERVICE); + + // Not using Shadows.shadowOf(connectivityManager) because of Robolectric bug when using API23+ + // See: https://github.com/robolectric/robolectric/issues/1862 + shadowConnectivityManager = (ShadowConnectivityManager) Shadow.extract(connectivityManager); + } + + @Test + public void testIsConnected() throws Exception { + assertFalse(NetworkUtils.isConnected((ConnectivityManager) null)); + + shadowConnectivityManager.setActiveNetworkInfo(null); + assertFalse(NetworkUtils.isConnected(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)); + assertTrue(NetworkUtils.isConnected(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.DISCONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, false)); + assertFalse(NetworkUtils.isConnected(connectivityManager)); + } + + @Test + public void testGetConnectionSubType() throws Exception { + assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(null)); + + shadowConnectivityManager.setActiveNetworkInfo(null); + assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager)); + + // We don't seem to care about figuring out all connection types. So... + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_VPN, 0, true, true)); + assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager)); + + // But anything below we should recognize. + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_ETHERNET, 0, true, true)); + assertEquals( + ConnectionSubType.ETHERNET, NetworkUtils.getConnectionSubType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)); + assertEquals(ConnectionSubType.WIFI, NetworkUtils.getConnectionSubType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIMAX, 0, true, true)); + assertEquals(ConnectionSubType.WIMAX, NetworkUtils.getConnectionSubType(connectivityManager)); + + // Unknown mobile + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_MOBILE, + TelephonyManager.NETWORK_TYPE_UNKNOWN, + true, + true)); + assertEquals(ConnectionSubType.UNKNOWN, NetworkUtils.getConnectionSubType(connectivityManager)); + + // 2G mobile types + final int[] cell2gTypes = + new int[] { + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_EDGE, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT, + TelephonyManager.NETWORK_TYPE_IDEN + }; + for (int i = 0; i < cell2gTypes.length; i++) { + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_MOBILE, + cell2gTypes[i], + true, + true)); + assertEquals( + ConnectionSubType.CELL_2G, NetworkUtils.getConnectionSubType(connectivityManager)); + } + + // 3G mobile types + final int[] cell3gTypes = + new int[] { + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_EVDO_0, + TelephonyManager.NETWORK_TYPE_EVDO_A, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_EVDO_B, + TelephonyManager.NETWORK_TYPE_EHRPD, + TelephonyManager.NETWORK_TYPE_HSPAP + }; + for (int i = 0; i < cell3gTypes.length; i++) { + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_MOBILE, + cell3gTypes[i], + true, + true)); + assertEquals( + ConnectionSubType.CELL_3G, NetworkUtils.getConnectionSubType(connectivityManager)); + } + + // 4G mobile type + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_MOBILE, + TelephonyManager.NETWORK_TYPE_LTE, + true, + true)); + assertEquals(ConnectionSubType.CELL_4G, NetworkUtils.getConnectionSubType(connectivityManager)); + } + + @Test + public void testGetConnectionType() { + shadowConnectivityManager.setActiveNetworkInfo(null); + assertEquals(ConnectionType.NONE, NetworkUtils.getConnectionType(connectivityManager)); + assertEquals(ConnectionType.NONE, NetworkUtils.getConnectionType(null)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_VPN, 0, true, true)); + assertEquals(ConnectionType.OTHER, NetworkUtils.getConnectionType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIFI, 0, true, true)); + assertEquals(ConnectionType.WIFI, NetworkUtils.getConnectionType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, 0, true, true)); + assertEquals(ConnectionType.CELLULAR, NetworkUtils.getConnectionType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_ETHERNET, 0, true, true)); + assertEquals(ConnectionType.ETHERNET, NetworkUtils.getConnectionType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, + ConnectivityManager.TYPE_BLUETOOTH, + 0, + true, + true)); + assertEquals(ConnectionType.BLUETOOTH, NetworkUtils.getConnectionType(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_WIMAX, 0, true, true)); + assertEquals(ConnectionType.CELLULAR, NetworkUtils.getConnectionType(connectivityManager)); + } + + @Test + public void testGetNetworkStatus() { + assertEquals(NetworkStatus.UNKNOWN, NetworkUtils.getNetworkStatus(null)); + + shadowConnectivityManager.setActiveNetworkInfo(null); + assertEquals(NetworkStatus.DOWN, NetworkUtils.getNetworkStatus(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTING, ConnectivityManager.TYPE_MOBILE, 0, true, false)); + assertEquals(NetworkStatus.DOWN, NetworkUtils.getNetworkStatus(connectivityManager)); + + shadowConnectivityManager.setActiveNetworkInfo( + ShadowNetworkInfo.newInstance( + NetworkInfo.DetailedState.CONNECTED, ConnectivityManager.TYPE_MOBILE, 0, true, true)); + assertEquals(NetworkStatus.UP, NetworkUtils.getNetworkStatus(connectivityManager)); + } +} -- cgit v1.2.3