summaryrefslogtreecommitdiffstats
path: root/mobile/android/geckoview
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/geckoview')
-rw-r--r--mobile/android/geckoview/api.txt2543
-rw-r--r--mobile/android/geckoview/build.gradle562
-rw-r--r--mobile/android/geckoview/checkstyle-suppressions.xml13
-rw-r--r--mobile/android/geckoview/checkstyle.xml60
-rw-r--r--mobile/android/geckoview/proguard-rules.txt180
-rw-r--r--mobile/android/geckoview/src/androidTest/AndroidManifest.xml51
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/moz.build78
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/.eslintrc.js14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/background.js190
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.pngbin0 -> 1395 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.pngbin0 -> 1093 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.pngbin0 -> 1074 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.pngbin0 -> 225 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.pngbin0 -> 225 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/icon.svg1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/content.js4
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/manifest.json43
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-browser-action.js7
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-open-popup-page-action.js7
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup-messaging.js24
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/test-popup.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpibin0 -> 1827 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpibin0 -> 1882 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpibin0 -> 9221 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.pngbin0 -> 225 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/icon.svg1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json20
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/background.js44
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data-built-in/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/background.js8
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/browsing-data/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/download.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-false/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/download.js16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-flags-true/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/download.js18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/download-onChanged/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpibin0 -> 544 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/dummy.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy/manifest.json21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/manifest.json11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab-script.js5
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-restore/tab.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/background-script.js7
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/manifest.json21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab-script.js2
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tab.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/extension-page-update/tabs.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/manifest.json22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-content/messaging.js29
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/manifest.json23
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging-iframe/messaging.js11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/background.js28
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.pngbin0 -> 225 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/manifest.json18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/background.js6
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/notification-test/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/background.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-1/manifest.json20
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/background.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/openoptionspage-2/manifest.json20
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/manifest.json11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/page-history/page.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/clickToRequestPermission.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/manifest.json13
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/permission-request/request-permission.js11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/background.js39
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/manifest.json25
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/redirect-to-android-resource/web-accessible-script.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/background.js16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove-2/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/background.js16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-activate-remove/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/background.js4
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-2/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/background.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create-remove/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/background.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-create/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/background.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/tabs-remove/manifest.json15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.jsm83
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.jsm26
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js118
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json41
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js229
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json264
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js48
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-1/manifest.json18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-2/manifest.json17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/background.js3
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-1/manifest.json21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-postpone-2/manifest.json17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-1/manifest.json18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/borderify.js1
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/web_extensions/update-with-perms-2/manifest.json18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-aria-comboboxes.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-checkbox.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-clipboard.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-collection.html21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-expandable.html13
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-headings.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-links.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-atomic.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-descendant.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image-labeled-by.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region-image.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-live-region.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-local-iframe.html21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-move-caret-accessibility-focus.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-mutation.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-range.html23
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-remote-iframe.html24
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-scroll.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-selectable.html22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-text-entry-node.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/accessibility/test-tree.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/address_form.html21
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3bin0 -> 67430 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/autoplay.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/badVideoPath.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/beforeunload.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/cc_form.html22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/clickToReload.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/clipboard_read.html22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/color_grid.html40
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/color_orange_background.html29
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/colors.html23
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_audio.html20
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_buffered.html44
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_blob_full.html22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_image.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_image_nested.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_link.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/context_menu_video.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/data_uri.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/download.html18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/fixedbottom.html36
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/fixedpercent.html25
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/fixedvh.html25
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/form_blank.html20
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms.html34
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms2.html17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms2_iframe.html16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms3.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms4.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms5.html24
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete.html16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms_autocomplete_iframe.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms_id_value.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms_iframe.html58
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/forms_xorigin.html77
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/fullscreen.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_container.html58
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/getusermedia_xorigin_iframe.html39
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/hello.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/hello2.html9
-rwxr-xr-xmobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdfbin0 -> 10414 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/hsts_header.sjs6
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/hungScript.html16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_no_scrollable.html60
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_100_percent_height_scrollable.html60
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_no_scrollable.html55
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_98vh_scrollable.html55
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_hello.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_http_only.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_automation.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_redirect_local.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/iframe_unknown_protocol.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/images/test.gifbin0 -> 23961 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/inputs.html66
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/links.html28
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/loremIpsum.html17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/manifest.webmanifest17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/media_session_default1.html15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/media_session_dom1.html109
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/metatags.html19
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/mouseToReload.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/mp4.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/newSession.html22
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/newSession_child.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/no-meta-viewport.html5
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/ogg.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto-none.html28
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-auto.html28
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-auto.html28
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/overscroll-behavior-none-on-non-root.html37
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/popup.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/print_content_change.html37
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/print_iframe.html39
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/prompts.html31
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/push/push.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/push/push.js44
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/push/sw.js30
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/red-background-body-fully-covered-by-green-element.html23
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/reflect_local_storage_into_title.html17
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/resubmit.html12
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/root_100_percent_height.html37
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/root_100vh.html36
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/root_98vh.html36
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/saveState.html18
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html35
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/scroll.html59
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/select-listbox.html7
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/select-multiple.html7
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/select.html6
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame.html6
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html41
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/showDynamicToolbar.html96
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/simple_redirect.sjs4
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/titleChange.html16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/touch-action-wheel-listener.html33
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/touch-action.html48
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/touch.html58
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/touch_xorigin.html16
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/touchstart.html37
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdfbin0 -> 178030 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/trackers.html14
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/transparent.gifbin0 -> 43 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/update_manifest.json40
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webmbin0 -> 159035 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4bin0 -> 13651 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/videos/video.oggbin0 -> 285310 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/viewport.html19
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/webm.html11
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.html10
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/worker/open_window.js15
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/worker/open_window_target.html9
-rw-r--r--mobile/android/geckoview/src/androidTest/assets/www/worker/service-worker.js15
-rw-r--r--mobile/android/geckoview/src/androidTest/java/android/view/inputmethod/CursorAnchorInfo.java14
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/GeckoInputStreamTest.java167
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt2275
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutocompleteTest.kt2532
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AutofillDelegateTest.kt715
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt297
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.kt302
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentCrashTest.kt51
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt278
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt161
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateTest.kt660
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DisplayTest.kt23
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DynamicToolbarTest.kt727
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExtensionActionTest.kt878
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/FinderTest.kt456
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoAppShellTest.kt120
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.java673
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoResultTest.kt37
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt2114
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTest.kt462
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoViewTestActivity.java21
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeolocationTest.kt294
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GpuCrashTest.kt63
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/HistoryDelegateTest.kt303
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt306
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt417
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/LocaleTest.kt43
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateTest.kt177
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaDelegateXOriginTest.kt197
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt1031
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MultiMapTest.java213
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt3126
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NimbusTest.kt35
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OpenWindowTest.kt145
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/OrientationDelegateTest.kt311
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt613
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt159
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfSaveTest.kt30
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PermissionDelegateTest.kt1132
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt255
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrivateModeTest.kt105
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfileLockedTest.kt52
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProfilerControllerTest.kt45
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ProgressDelegateTest.kt582
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt1084
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt253
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ScreenshotTest.kt433
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt913
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SessionLifecycleTest.kt240
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/StorageControllerTest.kt874
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TelemetryTest.kt131
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TemporaryProfileRule.java35
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestContentProvider.java103
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java281
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestRuntimeService.java404
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt1407
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrackingPermissionService.java119
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/VerticalClippingTest.kt88
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt545
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt2989
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebNotificationTest.kt386
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushTest.kt257
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java165
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt48
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/RuntimeCrashTestService.kt19
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java2915
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/TestHarnessException.java11
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java93
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java175
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt167
-rw-r--r--mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java167
-rw-r--r--mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.pngbin0 -> 16210 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.pngbin0 -> 4856 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.pngbin0 -> 2304 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.pngbin0 -> 5593 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.pngbin0 -> 1836 bytes
-rw-r--r--mobile/android/geckoview/src/androidTest/res/values/colors.xml9
-rw-r--r--mobile/android/geckoview/src/androidTest/res/values/strings.xml7
-rw-r--r--mobile/android/geckoview/src/androidTest/res/values/styles.xml11
-rw-r--r--mobile/android/geckoview/src/asan/resources/lib/arm64-v8a/wrap.sh52
-rw-r--r--mobile/android/geckoview/src/asan/resources/lib/armeabi-v7a/wrap.sh52
-rw-r--r--mobile/android/geckoview/src/asan/resources/lib/x86/wrap.sh52
-rw-r--r--mobile/android/geckoview/src/asan/resources/lib/x86_64/wrap.sh52
-rw-r--r--mobile/android/geckoview/src/main/AndroidManifest.xml91
-rw-r--r--mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja19
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl44
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableParent.aidl37
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/GeckoSurface.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ICompositorSurfaceManager.aidl11
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/ISurfaceAllocator.aidl15
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/gfx/SyncConfig.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/FormatParam.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodec.aidl33
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/ICodecCallbacks.aidl17
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridge.aidl27
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaDrmBridgeCallbacks.aidl31
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/IMediaManager.aidl21
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/Sample.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SampleBuffer.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/media/SessionKeyInfo.aidl7
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl48
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IProcessManager.aidl14
-rw-r--r--mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/util/GeckoBundle.aidl7
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java415
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java181
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java537
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java96
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java588
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java1641
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java200
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java456
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java807
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java413
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java76
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java273
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java185
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java985
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java106
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java137
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java186
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java225
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java24
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java230
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java198
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java102
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java25
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java18
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java14
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java56
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java72
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java26
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java152
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java330
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java71
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java77
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java143
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java105
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java38
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java59
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java63
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java104
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java712
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java508
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java178
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java166
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java119
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java93
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java170
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java1113
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java340
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java518
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java40
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java771
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java50
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java43
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java45
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java490
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java250
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java298
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java79
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java254
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java163
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java252
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java291
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java101
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java154
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java50
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java39
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java440
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java12
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java192
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java927
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java40
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java213
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java63
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java74
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java613
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java141
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java21
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java136
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java58
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java72
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java1164
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java397
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java46
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java12
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java88
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java334
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java120
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java168
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java149
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java145
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja38
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java170
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java16
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java1445
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java1234
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java685
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java15
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java133
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java1689
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java203
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java385
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java36
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java528
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java2616
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java172
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java829
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java226
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java1072
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java1054
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java1314
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java7146
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java106
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java732
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java42
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java1248
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java196
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java189
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java54
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java647
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java60
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java246
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java949
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java19
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java182
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java646
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java266
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java171
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java164
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java936
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java131
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java98
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java463
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java20
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java405
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java586
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java2806
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java1577
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java117
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java233
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java29
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java165
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java62
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java180
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java248
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java380
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java227
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md1379
-rw-r--r--mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java40
-rw-r--r--mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml11
-rw-r--r--mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/GeckoBundleTest.java745
-rw-r--r--mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/IntentUtilsTest.java66
-rw-r--r--mobile/android/geckoview/src/test/java/org/mozilla/gecko/util/NetworkUtilsTest.java215
501 files changed, 106539 insertions, 0 deletions
diff --git a/mobile/android/geckoview/api.txt b/mobile/android/geckoview/api.txt
new file mode 100644
index 0000000000..c0024aa5c6
--- /dev/null
+++ b/mobile/android/geckoview/api.txt
@@ -0,0 +1,2543 @@
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.graphics.Point;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Region;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcel;
+import android.os.ParcelFileDescriptor;
+import android.os.Parcelable;
+import android.print.PageRange;
+import android.print.PrintAttributes;
+import android.print.PrintDocumentAdapter;
+import android.util.AttributeSet;
+import android.util.SparseArray;
+import android.view.ActionMode;
+import android.view.KeyEvent;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.MotionEvent;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.SurfaceControl;
+import android.view.View;
+import android.view.ViewStructure;
+import android.view.autofill.AutofillValue;
+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.widget.FrameLayout;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.io.File;
+import java.io.InputStream;
+import java.lang.Boolean;
+import java.lang.CharSequence;
+import java.lang.Class;
+import java.lang.Double;
+import java.lang.Exception;
+import java.lang.Float;
+import java.lang.Integer;
+import java.lang.Long;
+import java.lang.Object;
+import java.lang.Runnable;
+import java.lang.RuntimeException;
+import java.lang.SafeVarargs;
+import java.lang.String;
+import java.lang.Throwable;
+import java.lang.Void;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.security.cert.X509Certificate;
+import java.util.AbstractSequentialList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import org.json.JSONObject;
+import org.mozilla.geckoview.AllowOrDeny;
+import org.mozilla.geckoview.Autocomplete;
+import org.mozilla.geckoview.Autofill;
+import org.mozilla.geckoview.CompositorController;
+import org.mozilla.geckoview.ContentBlocking;
+import org.mozilla.geckoview.ContentBlockingController;
+import org.mozilla.geckoview.GeckoDisplay;
+import org.mozilla.geckoview.GeckoResult;
+import org.mozilla.geckoview.GeckoRuntime;
+import org.mozilla.geckoview.GeckoRuntimeSettings;
+import org.mozilla.geckoview.GeckoSession;
+import org.mozilla.geckoview.GeckoSessionSettings;
+import org.mozilla.geckoview.GeckoView;
+import org.mozilla.geckoview.Image;
+import org.mozilla.geckoview.MediaSession;
+import org.mozilla.geckoview.OrientationController;
+import org.mozilla.geckoview.OverscrollEdgeEffect;
+import org.mozilla.geckoview.PanZoomController;
+import org.mozilla.geckoview.ProfilerController;
+import org.mozilla.geckoview.RuntimeSettings;
+import org.mozilla.geckoview.RuntimeTelemetry;
+import org.mozilla.geckoview.ScreenLength;
+import org.mozilla.geckoview.SessionAccessibility;
+import org.mozilla.geckoview.SessionFinder;
+import org.mozilla.geckoview.SessionPdfFileSaver;
+import org.mozilla.geckoview.SessionTextInput;
+import org.mozilla.geckoview.SlowScriptResponse;
+import org.mozilla.geckoview.StorageController;
+import org.mozilla.geckoview.WebExtension;
+import org.mozilla.geckoview.WebExtensionController;
+import org.mozilla.geckoview.WebMessage;
+import org.mozilla.geckoview.WebNotification;
+import org.mozilla.geckoview.WebNotificationDelegate;
+import org.mozilla.geckoview.WebPushController;
+import org.mozilla.geckoview.WebPushDelegate;
+import org.mozilla.geckoview.WebPushSubscription;
+import org.mozilla.geckoview.WebRequest;
+import org.mozilla.geckoview.WebRequestError;
+import org.mozilla.geckoview.WebResponse;
+
+package org.mozilla.geckoview {
+
+ @AnyThread public final enum AllowOrDeny {
+ method public static AllowOrDeny valueOf(String);
+ method public static AllowOrDeny[] values();
+ enum_constant public static final AllowOrDeny ALLOW;
+ enum_constant public static final AllowOrDeny DENY;
+ }
+
+ public class Autocomplete {
+ ctor protected Autocomplete();
+ }
+
+ public static class Autocomplete.Address {
+ ctor @AnyThread protected Address();
+ field @NonNull public final String additionalName;
+ field @NonNull public final String addressLevel1;
+ field @NonNull public final String addressLevel2;
+ field @NonNull public final String addressLevel3;
+ field @NonNull public final String country;
+ field @NonNull public final String email;
+ field @NonNull public final String familyName;
+ field @NonNull public final String givenName;
+ field @Nullable public final String guid;
+ field @NonNull public final String name;
+ field @NonNull public final String organization;
+ field @NonNull public final String postalCode;
+ field @NonNull public final String streetAddress;
+ field @NonNull public final String tel;
+ }
+
+ public static class Autocomplete.Address.Builder {
+ ctor @AnyThread public Builder();
+ method @AnyThread @NonNull public Autocomplete.Address.Builder additionalName(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder addressLevel1(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder addressLevel2(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder addressLevel3(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address build();
+ method @AnyThread @NonNull public Autocomplete.Address.Builder country(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder email(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder familyName(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder givenName(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder guid(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder name(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder organization(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder postalCode(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder streetAddress(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.Address.Builder tel(@Nullable String);
+ }
+
+ public static class Autocomplete.AddressSaveOption extends Autocomplete.SaveOption<Autocomplete.Address> {
+ ctor public AddressSaveOption(@NonNull Autocomplete.Address);
+ }
+
+ public static class Autocomplete.AddressSelectOption extends Autocomplete.SelectOption<Autocomplete.Address> {
+ ctor public AddressSelectOption(@NonNull Autocomplete.Address);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autocomplete.AddressSelectOption.AddressSelectHint {
+ }
+
+ public static class Autocomplete.AddressSelectOption.Hint {
+ ctor public Hint();
+ field public static final int INSECURE_FORM = 2;
+ field public static final int NONE = 0;
+ }
+
+ public static class Autocomplete.CreditCard {
+ ctor @AnyThread protected CreditCard();
+ field @NonNull public final String expirationMonth;
+ field @NonNull public final String expirationYear;
+ field @Nullable public final String guid;
+ field @NonNull public final String name;
+ field @NonNull public final String number;
+ }
+
+ public static class Autocomplete.CreditCard.Builder {
+ ctor @AnyThread public Builder();
+ method @AnyThread @NonNull public Autocomplete.CreditCard build();
+ method @AnyThread @NonNull public Autocomplete.CreditCard.Builder expirationMonth(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.CreditCard.Builder expirationYear(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.CreditCard.Builder guid(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.CreditCard.Builder name(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.CreditCard.Builder number(@Nullable String);
+ }
+
+ public static class Autocomplete.CreditCardSaveOption extends Autocomplete.SaveOption<Autocomplete.CreditCard> {
+ ctor public CreditCardSaveOption(@NonNull Autocomplete.CreditCard);
+ }
+
+ public static class Autocomplete.CreditCardSelectOption extends Autocomplete.SelectOption<Autocomplete.CreditCard> {
+ ctor public CreditCardSelectOption(@NonNull Autocomplete.CreditCard);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autocomplete.CreditCardSelectOption.CreditCardSelectHint {
+ }
+
+ public static class Autocomplete.CreditCardSelectOption.Hint {
+ ctor public Hint();
+ field public static final int INSECURE_FORM = 2;
+ field public static final int NONE = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autocomplete.LSUsedField {
+ }
+
+ public static class Autocomplete.LoginEntry {
+ ctor @AnyThread protected LoginEntry();
+ field @Nullable public final String formActionOrigin;
+ field @Nullable public final String guid;
+ field @Nullable public final String httpRealm;
+ field @NonNull public final String origin;
+ field @NonNull public final String password;
+ field @NonNull public final String username;
+ }
+
+ public static class Autocomplete.LoginEntry.Builder {
+ ctor @AnyThread public Builder();
+ method @AnyThread @NonNull public Autocomplete.LoginEntry build();
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder formActionOrigin(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder guid(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder httpRealm(@Nullable String);
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder origin(@NonNull String);
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder password(@NonNull String);
+ method @AnyThread @NonNull public Autocomplete.LoginEntry.Builder username(@NonNull String);
+ }
+
+ public static class Autocomplete.LoginSaveOption extends Autocomplete.SaveOption<Autocomplete.LoginEntry> {
+ ctor public LoginSaveOption(@NonNull Autocomplete.LoginEntry);
+ }
+
+ public static class Autocomplete.LoginSelectOption extends Autocomplete.SelectOption<Autocomplete.LoginEntry> {
+ ctor public LoginSelectOption(@NonNull Autocomplete.LoginEntry);
+ }
+
+ public abstract static class Autocomplete.Option<T> {
+ ctor public Option(@NonNull T, int);
+ field public final int hint;
+ field @NonNull public final T value;
+ }
+
+ public abstract static class Autocomplete.SaveOption<T> extends Autocomplete.Option<T> {
+ ctor public SaveOption(@NonNull T, int);
+ }
+
+ public static class Autocomplete.SaveOption.Hint {
+ ctor protected Hint();
+ field public static final int GENERATED = 1;
+ field public static final int LOW_CONFIDENCE = 2;
+ field public static final int NONE = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autocomplete.SaveOption.SaveOptionHint {
+ }
+
+ public abstract static class Autocomplete.SelectOption<T> extends Autocomplete.Option<T> {
+ ctor public SelectOption(@NonNull T, int);
+ }
+
+ public static class Autocomplete.SelectOption.Hint {
+ ctor public Hint();
+ field public static final int DUPLICATE_USERNAME = 4;
+ field public static final int GENERATED = 1;
+ field public static final int INSECURE_FORM = 2;
+ field public static final int MATCHING_ORIGIN = 8;
+ field public static final int NONE = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autocomplete.SelectOption.SelectOptionHint {
+ }
+
+ public static interface Autocomplete.StorageDelegate {
+ method @Nullable @UiThread default public GeckoResult<Autocomplete.Address[]> onAddressFetch();
+ method @UiThread default public void onAddressSave(@NonNull Autocomplete.Address);
+ method @Nullable @UiThread default public GeckoResult<Autocomplete.CreditCard[]> onCreditCardFetch();
+ method @UiThread default public void onCreditCardSave(@NonNull Autocomplete.CreditCard);
+ method @Nullable @UiThread default public GeckoResult<Autocomplete.LoginEntry[]> onLoginFetch(@NonNull String);
+ method @Nullable @UiThread default public GeckoResult<Autocomplete.LoginEntry[]> onLoginFetch();
+ method @UiThread default public void onLoginSave(@NonNull Autocomplete.LoginEntry);
+ method @UiThread default public void onLoginUsed(@NonNull Autocomplete.LoginEntry, int);
+ }
+
+ public static class Autocomplete.UsedField {
+ ctor protected UsedField();
+ field public static final int PASSWORD = 1;
+ }
+
+ public class Autofill {
+ ctor public Autofill();
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autofill.AutofillHint {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface Autofill.AutofillInputType {
+ }
+
+ public static interface Autofill.AutofillNotify {
+ }
+
+ public static interface Autofill.Delegate {
+ method @UiThread default public void onNodeAdd(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onNodeBlur(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onNodeFocus(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onNodeRemove(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onNodeUpdate(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onSessionCancel(@NonNull GeckoSession);
+ method @UiThread default public void onSessionCommit(@NonNull GeckoSession, @NonNull Autofill.Node, @NonNull Autofill.NodeData);
+ method @UiThread default public void onSessionStart(@NonNull GeckoSession);
+ }
+
+ public static final class Autofill.Hint {
+ method @AnyThread @Nullable public static String toString(int);
+ field public static final int EMAIL_ADDRESS = 0;
+ field public static final int NONE = -1;
+ field public static final int PASSWORD = 1;
+ field public static final int URI = 2;
+ field public static final int USERNAME = 3;
+ }
+
+ public static final class Autofill.InputType {
+ method @AnyThread @Nullable public static String toString(int);
+ field public static final int NONE = -1;
+ field public static final int NUMBER = 1;
+ field public static final int PHONE = 2;
+ field public static final int TEXT = 0;
+ }
+
+ public static final class Autofill.Node {
+ method @AnyThread @Nullable public String getAttribute(@NonNull String);
+ method @AnyThread @NonNull public Map<String,String> getAttributes();
+ method @AnyThread @NonNull public Collection<Autofill.Node> getChildren();
+ method @AnyThread @NonNull public String getDomain();
+ method @AnyThread public boolean getEnabled();
+ method @AnyThread public boolean getFocusable();
+ method @AnyThread public int getHint();
+ method @AnyThread public int getInputType();
+ method @AnyThread @NonNull public Rect getScreenRect();
+ method @AnyThread @NonNull public String getTag();
+ }
+
+ public static class Autofill.NodeData {
+ method @AnyThread public int getId();
+ method @AnyThread @Nullable public String getValue();
+ }
+
+ public static final class Autofill.Session {
+ method @UiThread public void autofill(@NonNull SparseArray<CharSequence>);
+ method @NonNull @UiThread public Autofill.NodeData dataFor(@NonNull Autofill.Node);
+ method @UiThread public void fillViewStructure(@NonNull View, @NonNull ViewStructure, int);
+ method @UiThread public void fillViewStructure(@NonNull Autofill.Node, @NonNull View, @NonNull ViewStructure, int);
+ method @NonNull @UiThread public Rect getDefaultDimensions();
+ method @Nullable @UiThread public Autofill.Node getFocused();
+ method @Nullable @UiThread public Autofill.NodeData getFocusedData();
+ method @AnyThread @NonNull public Autofill.Node getRoot();
+ method @UiThread public boolean isVisible(@NonNull Autofill.Node);
+ }
+
+ @UiThread public class BasicSelectionActionDelegate implements ActionMode.Callback GeckoSession.SelectionActionDelegate {
+ ctor public BasicSelectionActionDelegate(@NonNull Activity);
+ ctor public BasicSelectionActionDelegate(@NonNull Activity, boolean);
+ method public boolean areExternalActionsEnabled();
+ method public void clearSelection();
+ method public void enableExternalActions(boolean);
+ method @Nullable public GeckoSession.SelectionActionDelegate.Selection getSelection();
+ method public boolean isActionAvailable();
+ method public void onGetContentRect(@Nullable ActionMode, @Nullable View, @NonNull Rect);
+ method @NonNull protected String[] getAllActions();
+ method protected boolean isActionAvailable(@NonNull String);
+ method protected boolean performAction(@NonNull String, @NonNull MenuItem);
+ method protected void prepareAction(@NonNull String, @NonNull MenuItem);
+ field protected static final String ACTION_PROCESS_TEXT = "android.intent.action.PROCESS_TEXT";
+ field @Nullable protected ActionMode mActionMode;
+ field @NonNull protected final Activity mActivity;
+ field protected boolean mRepopulatedMenu;
+ field @Nullable protected GeckoSession.SelectionActionDelegate.Selection mSelection;
+ field @Nullable protected GeckoSession mSession;
+ field protected final boolean mUseFloatingToolbar;
+ }
+
+ @UiThread public final class CompositorController {
+ method public void addDrawCallback(@NonNull Runnable);
+ method public int getClearColor();
+ method @Nullable public Runnable getFirstPaintCallback();
+ method public void removeDrawCallback(@NonNull Runnable);
+ method public void setClearColor(int);
+ method public void setFirstPaintCallback(@Nullable Runnable);
+ }
+
+ @AnyThread public class ContentBlocking {
+ ctor protected ContentBlocking();
+ field public static final ContentBlocking.SafeBrowsingProvider GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER;
+ field public static final ContentBlocking.SafeBrowsingProvider GOOGLE_SAFE_BROWSING_PROVIDER;
+ }
+
+ public static class ContentBlocking.AntiTracking {
+ ctor protected AntiTracking();
+ field public static final int AD = 2;
+ field public static final int ANALYTIC = 4;
+ field public static final int CONTENT = 16;
+ field public static final int CRYPTOMINING = 64;
+ field public static final int DEFAULT = 46;
+ field public static final int FINGERPRINTING = 128;
+ field public static final int NONE = 0;
+ field public static final int SOCIAL = 8;
+ field public static final int STP = 256;
+ field public static final int STRICT = 254;
+ field public static final int TEST = 32;
+ }
+
+ public static class ContentBlocking.BlockEvent {
+ ctor public BlockEvent(@NonNull String, int, int, int, boolean);
+ method @UiThread public int getAntiTrackingCategory();
+ method @UiThread public int getCookieBehaviorCategory();
+ method @UiThread public int getSafeBrowsingCategory();
+ method @UiThread public boolean isBlocking();
+ field @NonNull public final String uri;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlocking.CBAntiTracking {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlocking.CBCookieBannerMode {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlocking.CBCookieBehavior {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlocking.CBEtpLevel {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlocking.CBSafeBrowsing {
+ }
+
+ public static class ContentBlocking.CookieBannerMode {
+ ctor protected CookieBannerMode();
+ field public static final int COOKIE_BANNER_MODE_DISABLED = 0;
+ field public static final int COOKIE_BANNER_MODE_REJECT = 1;
+ field public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2;
+ }
+
+ public static class ContentBlocking.CookieBehavior {
+ ctor protected CookieBehavior();
+ field public static final int ACCEPT_ALL = 0;
+ field public static final int ACCEPT_FIRST_PARTY = 1;
+ field public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5;
+ field public static final int ACCEPT_NONE = 2;
+ field public static final int ACCEPT_NON_TRACKERS = 4;
+ field public static final int ACCEPT_VISITED = 3;
+ }
+
+ public static interface ContentBlocking.Delegate {
+ method @UiThread default public void onContentBlocked(@NonNull GeckoSession, @NonNull ContentBlocking.BlockEvent);
+ method @UiThread default public void onContentLoaded(@NonNull GeckoSession, @NonNull ContentBlocking.BlockEvent);
+ }
+
+ public static class ContentBlocking.EtpLevel {
+ ctor public EtpLevel();
+ field public static final int DEFAULT = 1;
+ field public static final int NONE = 0;
+ field public static final int STRICT = 2;
+ }
+
+ public static class ContentBlocking.SafeBrowsing {
+ ctor protected SafeBrowsing();
+ field public static final int DEFAULT = 15360;
+ field public static final int HARMFUL = 4096;
+ field public static final int MALWARE = 1024;
+ field public static final int NONE = 0;
+ field public static final int PHISHING = 8192;
+ field public static final int UNWANTED = 2048;
+ }
+
+ @AnyThread public static class ContentBlocking.SafeBrowsingProvider extends RuntimeSettings {
+ method @NonNull public static ContentBlocking.SafeBrowsingProvider.Builder from(@NonNull ContentBlocking.SafeBrowsingProvider);
+ method @Nullable public String getAdvisoryName();
+ method @Nullable public String getAdvisoryUrl();
+ method @Nullable public Boolean getDataSharingEnabled();
+ method @Nullable public String getDataSharingUrl();
+ method @Nullable public String getGetHashUrl();
+ method @NonNull public String[] getLists();
+ method @NonNull public String getName();
+ method @Nullable public String getReportMalwareMistakeUrl();
+ method @Nullable public String getReportPhishingMistakeUrl();
+ method @Nullable public String getReportUrl();
+ method @Nullable public String getUpdateUrl();
+ method @Nullable public String getVersion();
+ method @NonNull public static ContentBlocking.SafeBrowsingProvider.Builder withName(@NonNull String);
+ field public static final Parcelable.Creator<ContentBlocking.SafeBrowsingProvider> CREATOR;
+ }
+
+ @AnyThread public static class ContentBlocking.SafeBrowsingProvider.Builder {
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder advisoryName(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder advisoryUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider build();
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder dataSharingEnabled(boolean);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder dataSharingUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder getHashUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder lists(@NonNull String...);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder reportMalwareMistakeUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder reportPhishingMistakeUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder reportUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder updateUrl(@NonNull String);
+ method @NonNull public ContentBlocking.SafeBrowsingProvider.Builder version(@NonNull String);
+ }
+
+ @AnyThread public static class ContentBlocking.Settings extends RuntimeSettings {
+ method public int getAntiTrackingCategories();
+ method public boolean getCookieBannerDetectOnlyMode();
+ method public int getCookieBannerMode();
+ method public int getCookieBannerModePrivateBrowsing();
+ method public int getCookieBehavior();
+ method public int getCookieBehaviorPrivateMode();
+ method public boolean getCookiePurging();
+ method public int getEnhancedTrackingProtectionLevel();
+ method public int getSafeBrowsingCategories();
+ method @NonNull public String[] getSafeBrowsingMalwareTable();
+ method @NonNull public String[] getSafeBrowsingPhishingTable();
+ method @NonNull public Collection<ContentBlocking.SafeBrowsingProvider> getSafeBrowsingProviders();
+ method public boolean getStrictSocialTrackingProtection();
+ method @NonNull public ContentBlocking.Settings setAntiTracking(int);
+ method @NonNull public ContentBlocking.Settings setCookieBannerDetectOnlyMode(boolean);
+ method @NonNull public ContentBlocking.Settings setCookieBannerMode(int);
+ method @NonNull public ContentBlocking.Settings setCookieBannerModePrivateBrowsing(int);
+ method @NonNull public ContentBlocking.Settings setCookieBehavior(int);
+ method @NonNull public ContentBlocking.Settings setCookieBehaviorPrivateMode(int);
+ method @NonNull public ContentBlocking.Settings setCookiePurging(boolean);
+ method @NonNull public ContentBlocking.Settings setEnhancedTrackingProtectionLevel(int);
+ method @NonNull public ContentBlocking.Settings setSafeBrowsing(int);
+ method @NonNull public ContentBlocking.Settings setSafeBrowsingMalwareTable(@NonNull String...);
+ method @NonNull public ContentBlocking.Settings setSafeBrowsingPhishingTable(@NonNull String...);
+ method @NonNull public ContentBlocking.Settings setSafeBrowsingProviders(@NonNull ContentBlocking.SafeBrowsingProvider...);
+ method @NonNull public ContentBlocking.Settings setStrictSocialTrackingProtection(boolean);
+ field public static final Parcelable.Creator<ContentBlocking.Settings> CREATOR;
+ }
+
+ @AnyThread public static class ContentBlocking.Settings.Builder extends RuntimeSettings.Builder<ContentBlocking.Settings> {
+ ctor public Builder();
+ method @NonNull public ContentBlocking.Settings.Builder antiTracking(int);
+ method @NonNull public ContentBlocking.Settings.Builder cookieBannerHandlingDetectOnlyMode(boolean);
+ method @NonNull public ContentBlocking.Settings.Builder cookieBannerHandlingMode(int);
+ method @NonNull public ContentBlocking.Settings.Builder cookieBannerHandlingModePrivateBrowsing(int);
+ method @NonNull public ContentBlocking.Settings.Builder cookieBehavior(int);
+ method @NonNull public ContentBlocking.Settings.Builder cookieBehaviorPrivateMode(int);
+ method @NonNull public ContentBlocking.Settings.Builder cookiePurging(boolean);
+ method @NonNull public ContentBlocking.Settings.Builder enhancedTrackingProtectionLevel(int);
+ method @NonNull public ContentBlocking.Settings.Builder safeBrowsing(int);
+ method @NonNull public ContentBlocking.Settings.Builder safeBrowsingMalwareTable(@NonNull String[]);
+ method @NonNull public ContentBlocking.Settings.Builder safeBrowsingPhishingTable(@NonNull String[]);
+ method @NonNull public ContentBlocking.Settings.Builder safeBrowsingProviders(@NonNull ContentBlocking.SafeBrowsingProvider...);
+ method @NonNull public ContentBlocking.Settings.Builder strictSocialTrackingProtection(boolean);
+ method @NonNull protected ContentBlocking.Settings newSettings(@Nullable ContentBlocking.Settings);
+ }
+
+ @AnyThread public class ContentBlockingController {
+ ctor public ContentBlockingController();
+ method @NonNull @UiThread public GeckoResult<List<ContentBlockingController.LogEntry>> getLog(@NonNull GeckoSession);
+ }
+
+ public static class ContentBlockingController.Event {
+ ctor protected Event();
+ field public static final int ALLOWED_TRACKING_CONTENT = 32;
+ field public static final int BLOCKED_CRYPTOMINING_CONTENT = 2048;
+ field public static final int BLOCKED_FINGERPRINTING_CONTENT = 64;
+ field public static final int BLOCKED_SOCIALTRACKING_CONTENT = 65536;
+ field public static final int BLOCKED_TRACKING_CONTENT = 4096;
+ field public static final int BLOCKED_UNSAFE_CONTENT = 16384;
+ field public static final int COOKIES_BLOCKED_ALL = 1073741824;
+ field public static final int COOKIES_BLOCKED_BY_PERMISSION = 268435456;
+ field public static final int COOKIES_BLOCKED_FOREIGN = 128;
+ field public static final int COOKIES_BLOCKED_SOCIALTRACKER = 16777216;
+ field public static final int COOKIES_BLOCKED_TRACKER = 536870912;
+ field public static final int COOKIES_LOADED = 32768;
+ field public static final int COOKIES_LOADED_SOCIALTRACKER = 524288;
+ field public static final int COOKIES_LOADED_TRACKER = 262144;
+ field public static final int COOKIES_PARTITIONED_FOREIGN = -2147483648;
+ field public static final int LOADED_CRYPTOMINING_CONTENT = 2097152;
+ field public static final int LOADED_FINGERPRINTING_CONTENT = 1024;
+ field public static final int LOADED_LEVEL_1_TRACKING_CONTENT = 8192;
+ field public static final int LOADED_LEVEL_2_TRACKING_CONTENT = 1048576;
+ field public static final int LOADED_SOCIALTRACKING_CONTENT = 131072;
+ field public static final int REPLACED_TRACKING_CONTENT = 16;
+ }
+
+ @AnyThread public static class ContentBlockingController.LogEntry {
+ ctor protected LogEntry();
+ field @NonNull public final List<ContentBlockingController.LogEntry.BlockingData> blockingData;
+ field @NonNull public final String origin;
+ }
+
+ public static class ContentBlockingController.LogEntry.BlockingData {
+ ctor protected BlockingData();
+ field public final boolean blocked;
+ field public final int category;
+ field public final int count;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ContentBlockingController.LogEntry.BlockingData.LogEvent {
+ }
+
+ public class CrashReporter {
+ ctor public CrashReporter();
+ method @AnyThread @NonNull public static GeckoResult<String> sendCrashReport(@NonNull Context, @NonNull Intent, @NonNull String);
+ method @AnyThread @NonNull public static GeckoResult<String> sendCrashReport(@NonNull Context, @NonNull Bundle, @NonNull String);
+ method @AnyThread @NonNull public static GeckoResult<String> sendCrashReport(@NonNull Context, @NonNull File, @NonNull File, @NonNull String);
+ method @AnyThread @NonNull public static GeckoResult<String> sendCrashReport(@NonNull String, @NonNull File, @NonNull JSONObject);
+ }
+
+ @Documented @Retention(value=RetentionPolicy.RUNTIME) @Target(value={ElementType.CONSTRUCTOR, ElementType.FIELD, ElementType.LOCAL_VARIABLE, ElementType.METHOD, ElementType.PACKAGE, ElementType.PARAMETER, ElementType.TYPE}) public interface DeprecationSchedule {
+ element public String id();
+ element public int version();
+ }
+
+ public class GeckoDisplay {
+ ctor protected GeckoDisplay(GeckoSession);
+ method @NonNull @UiThread public GeckoResult<Bitmap> capturePixels();
+ method @UiThread public void safeAreaInsetsChanged(int, int, int, int);
+ method @UiThread public void screenOriginChanged(int, int);
+ method @NonNull @UiThread public GeckoDisplay.ScreenshotBuilder screenshot();
+ method @UiThread public void setDynamicToolbarMaxHeight(int);
+ method @UiThread public void setVerticalClipping(int);
+ method @UiThread public boolean shouldPinOnScreen();
+ method @UiThread public void surfaceChanged(@NonNull GeckoDisplay.SurfaceInfo);
+ method @UiThread public void surfaceDestroyed();
+ }
+
+ public static interface GeckoDisplay.NewSurfaceProvider {
+ method @UiThread public void requestNewSurface();
+ }
+
+ public static final class GeckoDisplay.ScreenshotBuilder {
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder aspectPreservingSize(int);
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder bitmap(@Nullable Bitmap);
+ method @NonNull @UiThread public GeckoResult<Bitmap> capture();
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder scale(float);
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder size(int, int);
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder source(int, int, int, int);
+ method @AnyThread @NonNull public GeckoDisplay.ScreenshotBuilder source(@NonNull Rect);
+ }
+
+ public static class GeckoDisplay.SurfaceInfo {
+ }
+
+ public static class GeckoDisplay.SurfaceInfo.Builder {
+ ctor public Builder(@NonNull Surface);
+ method @NonNull @UiThread public GeckoDisplay.SurfaceInfo build();
+ method @NonNull @UiThread public GeckoDisplay.SurfaceInfo.Builder newSurfaceProvider(@Nullable GeckoDisplay.NewSurfaceProvider);
+ method @NonNull @UiThread public GeckoDisplay.SurfaceInfo.Builder offset(int, int);
+ method @NonNull @UiThread public GeckoDisplay.SurfaceInfo.Builder size(int, int);
+ method @NonNull @UiThread public GeckoDisplay.SurfaceInfo.Builder surfaceControl(@Nullable SurfaceControl);
+ }
+
+ @AnyThread public class GeckoResult<T> {
+ ctor public GeckoResult();
+ ctor public GeckoResult(Handler);
+ ctor public GeckoResult(GeckoResult<T>);
+ method @NonNull public GeckoResult<Void> accept(@Nullable GeckoResult.Consumer<T>);
+ method @NonNull public GeckoResult<Void> accept(@Nullable GeckoResult.Consumer<T>, @Nullable GeckoResult.Consumer<Throwable>);
+ method @NonNull @SafeVarargs public static <V> GeckoResult<List<V>> allOf(@NonNull GeckoResult<V>...);
+ method @NonNull public static <V> GeckoResult<List<V>> allOf(@Nullable List<GeckoResult<V>>);
+ method @AnyThread @NonNull public static GeckoResult<AllowOrDeny> allow();
+ method @NonNull public synchronized GeckoResult<Boolean> cancel();
+ method public synchronized void complete(@Nullable T);
+ method public synchronized void completeExceptionally(@NonNull Throwable);
+ method public void completeFrom(@Nullable GeckoResult<T>);
+ method @AnyThread @NonNull public static GeckoResult<AllowOrDeny> deny();
+ method @NonNull public <U> GeckoResult<U> exceptionally(@NonNull GeckoResult.OnExceptionListener<U>);
+ method @NonNull public GeckoResult<Void> finally_(@NonNull Runnable);
+ method @NonNull public static <T> GeckoResult<T> fromException(@NonNull Throwable);
+ method @NonNull public static <U> GeckoResult<U> fromValue(@Nullable U);
+ method @Nullable public Looper getLooper();
+ method @NonNull public <U> GeckoResult<U> map(@Nullable GeckoResult.OnValueMapper<T,U>);
+ method @NonNull public <U> GeckoResult<U> map(@Nullable GeckoResult.OnValueMapper<T,U>, @Nullable GeckoResult.OnExceptionMapper);
+ method @Nullable public synchronized T poll();
+ method @Nullable public synchronized T poll(long);
+ method public void setCancellationDelegate(@Nullable GeckoResult.CancellationDelegate);
+ method @NonNull public <U> GeckoResult<U> then(@NonNull GeckoResult.OnValueListener<T,U>);
+ method @NonNull public <U> GeckoResult<U> then(@Nullable GeckoResult.OnValueListener<T,U>, @Nullable GeckoResult.OnExceptionListener<U>);
+ method @NonNull public GeckoResult<T> withHandler(@Nullable Handler);
+ }
+
+ @AnyThread public static interface GeckoResult.CancellationDelegate {
+ method @NonNull default public GeckoResult<Boolean> cancel();
+ }
+
+ public static interface GeckoResult.Consumer<T> {
+ method @AnyThread public void accept(@Nullable T);
+ }
+
+ public static interface GeckoResult.OnExceptionListener<V> {
+ method @AnyThread @Nullable public GeckoResult<V> onException(@NonNull Throwable);
+ }
+
+ public static interface GeckoResult.OnExceptionMapper {
+ method @AnyThread @Nullable public Throwable onException(@NonNull Throwable);
+ }
+
+ public static interface GeckoResult.OnValueListener<T,U> {
+ method @AnyThread @Nullable public GeckoResult<U> onValue(@Nullable T);
+ }
+
+ public static interface GeckoResult.OnValueMapper<T,U> {
+ method @AnyThread @Nullable public U onValue(@Nullable T);
+ }
+
+ public static final class GeckoResult.UncaughtException extends RuntimeException {
+ ctor public UncaughtException(Throwable);
+ }
+
+ public final class GeckoRuntime implements Parcelable {
+ method @AnyThread public void appendAppNotesToCrashReport(@NonNull String);
+ method @UiThread public void attachTo(@NonNull Context);
+ method @UiThread public void configurationChanged(@NonNull Configuration);
+ method @NonNull @UiThread public static GeckoRuntime create(@NonNull Context);
+ method @NonNull @UiThread public static GeckoRuntime create(@NonNull Context, @NonNull GeckoRuntimeSettings);
+ method @Nullable @UiThread public GeckoRuntime.ActivityDelegate getActivityDelegate();
+ method @Nullable @UiThread public Autocomplete.StorageDelegate getAutocompleteStorageDelegate();
+ method @NonNull @UiThread public ContentBlockingController getContentBlockingController();
+ method @NonNull @UiThread public static synchronized GeckoRuntime getDefault(@NonNull Context);
+ method @Nullable @UiThread public GeckoRuntime.Delegate getDelegate();
+ method @NonNull @UiThread public OrientationController getOrientationController();
+ method @NonNull @UiThread public ProfilerController getProfilerController();
+ method @Nullable @UiThread public GeckoRuntime.ServiceWorkerDelegate getServiceWorkerDelegate();
+ method @AnyThread @NonNull public GeckoRuntimeSettings getSettings();
+ method @NonNull @UiThread public StorageController getStorageController();
+ method @NonNull @UiThread public WebExtensionController getWebExtensionController();
+ method @Nullable @UiThread public WebNotificationDelegate getWebNotificationDelegate();
+ method @NonNull @UiThread public WebPushController getWebPushController();
+ method @UiThread public void orientationChanged();
+ method @UiThread public void orientationChanged(int);
+ method @AnyThread public void readFromParcel(@NonNull Parcel);
+ method @UiThread public void setActivityDelegate(@Nullable GeckoRuntime.ActivityDelegate);
+ method @UiThread public void setAutocompleteStorageDelegate(@Nullable Autocomplete.StorageDelegate);
+ method @UiThread public void setDelegate(@Nullable GeckoRuntime.Delegate);
+ method @UiThread public void setServiceWorkerDelegate(@Nullable GeckoRuntime.ServiceWorkerDelegate);
+ method @UiThread public void setWebNotificationDelegate(@Nullable WebNotificationDelegate);
+ method @AnyThread public void shutdown();
+ field public static final String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED";
+ field public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD";
+ field public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD";
+ field public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN";
+ field public static final Parcelable.Creator<GeckoRuntime> CREATOR;
+ field public static final String EXTRA_CRASH_PROCESS_TYPE = "processType";
+ field public static final String EXTRA_EXTRAS_PATH = "extrasPath";
+ field public static final String EXTRA_MINIDUMP_PATH = "minidumpPath";
+ }
+
+ public static interface GeckoRuntime.ActivityDelegate {
+ method @Nullable @UiThread public GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoRuntime.CrashedProcessType {
+ }
+
+ public static interface GeckoRuntime.Delegate {
+ method @UiThread public void onShutdown();
+ }
+
+ @UiThread public static interface GeckoRuntime.ServiceWorkerDelegate {
+ method @NonNull @UiThread public GeckoResult<GeckoSession> onOpenWindow(@NonNull String);
+ }
+
+ @AnyThread public final class GeckoRuntimeSettings extends RuntimeSettings {
+ method public boolean getAboutConfigEnabled();
+ method public int getAllowInsecureConnections();
+ method @NonNull public String[] getArguments();
+ method public boolean getAutomaticFontSizeAdjustment();
+ method @Nullable public String getConfigFilePath();
+ method public boolean getConsoleOutputEnabled();
+ method @NonNull public ContentBlocking.Settings getContentBlocking();
+ method @Nullable public Class<? extends android.app.Service> getCrashHandler();
+ method @Nullable public Float getDisplayDensityOverride();
+ method @Nullable public Integer getDisplayDpiOverride();
+ method public boolean getDoubleTapZoomingEnabled();
+ method public boolean getEnterpriseRootsEnabled();
+ method @NonNull public Bundle getExtras();
+ method public boolean getFontInflationEnabled();
+ method public float getFontSizeFactor();
+ method public boolean getForceEnableAccessibility();
+ method public boolean getForceUserScalableEnabled();
+ method public int getGlMsaaLevel();
+ method public boolean getInputAutoZoomEnabled();
+ method public boolean getJavaScriptEnabled();
+ method @Nullable public String[] getLocales();
+ method public boolean getLoginAutofillEnabled();
+ method public boolean getPauseForDebuggerEnabled();
+ method public int getPreferredColorScheme();
+ method public boolean getRemoteDebuggingEnabled();
+ method @Nullable public GeckoRuntime getRuntime();
+ method @Nullable public Rect getScreenSizeOverride();
+ method @Nullable public RuntimeTelemetry.Delegate getTelemetryDelegate();
+ method public boolean getUseMaxScreenDepth();
+ method public boolean getWebFontsEnabled();
+ method public boolean getWebManifestEnabled();
+ method @NonNull public GeckoRuntimeSettings setAboutConfigEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setAllowInsecureConnections(int);
+ method @NonNull public GeckoRuntimeSettings setAutomaticFontSizeAdjustment(boolean);
+ method @NonNull public GeckoRuntimeSettings setConsoleOutputEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setDoubleTapZoomingEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setEnterpriseRootsEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setFontInflationEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setFontSizeFactor(float);
+ method @NonNull public GeckoRuntimeSettings setForceEnableAccessibility(boolean);
+ method @NonNull public GeckoRuntimeSettings setForceUserScalableEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setGlMsaaLevel(int);
+ method @NonNull public GeckoRuntimeSettings setInputAutoZoomEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setJavaScriptEnabled(boolean);
+ method public void setLocales(@Nullable String[]);
+ method @NonNull public GeckoRuntimeSettings setLoginAutofillEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setPreferredColorScheme(int);
+ method @NonNull public GeckoRuntimeSettings setRemoteDebuggingEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setWebFontsEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings setWebManifestEnabled(boolean);
+ field public static final int ALLOW_ALL = 0;
+ field public static final int COLOR_SCHEME_DARK = 1;
+ field public static final int COLOR_SCHEME_LIGHT = 0;
+ field public static final int COLOR_SCHEME_SYSTEM = -1;
+ field public static final Parcelable.Creator<GeckoRuntimeSettings> CREATOR;
+ field public static final int HTTPS_ONLY = 2;
+ field public static final int HTTPS_ONLY_PRIVATE = 1;
+ }
+
+ @AnyThread public static final class GeckoRuntimeSettings.Builder extends RuntimeSettings.Builder<GeckoRuntimeSettings> {
+ ctor public Builder();
+ method @NonNull public GeckoRuntimeSettings.Builder aboutConfigEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder allowInsecureConnections(int);
+ method @NonNull public GeckoRuntimeSettings.Builder arguments(@NonNull String[]);
+ method @NonNull public GeckoRuntimeSettings.Builder automaticFontSizeAdjustment(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder configFilePath(@Nullable String);
+ method @NonNull public GeckoRuntimeSettings.Builder consoleOutput(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder contentBlocking(@NonNull ContentBlocking.Settings);
+ method @NonNull public GeckoRuntimeSettings.Builder crashHandler(@Nullable Class<? extends android.app.Service>);
+ method @NonNull public GeckoRuntimeSettings.Builder debugLogging(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder displayDensityOverride(float);
+ method @NonNull public GeckoRuntimeSettings.Builder displayDpiOverride(int);
+ method @NonNull public GeckoRuntimeSettings.Builder doubleTapZoomingEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder enterpriseRootsEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder extras(@NonNull Bundle);
+ method @NonNull public GeckoRuntimeSettings.Builder fontInflation(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder fontSizeFactor(float);
+ method @NonNull public GeckoRuntimeSettings.Builder forceUserScalableEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder glMsaaLevel(int);
+ method @NonNull public GeckoRuntimeSettings.Builder inputAutoZoomEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder javaScriptEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder locales(@Nullable String[]);
+ method @NonNull public GeckoRuntimeSettings.Builder loginAutofillEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder pauseForDebugger(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder preferredColorScheme(int);
+ method @NonNull public GeckoRuntimeSettings.Builder remoteDebuggingEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder screenSizeOverride(int, int);
+ method @NonNull public GeckoRuntimeSettings.Builder telemetryDelegate(@NonNull RuntimeTelemetry.Delegate);
+ method @NonNull public GeckoRuntimeSettings.Builder useMaxScreenDepth(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder webFontsEnabled(boolean);
+ method @NonNull public GeckoRuntimeSettings.Builder webManifest(boolean);
+ method @NonNull protected GeckoRuntimeSettings newSettings(@Nullable GeckoRuntimeSettings);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoRuntimeSettings.ColorScheme {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoRuntimeSettings.HttpsOnlyMode {
+ }
+
+ public class GeckoSession {
+ ctor public GeckoSession();
+ ctor public GeckoSession(@Nullable GeckoSessionSettings);
+ method @NonNull @UiThread public GeckoDisplay acquireDisplay();
+ method @UiThread public void close();
+ method @AnyThread @NonNull public GeckoResult<Boolean> containsFormData();
+ method @AnyThread public void exitFullScreen();
+ method @NonNull @UiThread public SessionAccessibility getAccessibility();
+ method @Nullable @UiThread public Autofill.Delegate getAutofillDelegate();
+ method @NonNull @UiThread public Autofill.Session getAutofillSession();
+ method @UiThread public void getClientBounds(@NonNull RectF);
+ method @UiThread public void getClientToScreenMatrix(@NonNull Matrix);
+ method @UiThread public void getClientToSurfaceMatrix(@NonNull Matrix);
+ method @NonNull @UiThread public CompositorController getCompositorController();
+ method @AnyThread @Nullable public ContentBlocking.Delegate getContentBlockingDelegate();
+ method @Nullable @UiThread public GeckoSession.ContentDelegate getContentDelegate();
+ method @AnyThread @NonNull public static String getDefaultUserAgent();
+ method @AnyThread @NonNull public SessionFinder getFinder();
+ method @AnyThread @Nullable public GeckoSession.HistoryDelegate getHistoryDelegate();
+ method @AnyThread @Nullable public GeckoSession.MediaDelegate getMediaDelegate();
+ method @AnyThread @Nullable public MediaSession.Delegate getMediaSessionDelegate();
+ method @Nullable @UiThread public GeckoSession.NavigationDelegate getNavigationDelegate();
+ method @NonNull @UiThread public OverscrollEdgeEffect getOverscrollEdgeEffect();
+ method @UiThread public void getPageToScreenMatrix(@NonNull Matrix);
+ method @UiThread public void getPageToSurfaceMatrix(@NonNull Matrix);
+ method @NonNull @UiThread public PanZoomController getPanZoomController();
+ method @AnyThread @NonNull public SessionPdfFileSaver getPdfFileSaver();
+ method @Nullable @UiThread public GeckoSession.PermissionDelegate getPermissionDelegate();
+ method @AnyThread @Nullable public GeckoSession.PrintDelegate getPrintDelegate();
+ method @Nullable @UiThread public GeckoSession.ProgressDelegate getProgressDelegate();
+ method @AnyThread @Nullable public GeckoSession.PromptDelegate getPromptDelegate();
+ method @Nullable @UiThread public GeckoSession.ScrollDelegate getScrollDelegate();
+ method @AnyThread @Nullable public GeckoSession.SelectionActionDelegate getSelectionActionDelegate();
+ method @AnyThread @NonNull public GeckoSessionSettings getSettings();
+ method @UiThread public void getSurfaceBounds(@NonNull Rect);
+ method @AnyThread @NonNull public SessionTextInput getTextInput();
+ method @AnyThread @NonNull public GeckoResult<String> getUserAgent();
+ method @NonNull @UiThread public WebExtension.SessionController getWebExtensionController();
+ method @AnyThread public void goBack();
+ method @AnyThread public void goBack(boolean);
+ method @AnyThread public void goForward();
+ method @AnyThread public void goForward(boolean);
+ method @AnyThread public void gotoHistoryIndex(int);
+ method @AnyThread @NonNull public GeckoResult<Boolean> hasCookieBannerRuleForBrowsingContextTree();
+ method @UiThread public boolean isOpen();
+ method @AnyThread @NonNull public GeckoResult<Boolean> isPdfJs();
+ method @AnyThread public void load(@NonNull GeckoSession.Loader);
+ method @AnyThread public void loadUri(@NonNull String);
+ method @UiThread public void open(@NonNull GeckoRuntime);
+ method @AnyThread public void printPageContent();
+ method @AnyThread public void purgeHistory();
+ method @UiThread public void releaseDisplay(@NonNull GeckoDisplay);
+ method @AnyThread public void reload();
+ method @AnyThread public void reload(int);
+ method @AnyThread public void restoreState(@NonNull GeckoSession.SessionState);
+ method @AnyThread @NonNull public GeckoResult<InputStream> saveAsPdf();
+ method @AnyThread public void setActive(boolean);
+ method @UiThread public void setAutofillDelegate(@Nullable Autofill.Delegate);
+ method @AnyThread public void setContentBlockingDelegate(@Nullable ContentBlocking.Delegate);
+ method @UiThread public void setContentDelegate(@Nullable GeckoSession.ContentDelegate);
+ method @AnyThread public void setFocused(boolean);
+ method @AnyThread public void setHistoryDelegate(@Nullable GeckoSession.HistoryDelegate);
+ method @AnyThread public void setMediaDelegate(@Nullable GeckoSession.MediaDelegate);
+ method @AnyThread public void setMediaSessionDelegate(@Nullable MediaSession.Delegate);
+ method @UiThread public void setNavigationDelegate(@Nullable GeckoSession.NavigationDelegate);
+ method @UiThread public void setPermissionDelegate(@Nullable GeckoSession.PermissionDelegate);
+ method @AnyThread public void setPrintDelegate(@Nullable GeckoSession.PrintDelegate);
+ method @AnyThread public void setPriorityHint(int);
+ method @UiThread public void setProgressDelegate(@Nullable GeckoSession.ProgressDelegate);
+ method @AnyThread public void setPromptDelegate(@Nullable GeckoSession.PromptDelegate);
+ method @UiThread public void setScrollDelegate(@Nullable GeckoSession.ScrollDelegate);
+ method @UiThread public void setSelectionActionDelegate(@Nullable GeckoSession.SelectionActionDelegate);
+ method @AnyThread public void stop();
+ method @UiThread protected void setShouldPinOnScreen(boolean);
+ field public static final int FINDER_DISPLAY_DIM_PAGE = 2;
+ field public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 4;
+ field public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
+ field public static final int FINDER_FIND_BACKWARDS = 1;
+ field public static final int FINDER_FIND_LINKS_ONLY = 8;
+ field public static final int FINDER_FIND_MATCH_CASE = 2;
+ field public static final int FINDER_FIND_WHOLE_WORD = 4;
+ field public static final int HEADER_FILTER_CORS_SAFELISTED = 1;
+ field public static final int HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;
+ field public static final int LOAD_FLAGS_ALLOW_POPUPS = 8;
+ field public static final int LOAD_FLAGS_BYPASS_CACHE = 1;
+ field public static final int LOAD_FLAGS_BYPASS_CLASSIFIER = 16;
+ field public static final int LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE = 128;
+ field public static final int LOAD_FLAGS_BYPASS_PROXY = 2;
+ field public static final int LOAD_FLAGS_EXTERNAL = 4;
+ field public static final int LOAD_FLAGS_FORCE_ALLOW_DATA_URI = 32;
+ field public static final int LOAD_FLAGS_NONE = 0;
+ field public static final int LOAD_FLAGS_REPLACE_HISTORY = 64;
+ field public static final int PRIORITY_DEFAULT = 0;
+ field public static final int PRIORITY_HIGH = 1;
+ field @Nullable protected GeckoSession.Window mWindow;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.ClipboardPermissionType {
+ }
+
+ public static interface GeckoSession.ContentDelegate {
+ method @UiThread default public void onCloseRequest(@NonNull GeckoSession);
+ method @UiThread default public void onContextMenu(@NonNull GeckoSession, int, int, @NonNull GeckoSession.ContentDelegate.ContextElement);
+ method @AnyThread default public void onCookieBannerDetected(@NonNull GeckoSession);
+ method @AnyThread default public void onCookieBannerHandled(@NonNull GeckoSession);
+ method @UiThread default public void onCrash(@NonNull GeckoSession);
+ method @UiThread default public void onExternalResponse(@NonNull GeckoSession, @NonNull WebResponse);
+ method @UiThread default public void onFirstComposite(@NonNull GeckoSession);
+ method @UiThread default public void onFirstContentfulPaint(@NonNull GeckoSession);
+ method @UiThread default public void onFocusRequest(@NonNull GeckoSession);
+ method @UiThread default public void onFullScreen(@NonNull GeckoSession, boolean);
+ method @AnyThread @Nullable default public JSONObject onGetNimbusFeature(@NonNull GeckoSession, @NonNull String);
+ method @UiThread default public void onKill(@NonNull GeckoSession);
+ method @UiThread default public void onMetaViewportFitChange(@NonNull GeckoSession, @NonNull String);
+ method @UiThread default public void onPaintStatusReset(@NonNull GeckoSession);
+ method @UiThread default public void onPointerIconChange(@NonNull GeckoSession, @NonNull PointerIcon);
+ method @UiThread default public void onPreviewImage(@NonNull GeckoSession, @NonNull String);
+ method @UiThread default public void onShowDynamicToolbar(@NonNull GeckoSession);
+ method @Nullable @UiThread default public GeckoResult<SlowScriptResponse> onSlowScript(@NonNull GeckoSession, @NonNull String);
+ method @UiThread default public void onTitleChange(@NonNull GeckoSession, @Nullable String);
+ method @UiThread default public void onWebAppManifest(@NonNull GeckoSession, @NonNull JSONObject);
+ }
+
+ public static class GeckoSession.ContentDelegate.ContextElement {
+ ctor protected ContextElement(@Nullable String, @Nullable String, @Nullable String, @Nullable String, @NonNull String, @Nullable String, @Nullable String);
+ ctor protected ContextElement(@Nullable String, @Nullable String, @Nullable String, @Nullable String, @NonNull String, @Nullable String);
+ field public static final int TYPE_AUDIO = 3;
+ field public static final int TYPE_IMAGE = 1;
+ field public static final int TYPE_NONE = 0;
+ field public static final int TYPE_VIDEO = 2;
+ field @Nullable public final String altText;
+ field @Nullable public final String baseUri;
+ field @Nullable public final String linkUri;
+ field @Nullable public final String srcUri;
+ field @Nullable public final String textContent;
+ field @Nullable public final String title;
+ field public final int type;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.ContentDelegate.ContextElement.Type {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.FinderDisplayFlags {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.FinderFindFlags {
+ }
+
+ @AnyThread public static class GeckoSession.FinderResult {
+ ctor protected FinderResult();
+ field @Nullable public final RectF clientRect;
+ field public final int current;
+ field public final int flags;
+ field public final boolean found;
+ field @Nullable public final String linkUri;
+ field @NonNull public final String searchString;
+ field public final int total;
+ field public final boolean wrapped;
+ }
+
+ public static class GeckoSession.GeckoPrintException extends Exception {
+ ctor protected GeckoPrintException();
+ field public static final int ERROR_NO_ACTIVITY_CONTEXT = -5;
+ field public static final int ERROR_NO_ACTIVITY_CONTEXT_DELEGATE = -4;
+ field public static final int ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE = -1;
+ field public static final int ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS = -2;
+ field public static final int ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT = -3;
+ field public final int code;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.GeckoPrintException.Codes {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.HeaderFilter {
+ }
+
+ public static interface GeckoSession.HistoryDelegate {
+ method @Nullable @UiThread default public GeckoResult<boolean[]> getVisited(@NonNull GeckoSession, @NonNull String[]);
+ method @UiThread default public void onHistoryStateChange(@NonNull GeckoSession, @NonNull GeckoSession.HistoryDelegate.HistoryList);
+ method @Nullable @UiThread default public GeckoResult<Boolean> onVisited(@NonNull GeckoSession, @NonNull String, @Nullable String, int);
+ field public static final int VISIT_REDIRECT_PERMANENT = 4;
+ field public static final int VISIT_REDIRECT_SOURCE = 8;
+ field public static final int VISIT_REDIRECT_SOURCE_PERMANENT = 16;
+ field public static final int VISIT_REDIRECT_TEMPORARY = 2;
+ field public static final int VISIT_TOP_LEVEL = 1;
+ field public static final int VISIT_UNRECOVERABLE_ERROR = 32;
+ }
+
+ public static interface GeckoSession.HistoryDelegate.HistoryItem {
+ method @AnyThread @NonNull default public String getTitle();
+ method @AnyThread @NonNull default public String getUri();
+ }
+
+ public static interface GeckoSession.HistoryDelegate.HistoryList implements List<GeckoSession.HistoryDelegate.HistoryItem> {
+ method @AnyThread default public int getCurrentIndex();
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.LoadFlags {
+ }
+
+ @AnyThread public static class GeckoSession.Loader {
+ ctor public Loader();
+ method @NonNull public GeckoSession.Loader additionalHeaders(@NonNull Map<String,String>);
+ method @NonNull public GeckoSession.Loader data(@NonNull byte[], @Nullable String);
+ method @NonNull public GeckoSession.Loader data(@NonNull String, @Nullable String);
+ method @NonNull public GeckoSession.Loader flags(int);
+ method @NonNull public GeckoSession.Loader headerFilter(int);
+ method @NonNull public GeckoSession.Loader referrer(@NonNull GeckoSession);
+ method @NonNull public GeckoSession.Loader referrer(@NonNull Uri);
+ method @NonNull public GeckoSession.Loader referrer(@NonNull String);
+ method @NonNull public GeckoSession.Loader uri(@NonNull String);
+ method @NonNull public GeckoSession.Loader uri(@NonNull Uri);
+ }
+
+ public static interface GeckoSession.MediaDelegate {
+ method @UiThread default public void onRecordingStatusChanged(@NonNull GeckoSession, @NonNull GeckoSession.MediaDelegate.RecordingDevice[]);
+ }
+
+ public static class GeckoSession.MediaDelegate.RecordingDevice {
+ ctor protected RecordingDevice();
+ field public final long status;
+ field public final long type;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.MediaDelegate.RecordingDevice.DeviceType {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.MediaDelegate.RecordingDevice.RecordingStatus {
+ }
+
+ public static class GeckoSession.MediaDelegate.RecordingDevice.Status {
+ ctor protected Status();
+ field public static final long INACTIVE = 1L;
+ field public static final long RECORDING = 0L;
+ }
+
+ public static class GeckoSession.MediaDelegate.RecordingDevice.Type {
+ ctor protected Type();
+ field public static final long CAMERA = 0L;
+ field public static final long MICROPHONE = 1L;
+ }
+
+ public static interface GeckoSession.NavigationDelegate {
+ method @UiThread default public void onCanGoBack(@NonNull GeckoSession, boolean);
+ method @UiThread default public void onCanGoForward(@NonNull GeckoSession, boolean);
+ method @Nullable @UiThread default public GeckoResult<String> onLoadError(@NonNull GeckoSession, @Nullable String, @NonNull WebRequestError);
+ method @Nullable @UiThread default public GeckoResult<AllowOrDeny> onLoadRequest(@NonNull GeckoSession, @NonNull GeckoSession.NavigationDelegate.LoadRequest);
+ method @UiThread default public void onLocationChange(@NonNull GeckoSession, @Nullable String, @NonNull List<GeckoSession.PermissionDelegate.ContentPermission>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession> onNewSession(@NonNull GeckoSession, @NonNull String);
+ method @Nullable @UiThread default public GeckoResult<AllowOrDeny> onSubframeLoadRequest(@NonNull GeckoSession, @NonNull GeckoSession.NavigationDelegate.LoadRequest);
+ field public static final int LOAD_REQUEST_IS_REDIRECT = 8388608;
+ field public static final int TARGET_WINDOW_CURRENT = 1;
+ field public static final int TARGET_WINDOW_NEW = 2;
+ field public static final int TARGET_WINDOW_NONE = 0;
+ }
+
+ public static class GeckoSession.NavigationDelegate.LoadRequest {
+ ctor protected LoadRequest();
+ field public final boolean hasUserGesture;
+ field public final boolean isDirectNavigation;
+ field public final boolean isRedirect;
+ field public final int target;
+ field @Nullable public final String triggerUri;
+ field @NonNull public final String uri;
+ }
+
+ @AnyThread public static class GeckoSession.PdfSaveResult {
+ ctor protected PdfSaveResult();
+ field @NonNull public final byte[] bytes;
+ field @NonNull public final String filename;
+ field public final boolean isPrivate;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.Permission {
+ }
+
+ public static interface GeckoSession.PermissionDelegate {
+ method @UiThread default public void onAndroidPermissionsRequest(@NonNull GeckoSession, @Nullable String[], @NonNull GeckoSession.PermissionDelegate.Callback);
+ method @Nullable @UiThread default public GeckoResult<Integer> onContentPermissionRequest(@NonNull GeckoSession, @NonNull GeckoSession.PermissionDelegate.ContentPermission);
+ method @UiThread default public void onMediaPermissionRequest(@NonNull GeckoSession, @NonNull String, @Nullable GeckoSession.PermissionDelegate.MediaSource[], @Nullable GeckoSession.PermissionDelegate.MediaSource[], @NonNull GeckoSession.PermissionDelegate.MediaCallback);
+ field public static final int PERMISSION_AUTOPLAY_AUDIBLE = 5;
+ field public static final int PERMISSION_AUTOPLAY_INAUDIBLE = 4;
+ field public static final int PERMISSION_DESKTOP_NOTIFICATION = 1;
+ field public static final int PERMISSION_GEOLOCATION = 0;
+ field public static final int PERMISSION_MEDIA_KEY_SYSTEM_ACCESS = 6;
+ field public static final int PERMISSION_PERSISTENT_STORAGE = 2;
+ field public static final int PERMISSION_STORAGE_ACCESS = 8;
+ field public static final int PERMISSION_TRACKING = 7;
+ field public static final int PERMISSION_XR = 3;
+ }
+
+ public static interface GeckoSession.PermissionDelegate.Callback {
+ method @UiThread default public void grant();
+ method @UiThread default public void reject();
+ }
+
+ public static class GeckoSession.PermissionDelegate.ContentPermission {
+ ctor protected ContentPermission();
+ method @AnyThread @Nullable public static GeckoSession.PermissionDelegate.ContentPermission fromJson(@NonNull JSONObject);
+ method @AnyThread @NonNull public JSONObject toJson();
+ field public static final int VALUE_ALLOW = 1;
+ field public static final int VALUE_DENY = 2;
+ field public static final int VALUE_PROMPT = 3;
+ field @Nullable public final String contextId;
+ field public final int permission;
+ field public final boolean privateMode;
+ field @Nullable public final String thirdPartyOrigin;
+ field @NonNull public final String uri;
+ field public final int value;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PermissionDelegate.ContentPermission.Value {
+ }
+
+ public static interface GeckoSession.PermissionDelegate.MediaCallback {
+ method @UiThread default public void grant(@Nullable String, @Nullable String);
+ method @UiThread default public void grant(@Nullable GeckoSession.PermissionDelegate.MediaSource, @Nullable GeckoSession.PermissionDelegate.MediaSource);
+ method @UiThread default public void reject();
+ }
+
+ public static class GeckoSession.PermissionDelegate.MediaSource {
+ ctor protected MediaSource();
+ field public static final int SOURCE_AUDIOCAPTURE = 3;
+ field public static final int SOURCE_CAMERA = 0;
+ field public static final int SOURCE_MICROPHONE = 2;
+ field public static final int SOURCE_OTHER = 4;
+ field public static final int SOURCE_SCREEN = 1;
+ field public static final int TYPE_AUDIO = 1;
+ field public static final int TYPE_VIDEO = 0;
+ field @NonNull public final String id;
+ field @Nullable public final String name;
+ field public final int source;
+ field public final int type;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PermissionDelegate.MediaSource.Source {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PermissionDelegate.MediaSource.Type {
+ }
+
+ @AnyThread public static interface GeckoSession.PrintDelegate {
+ method default public void onPrint(@NonNull GeckoSession);
+ method default public void onPrint(@NonNull InputStream);
+ method @Nullable default public GeckoResult<Boolean> onPrintWithStatus(@NonNull InputStream);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.Priority {
+ }
+
+ public static interface GeckoSession.ProgressDelegate {
+ method @UiThread default public void onPageStart(@NonNull GeckoSession, @NonNull String);
+ method @UiThread default public void onPageStop(@NonNull GeckoSession, boolean);
+ method @UiThread default public void onProgressChange(@NonNull GeckoSession, int);
+ method @UiThread default public void onSecurityChange(@NonNull GeckoSession, @NonNull GeckoSession.ProgressDelegate.SecurityInformation);
+ method @UiThread default public void onSessionStateChange(@NonNull GeckoSession, @NonNull GeckoSession.SessionState);
+ }
+
+ public static class GeckoSession.ProgressDelegate.SecurityInformation {
+ ctor protected SecurityInformation();
+ field public static final int CONTENT_BLOCKED = 1;
+ field public static final int CONTENT_LOADED = 2;
+ field public static final int CONTENT_UNKNOWN = 0;
+ field public static final int SECURITY_MODE_IDENTIFIED = 1;
+ field public static final int SECURITY_MODE_UNKNOWN = 0;
+ field public static final int SECURITY_MODE_VERIFIED = 2;
+ field @Nullable public final X509Certificate certificate;
+ field @NonNull public final String host;
+ field public final boolean isException;
+ field public final boolean isSecure;
+ field public final int mixedModeActive;
+ field public final int mixedModePassive;
+ field @Nullable public final String origin;
+ field public final int securityMode;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.ProgressDelegate.SecurityInformation.ContentType {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.ProgressDelegate.SecurityInformation.SecurityMode {
+ }
+
+ public static interface GeckoSession.PromptDelegate {
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onAddressSave(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.AddressSaveOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onAddressSelect(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.AddressSelectOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onAlertPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AlertPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onAuthPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AuthPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onBeforeUnloadPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.BeforeUnloadPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onButtonPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.ButtonPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onChoicePrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.ChoicePrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onColorPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.ColorPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onCreditCardSave(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSaveOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onCreditCardSelect(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.CreditCardSelectOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onDateTimePrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.DateTimePrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onFilePrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.FilePrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onLoginSave(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onLoginSelect(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.AutocompleteRequest<Autocomplete.LoginSelectOption>);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onPopupPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.PopupPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onRepostConfirmPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.RepostConfirmPrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onSharePrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.SharePrompt);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onTextPrompt(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.TextPrompt);
+ }
+
+ public static class GeckoSession.PromptDelegate.AlertPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected AlertPrompt(@NonNull String, @Nullable String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ field @Nullable public final String message;
+ }
+
+ public static class GeckoSession.PromptDelegate.AuthPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected AuthPrompt(@NonNull String, @Nullable String, @Nullable String, @NonNull GeckoSession.PromptDelegate.AuthPrompt.AuthOptions, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String, @NonNull String);
+ field @NonNull public final GeckoSession.PromptDelegate.AuthPrompt.AuthOptions authOptions;
+ field @Nullable public final String message;
+ }
+
+ public static class GeckoSession.PromptDelegate.AuthPrompt.AuthOptions {
+ ctor protected AuthOptions();
+ field public final int flags;
+ field public final int level;
+ field @Nullable public final String password;
+ field @Nullable public final String uri;
+ field @Nullable public final String username;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.AuthFlag {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.AuthLevel {
+ }
+
+ public static class GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.Flags {
+ ctor protected Flags();
+ field public static final int CROSS_ORIGIN_SUB_RESOURCE = 32;
+ field public static final int HOST = 1;
+ field public static final int ONLY_PASSWORD = 8;
+ field public static final int PREVIOUS_FAILED = 16;
+ field public static final int PROXY = 2;
+ }
+
+ public static class GeckoSession.PromptDelegate.AuthPrompt.AuthOptions.Level {
+ ctor protected Level();
+ field public static final int NONE = 0;
+ field public static final int PW_ENCRYPTED = 1;
+ field public static final int SECURE = 2;
+ }
+
+ public static class GeckoSession.PromptDelegate.AutocompleteRequest<T extends Autocomplete.Option<?>> extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected AutocompleteRequest(@NonNull String, @NonNull T[], GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull Autocomplete.Option<?>);
+ field @NonNull public final T[] options;
+ }
+
+ public static class GeckoSession.PromptDelegate.BasePrompt {
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse dismiss();
+ method @Nullable @UiThread public GeckoSession.PromptDelegate.PromptInstanceDelegate getDelegate();
+ method @UiThread public boolean isComplete();
+ method @UiThread public void setDelegate(@Nullable GeckoSession.PromptDelegate.PromptInstanceDelegate);
+ method @NonNull @UiThread protected GeckoSession.PromptDelegate.PromptResponse confirm();
+ field @Nullable public final String title;
+ }
+
+ protected static interface GeckoSession.PromptDelegate.BasePrompt.Observer {
+ method @AnyThread default public void onPromptCompleted(@NonNull GeckoSession.PromptDelegate.BasePrompt);
+ }
+
+ public static class GeckoSession.PromptDelegate.BeforeUnloadPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected BeforeUnloadPrompt(@NonNull String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@Nullable AllowOrDeny);
+ }
+
+ public static class GeckoSession.PromptDelegate.ButtonPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected ButtonPrompt(@NonNull String, @Nullable String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(int);
+ field @Nullable public final String message;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.ButtonPrompt.ButtonType {
+ }
+
+ public static class GeckoSession.PromptDelegate.ButtonPrompt.Type {
+ ctor protected Type();
+ field public static final int NEGATIVE = 2;
+ field public static final int POSITIVE = 0;
+ }
+
+ public static class GeckoSession.PromptDelegate.ChoicePrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected ChoicePrompt(@NonNull String, @Nullable String, @Nullable String, int, @NonNull GeckoSession.PromptDelegate.ChoicePrompt.Choice[], @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String[]);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull GeckoSession.PromptDelegate.ChoicePrompt.Choice);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull GeckoSession.PromptDelegate.ChoicePrompt.Choice[]);
+ field @NonNull public final GeckoSession.PromptDelegate.ChoicePrompt.Choice[] choices;
+ field @Nullable public final String message;
+ field public final int type;
+ }
+
+ public static class GeckoSession.PromptDelegate.ChoicePrompt.Choice {
+ ctor protected Choice();
+ field public final boolean disabled;
+ field @Nullable public final String icon;
+ field @NonNull public final String id;
+ field @Nullable public final GeckoSession.PromptDelegate.ChoicePrompt.Choice[] items;
+ field @NonNull public final String label;
+ field public final boolean selected;
+ field public final boolean separator;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.ChoicePrompt.ChoiceType {
+ }
+
+ public static class GeckoSession.PromptDelegate.ChoicePrompt.Type {
+ ctor protected Type();
+ field public static final int MENU = 1;
+ field public static final int MULTIPLE = 3;
+ field public static final int SINGLE = 2;
+ }
+
+ public static class GeckoSession.PromptDelegate.ColorPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected ColorPrompt(@NonNull String, @Nullable String, @Nullable String, @Nullable String[], @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String);
+ field @Nullable public final String defaultValue;
+ field @Nullable public final String[] predefinedValues;
+ }
+
+ public static class GeckoSession.PromptDelegate.DateTimePrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String);
+ field @Nullable public final String defaultValue;
+ field @Nullable public final String maxValue;
+ field @Nullable public final String minValue;
+ field @Nullable public final String stepValue;
+ field public final int type;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.DateTimePrompt.DatetimeType {
+ }
+
+ public static class GeckoSession.PromptDelegate.DateTimePrompt.Type {
+ ctor protected Type();
+ field public static final int DATE = 1;
+ field public static final int DATETIME_LOCAL = 5;
+ field public static final int MONTH = 2;
+ field public static final int TIME = 4;
+ field public static final int WEEK = 3;
+ }
+
+ public static class GeckoSession.PromptDelegate.FilePrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected FilePrompt(@NonNull String, @Nullable String, int, int, @Nullable String[], @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull Context, @NonNull Uri);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull Context, @NonNull Uri[]);
+ field public final int capture;
+ field @Nullable public final String[] mimeTypes;
+ field public final int type;
+ }
+
+ public static class GeckoSession.PromptDelegate.FilePrompt.Capture {
+ ctor protected Capture();
+ field public static final int ANY = 1;
+ field public static final int ENVIRONMENT = 3;
+ field public static final int NONE = 0;
+ field public static final int USER = 2;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.FilePrompt.CaptureType {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.FilePrompt.FileType {
+ }
+
+ public static class GeckoSession.PromptDelegate.FilePrompt.Type {
+ ctor protected Type();
+ field public static final int MULTIPLE = 2;
+ field public static final int SINGLE = 1;
+ }
+
+ public static class GeckoSession.PromptDelegate.PopupPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected PopupPrompt(@NonNull String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull AllowOrDeny);
+ field @Nullable public final String targetUri;
+ }
+
+ public static interface GeckoSession.PromptDelegate.PromptInstanceDelegate {
+ method @UiThread default public void onPromptDismiss(@NonNull GeckoSession.PromptDelegate.BasePrompt);
+ method @UiThread default public void onPromptUpdate(@NonNull GeckoSession.PromptDelegate.BasePrompt);
+ }
+
+ public static class GeckoSession.PromptDelegate.PromptResponse {
+ }
+
+ public static class GeckoSession.PromptDelegate.RepostConfirmPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected RepostConfirmPrompt(@NonNull String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@Nullable AllowOrDeny);
+ }
+
+ public static class GeckoSession.PromptDelegate.SharePrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected SharePrompt(@NonNull String, @Nullable String, @Nullable String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(int);
+ field @Nullable public final String text;
+ field @Nullable public final String uri;
+ }
+
+ public static class GeckoSession.PromptDelegate.SharePrompt.Result {
+ ctor protected Result();
+ field public static final int ABORT = 2;
+ field public static final int FAILURE = 1;
+ field public static final int SUCCESS = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.PromptDelegate.SharePrompt.ShareResult {
+ }
+
+ public static class GeckoSession.PromptDelegate.TextPrompt extends GeckoSession.PromptDelegate.BasePrompt {
+ ctor protected TextPrompt(@NonNull String, @Nullable String, @Nullable String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer);
+ method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull String);
+ field @Nullable public final String defaultValue;
+ field @Nullable public final String message;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.RestartReason {
+ }
+
+ public static interface GeckoSession.ScrollDelegate {
+ method @UiThread default public void onScrollChanged(@NonNull GeckoSession, int, int);
+ }
+
+ public static interface GeckoSession.SelectionActionDelegate {
+ method @UiThread default public void onDismissClipboardPermissionRequest(@NonNull GeckoSession);
+ method @UiThread default public void onHideAction(@NonNull GeckoSession, int);
+ method @UiThread default public void onShowActionRequest(@NonNull GeckoSession, @NonNull GeckoSession.SelectionActionDelegate.Selection);
+ method @Nullable @UiThread default public GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(@NonNull GeckoSession, @NonNull GeckoSession.SelectionActionDelegate.ClipboardPermission);
+ field public static final String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END";
+ field public static final String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START";
+ field public static final String ACTION_COPY = "org.mozilla.geckoview.COPY";
+ field public static final String ACTION_CUT = "org.mozilla.geckoview.CUT";
+ field public static final String ACTION_DELETE = "org.mozilla.geckoview.DELETE";
+ field public static final String ACTION_HIDE = "org.mozilla.geckoview.HIDE";
+ field public static final String ACTION_PASTE = "org.mozilla.geckoview.PASTE";
+ field public static final String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT";
+ field public static final String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL";
+ field public static final String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT";
+ field public static final int FLAG_IS_COLLAPSED = 1;
+ field public static final int FLAG_IS_EDITABLE = 2;
+ field public static final int FLAG_IS_PASSWORD = 4;
+ field public static final int HIDE_REASON_ACTIVE_SCROLL = 3;
+ field public static final int HIDE_REASON_ACTIVE_SELECTION = 2;
+ field public static final int HIDE_REASON_INVISIBLE_SELECTION = 1;
+ field public static final int HIDE_REASON_NO_SELECTION = 0;
+ field public static final int PERMISSION_CLIPBOARD_READ = 1;
+ }
+
+ public static class GeckoSession.SelectionActionDelegate.ClipboardPermission {
+ ctor protected ClipboardPermission();
+ field @Nullable public final Point screenPoint;
+ field public final int type;
+ field @NonNull public final String uri;
+ }
+
+ public static class GeckoSession.SelectionActionDelegate.Selection {
+ ctor protected Selection();
+ method @AnyThread public void collapseToEnd();
+ method @AnyThread public void collapseToStart();
+ method @AnyThread public void copy();
+ method @AnyThread public void cut();
+ method @AnyThread public void delete();
+ method @AnyThread public void execute(@NonNull String);
+ method @AnyThread public void hide();
+ method @AnyThread public boolean isActionAvailable(@NonNull String);
+ method @AnyThread public void paste();
+ method @AnyThread public void pasteAsPlainText();
+ method @AnyThread public void selectAll();
+ method @AnyThread public void unselect();
+ field @NonNull public final Collection<String> availableActions;
+ field public final int flags;
+ field @Nullable public final RectF screenRect;
+ field @NonNull public final String text;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.SelectionActionDelegateAction {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.SelectionActionDelegateFlag {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.SelectionActionDelegateHideReason {
+ }
+
+ @AnyThread public static class GeckoSession.SessionState extends AbstractSequentialList<GeckoSession.HistoryDelegate.HistoryItem> implements Parcelable GeckoSession.HistoryDelegate.HistoryList {
+ ctor public SessionState(@NonNull GeckoSession.SessionState);
+ method @Nullable public static GeckoSession.SessionState fromString(@Nullable String);
+ method public void readFromParcel(@NonNull Parcel);
+ field public static final Parcelable.Creator<GeckoSession.SessionState> CREATOR;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.TargetWindow {
+ }
+
+ public static interface GeckoSession.TextInputDelegate {
+ method @UiThread default public void hideSoftInput(@NonNull GeckoSession);
+ method @UiThread default public void restartInput(@NonNull GeckoSession, int);
+ method @UiThread default public void showSoftInput(@NonNull GeckoSession);
+ method @UiThread default public void updateCursorAnchorInfo(@NonNull GeckoSession, @NonNull CursorAnchorInfo);
+ method @UiThread default public void updateExtractedText(@NonNull GeckoSession, @NonNull ExtractedTextRequest, @NonNull ExtractedText);
+ method @UiThread default public void updateSelection(@NonNull GeckoSession, int, int, int, int);
+ field public static final int RESTART_REASON_BLUR = 1;
+ field public static final int RESTART_REASON_CONTENT_CHANGE = 2;
+ field public static final int RESTART_REASON_FOCUS = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.VisitFlags {
+ }
+
+ @AnyThread public static class GeckoSession.WebResponseInfo {
+ ctor protected WebResponseInfo();
+ field @Nullable public final long contentLength;
+ field @Nullable public final String contentType;
+ field @Nullable public final String filename;
+ field @NonNull public final String uri;
+ }
+
+ @AnyThread public final class GeckoSessionSettings implements Parcelable {
+ ctor public GeckoSessionSettings();
+ ctor public GeckoSessionSettings(@NonNull GeckoSessionSettings);
+ method public boolean getAllowJavascript();
+ method @Nullable public String getChromeUri();
+ method @Nullable public String getContextId();
+ method public int getDisplayMode();
+ method public boolean getFullAccessibilityTree();
+ method public int getScreenId();
+ method public boolean getSuspendMediaWhenInactive();
+ method public boolean getUsePrivateMode();
+ method public boolean getUseTrackingProtection();
+ method public int getUserAgentMode();
+ method @Nullable public String getUserAgentOverride();
+ method public int getViewportMode();
+ method public void readFromParcel(@NonNull Parcel);
+ method public void setAllowJavascript(boolean);
+ method public void setDisplayMode(int);
+ method public void setFullAccessibilityTree(boolean);
+ method public void setSuspendMediaWhenInactive(boolean);
+ method public void setUseTrackingProtection(boolean);
+ method public void setUserAgentMode(int);
+ method public void setUserAgentOverride(@Nullable String);
+ method public void setViewportMode(int);
+ field public static final Parcelable.Creator<GeckoSessionSettings> CREATOR;
+ field public static final int DISPLAY_MODE_BROWSER = 0;
+ field public static final int DISPLAY_MODE_FULLSCREEN = 3;
+ field public static final int DISPLAY_MODE_MINIMAL_UI = 1;
+ field public static final int DISPLAY_MODE_STANDALONE = 2;
+ field public static final int USER_AGENT_MODE_DESKTOP = 1;
+ field public static final int USER_AGENT_MODE_MOBILE = 0;
+ field public static final int USER_AGENT_MODE_VR = 2;
+ field public static final int VIEWPORT_MODE_DESKTOP = 1;
+ field public static final int VIEWPORT_MODE_MOBILE = 0;
+ }
+
+ @AnyThread public static final class GeckoSessionSettings.Builder {
+ ctor public Builder();
+ ctor public Builder(GeckoSessionSettings);
+ method @NonNull public GeckoSessionSettings.Builder allowJavascript(boolean);
+ method @NonNull public GeckoSessionSettings build();
+ method @NonNull public GeckoSessionSettings.Builder chromeUri(@NonNull String);
+ method @NonNull public GeckoSessionSettings.Builder contextId(@Nullable String);
+ method @NonNull public GeckoSessionSettings.Builder displayMode(int);
+ method @NonNull public GeckoSessionSettings.Builder fullAccessibilityTree(boolean);
+ method @NonNull public GeckoSessionSettings.Builder screenId(int);
+ method @NonNull public GeckoSessionSettings.Builder suspendMediaWhenInactive(boolean);
+ method @NonNull public GeckoSessionSettings.Builder usePrivateMode(boolean);
+ method @NonNull public GeckoSessionSettings.Builder useTrackingProtection(boolean);
+ method @NonNull public GeckoSessionSettings.Builder userAgentMode(int);
+ method @NonNull public GeckoSessionSettings.Builder userAgentOverride(@NonNull String);
+ method @NonNull public GeckoSessionSettings.Builder viewportMode(int);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSessionSettings.DisplayMode {
+ }
+
+ public static class GeckoSessionSettings.Key<T> {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSessionSettings.UserAgentMode {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSessionSettings.ViewportMode {
+ }
+
+ public class GeckoVRManager {
+ method @AnyThread public static synchronized void setExternalContext(long);
+ }
+
+ @UiThread public class GeckoView extends FrameLayout implements GeckoDisplay.NewSurfaceProvider {
+ ctor public GeckoView(Context);
+ ctor public GeckoView(Context, AttributeSet);
+ method @NonNull @UiThread public GeckoResult<Bitmap> capturePixels();
+ method public void coverUntilFirstPaint(int);
+ method public void dispatchDraw(@Nullable Canvas);
+ method @Nullable public GeckoView.ActivityContextDelegate getActivityContextDelegate();
+ method public boolean getAutofillEnabled();
+ method @NonNull public PanZoomController getPanZoomController();
+ method @Nullable public GeckoSession.PrintDelegate getPrintDelegate();
+ method public void getPrintDelegate(@Nullable GeckoSession.PrintDelegate);
+ method @AnyThread @Nullable public GeckoSession getSession();
+ method public void onAttachedToWindow();
+ method public void onDetachedFromWindow();
+ method @NonNull public GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult(@NonNull MotionEvent);
+ method @Nullable @UiThread public GeckoSession releaseSession();
+ method public void setActivityContextDelegate(@Nullable GeckoView.ActivityContextDelegate);
+ method public void setAutofillEnabled(boolean);
+ method public void setDynamicToolbarMaxHeight(int);
+ method @UiThread public void setSession(@NonNull GeckoSession);
+ method public void setVerticalClipping(int);
+ method public void setViewBackend(int);
+ method public boolean shouldPinOnScreen();
+ field public static final int BACKEND_SURFACE_VIEW = 1;
+ field public static final int BACKEND_TEXTURE_VIEW = 2;
+ field @NonNull protected final GeckoView.Display mDisplay;
+ field @Nullable protected GeckoSession mSession;
+ }
+
+ @AnyThread public static interface GeckoView.ActivityContextDelegate {
+ method @Nullable public Context getActivityContext();
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoView.ViewBackend {
+ }
+
+ public class GeckoViewPrintDocumentAdapter extends PrintDocumentAdapter {
+ ctor public GeckoViewPrintDocumentAdapter(@NonNull InputStream, @NonNull Context);
+ ctor public GeckoViewPrintDocumentAdapter(@NonNull InputStream, @NonNull Context, @Nullable GeckoResult<Boolean>);
+ ctor public GeckoViewPrintDocumentAdapter(@NonNull File);
+ method @AnyThread @Nullable public static File makeTempPdfFile(@NonNull InputStream, @NonNull Context);
+ }
+
+ @AnyThread public class GeckoWebExecutor {
+ ctor public GeckoWebExecutor(@NonNull GeckoRuntime);
+ method @NonNull public GeckoResult<WebResponse> fetch(@NonNull WebRequest);
+ method @NonNull public GeckoResult<WebResponse> fetch(@NonNull WebRequest, int);
+ method @NonNull public GeckoResult<InetAddress[]> resolve(@NonNull String);
+ method public void speculativeConnect(@NonNull String);
+ field public static final int FETCH_FLAGS_ANONYMOUS = 1;
+ field public static final int FETCH_FLAGS_NONE = 0;
+ field public static final int FETCH_FLAGS_NO_REDIRECTS = 2;
+ field public static final int FETCH_FLAGS_PRIVATE = 8;
+ field public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1024;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoWebExecutor.FetchFlags {
+ }
+
+ @AnyThread public class Image {
+ method @NonNull public GeckoResult<Bitmap> getBitmap(int);
+ }
+
+ public static class Image.ImageProcessingException extends RuntimeException {
+ ctor public ImageProcessingException(String);
+ }
+
+ @UiThread public class MediaSession {
+ ctor protected MediaSession(GeckoSession);
+ method public boolean isActive();
+ method public void muteAudio(boolean);
+ method public void nextTrack();
+ method public void pause();
+ method public void play();
+ method public void previousTrack();
+ method public void seekBackward();
+ method public void seekForward();
+ method public void seekTo(double, boolean);
+ method public void skipAd();
+ method public void stop();
+ }
+
+ @UiThread public static interface MediaSession.Delegate {
+ method default public void onActivated(@NonNull GeckoSession, @NonNull MediaSession);
+ method default public void onDeactivated(@NonNull GeckoSession, @NonNull MediaSession);
+ method default public void onFeatures(@NonNull GeckoSession, @NonNull MediaSession, long);
+ method default public void onFullscreen(@NonNull GeckoSession, @NonNull MediaSession, boolean, @Nullable MediaSession.ElementMetadata);
+ method default public void onMetadata(@NonNull GeckoSession, @NonNull MediaSession, @NonNull MediaSession.Metadata);
+ method default public void onPause(@NonNull GeckoSession, @NonNull MediaSession);
+ method default public void onPlay(@NonNull GeckoSession, @NonNull MediaSession);
+ method default public void onPositionState(@NonNull GeckoSession, @NonNull MediaSession, @NonNull MediaSession.PositionState);
+ method default public void onStop(@NonNull GeckoSession, @NonNull MediaSession);
+ }
+
+ public static class MediaSession.ElementMetadata {
+ ctor public ElementMetadata(@Nullable String, double, long, long, int, int);
+ field public final int audioTrackCount;
+ field public final double duration;
+ field public final long height;
+ field @Nullable public final String source;
+ field public final int videoTrackCount;
+ field public final long width;
+ }
+
+ public static class MediaSession.Feature {
+ ctor public Feature();
+ field public static final long FOCUS = 512L;
+ field public static final long NEXT_TRACK = 128L;
+ field public static final long NONE = 0L;
+ field public static final long PAUSE = 2L;
+ field public static final long PLAY = 1L;
+ field public static final long PREVIOUS_TRACK = 256L;
+ field public static final long SEEK_BACKWARD = 32L;
+ field public static final long SEEK_FORWARD = 16L;
+ field public static final long SEEK_TO = 8L;
+ field public static final long SKIP_AD = 64L;
+ field public static final long STOP = 4L;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface MediaSession.MSFeature {
+ }
+
+ public static class MediaSession.Metadata {
+ ctor protected Metadata(@Nullable String, @Nullable String, @Nullable String, @Nullable Image);
+ field @Nullable public final String album;
+ field @Nullable public final String artist;
+ field @Nullable public final Image artwork;
+ field @Nullable public final String title;
+ }
+
+ public static class MediaSession.PositionState {
+ ctor protected PositionState(double, double, double);
+ field public final double duration;
+ field public final double playbackRate;
+ field public final double position;
+ }
+
+ public class OrientationController {
+ method @Nullable @UiThread public OrientationController.OrientationDelegate getDelegate();
+ method @UiThread public void setDelegate(@Nullable OrientationController.OrientationDelegate);
+ }
+
+ @UiThread public static interface OrientationController.OrientationDelegate {
+ method @Nullable default public GeckoResult<AllowOrDeny> onOrientationLock(@NonNull int);
+ method @Nullable default public void onOrientationUnlock();
+ }
+
+ @UiThread public final class OverscrollEdgeEffect {
+ method public void draw(@NonNull Canvas);
+ method @Nullable public Runnable getInvalidationCallback();
+ method public void setInvalidationCallback(@Nullable Runnable);
+ method public void setTheme(@NonNull Context);
+ }
+
+ @UiThread public class PanZoomController {
+ ctor protected PanZoomController(GeckoSession);
+ method public float getScrollFactor();
+ method public void onMotionEvent(@NonNull MotionEvent);
+ method public void onMouseEvent(@NonNull MotionEvent);
+ method public void onTouchEvent(@NonNull MotionEvent);
+ method @NonNull public GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult(@NonNull MotionEvent);
+ method @UiThread public void scrollBy(@NonNull ScreenLength, @NonNull ScreenLength);
+ method @UiThread public void scrollBy(@NonNull ScreenLength, @NonNull ScreenLength, int);
+ method @UiThread public void scrollTo(@NonNull ScreenLength, @NonNull ScreenLength);
+ method @UiThread public void scrollTo(@NonNull ScreenLength, @NonNull ScreenLength, int);
+ method @UiThread public void scrollToBottom();
+ method @UiThread public void scrollToTop();
+ method public void setIsLongpressEnabled(boolean);
+ method public void setScrollFactor(float);
+ field public static final int INPUT_RESULT_HANDLED = 1;
+ field public static final int INPUT_RESULT_HANDLED_CONTENT = 2;
+ field public static final int INPUT_RESULT_IGNORED = 3;
+ field public static final int INPUT_RESULT_UNHANDLED = 0;
+ field public static final int OVERSCROLL_FLAG_HORIZONTAL = 1;
+ field public static final int OVERSCROLL_FLAG_NONE = 0;
+ field public static final int OVERSCROLL_FLAG_VERTICAL = 2;
+ field public static final int SCROLLABLE_FLAG_BOTTOM = 4;
+ field public static final int SCROLLABLE_FLAG_LEFT = 8;
+ field public static final int SCROLLABLE_FLAG_NONE = 0;
+ field public static final int SCROLLABLE_FLAG_RIGHT = 2;
+ field public static final int SCROLLABLE_FLAG_TOP = 1;
+ field public static final int SCROLL_BEHAVIOR_AUTO = 1;
+ field public static final int SCROLL_BEHAVIOR_SMOOTH = 0;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface PanZoomController.InputResult {
+ }
+
+ public static class PanZoomController.InputResultDetail {
+ ctor protected InputResultDetail(int, int, int);
+ method @AnyThread public int handledResult();
+ method @AnyThread public int overscrollDirections();
+ method @AnyThread public int scrollableDirections();
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface PanZoomController.OverscrollDirections {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface PanZoomController.ScrollBehaviorType {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface PanZoomController.ScrollableDirections {
+ }
+
+ @UiThread public class ProfilerController {
+ ctor public ProfilerController();
+ method public void addMarker(@NonNull String, @Nullable Double, @Nullable Double, @Nullable String);
+ method public void addMarker(@NonNull String, @Nullable Double, @Nullable String);
+ method public void addMarker(@NonNull String, @Nullable Double);
+ method public void addMarker(@NonNull String, @Nullable String);
+ method public void addMarker(@NonNull String);
+ method @Nullable public Double getProfilerTime();
+ method public boolean isProfilerActive();
+ method public void startProfiler(@NonNull String[], @NonNull String[]);
+ method @NonNull public GeckoResult<byte[]> stopProfiler();
+ }
+
+ public abstract class RuntimeSettings implements Parcelable {
+ ctor protected RuntimeSettings();
+ ctor protected RuntimeSettings(@Nullable RuntimeSettings);
+ method @AnyThread public void readFromParcel(@NonNull Parcel);
+ method @AnyThread protected void updatePrefs(@NonNull RuntimeSettings);
+ }
+
+ public abstract static class RuntimeSettings.Builder<Settings extends RuntimeSettings> {
+ ctor public Builder();
+ method @AnyThread @NonNull public Settings build();
+ method @AnyThread @NonNull protected Settings getSettings();
+ method @AnyThread @NonNull protected abstract Settings newSettings(@Nullable Settings);
+ }
+
+ public final class RuntimeTelemetry {
+ ctor protected RuntimeTelemetry();
+ }
+
+ public static interface RuntimeTelemetry.Delegate {
+ method @AnyThread default public void onBooleanScalar(@NonNull RuntimeTelemetry.Metric<Boolean>);
+ method @AnyThread default public void onHistogram(@NonNull RuntimeTelemetry.Histogram);
+ method @AnyThread default public void onLongScalar(@NonNull RuntimeTelemetry.Metric<Long>);
+ method @AnyThread default public void onStringScalar(@NonNull RuntimeTelemetry.Metric<String>);
+ }
+
+ public static class RuntimeTelemetry.Histogram extends RuntimeTelemetry.Metric<long[]> {
+ ctor protected Histogram();
+ field public final boolean isCategorical;
+ }
+
+ public static class RuntimeTelemetry.Metric<T> {
+ ctor protected Metric();
+ field @NonNull public final String name;
+ field @NonNull public final T value;
+ }
+
+ public class ScreenLength {
+ method @AnyThread @NonNull public static ScreenLength bottom();
+ method @AnyThread @NonNull public static ScreenLength fromPixels(double);
+ method @AnyThread @NonNull public static ScreenLength fromVisualViewportHeight(double);
+ method @AnyThread @NonNull public static ScreenLength fromVisualViewportWidth(double);
+ method @AnyThread public int getType();
+ method @AnyThread public double getValue();
+ method @AnyThread @NonNull public static ScreenLength top();
+ method @AnyThread @NonNull public static ScreenLength zero();
+ field public static final int DOCUMENT_HEIGHT = 4;
+ field public static final int DOCUMENT_WIDTH = 3;
+ field public static final int PIXEL = 0;
+ field public static final int VISUAL_VIEWPORT_HEIGHT = 2;
+ field public static final int VISUAL_VIEWPORT_WIDTH = 1;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface ScreenLength.ScreenLengthType {
+ }
+
+ @UiThread public class SessionAccessibility {
+ method @Nullable public View getView();
+ method public boolean onMotionEvent(@NonNull MotionEvent);
+ method @UiThread public void setView(@Nullable View);
+ }
+
+ @AnyThread public final class SessionFinder {
+ method public void clear();
+ method @NonNull public GeckoResult<GeckoSession.FinderResult> find(@Nullable String, int);
+ method public int getDisplayFlags();
+ method public void setDisplayFlags(int);
+ }
+
+ @AnyThread public final class SessionPdfFileSaver {
+ method @Nullable public static GeckoResult<WebResponse> createResponse(@NonNull GeckoSession, @NonNull String, @NonNull String, @NonNull String, boolean, boolean);
+ method @NonNull public GeckoResult<WebResponse> save();
+ }
+
+ public final class SessionTextInput {
+ method @NonNull @UiThread public GeckoSession.TextInputDelegate getDelegate();
+ method @AnyThread @NonNull public synchronized Handler getHandler(@NonNull Handler);
+ method @Nullable @UiThread public View getView();
+ method @AnyThread @Nullable public synchronized InputConnection onCreateInputConnection(@NonNull EditorInfo);
+ method @UiThread public boolean onKeyDown(int, @NonNull KeyEvent);
+ method @UiThread public boolean onKeyLongPress(int, @NonNull KeyEvent);
+ method @UiThread public boolean onKeyMultiple(int, int, @NonNull KeyEvent);
+ method @UiThread public boolean onKeyPreIme(int, @NonNull KeyEvent);
+ method @UiThread public boolean onKeyUp(int, @NonNull KeyEvent);
+ method @UiThread public void setDelegate(@Nullable GeckoSession.TextInputDelegate);
+ method @UiThread public synchronized void setView(@Nullable View);
+ }
+
+ @AnyThread public final enum SlowScriptResponse {
+ method public static SlowScriptResponse valueOf(String);
+ method public static SlowScriptResponse[] values();
+ enum_constant public static final SlowScriptResponse CONTINUE;
+ enum_constant public static final SlowScriptResponse STOP;
+ }
+
+ public final class StorageController {
+ ctor public StorageController();
+ method @AnyThread @NonNull public GeckoResult<Void> clearData(long);
+ method @AnyThread public void clearDataForSessionContext(@NonNull String);
+ method @AnyThread @NonNull public GeckoResult<Void> clearDataFromBaseDomain(@NonNull String, long);
+ method @AnyThread @NonNull public GeckoResult<Void> clearDataFromHost(@NonNull String, long);
+ method @AnyThread @NonNull public GeckoResult<List<GeckoSession.PermissionDelegate.ContentPermission>> getAllPermissions();
+ method @AnyThread @NonNull public GeckoResult<Integer> getCookieBannerModeForDomain(@NonNull String, boolean);
+ method @AnyThread @NonNull public GeckoResult<List<GeckoSession.PermissionDelegate.ContentPermission>> getPermissions(@NonNull String);
+ method @AnyThread @NonNull public GeckoResult<List<GeckoSession.PermissionDelegate.ContentPermission>> getPermissions(@NonNull String, boolean);
+ method @AnyThread @NonNull public GeckoResult<List<GeckoSession.PermissionDelegate.ContentPermission>> getPermissions(@NonNull String, @Nullable String, boolean);
+ method @AnyThread @NonNull public GeckoResult<Void> removeCookieBannerModeForDomain(@NonNull String, boolean);
+ method @AnyThread @NonNull public GeckoResult<Void> setCookieBannerModeAndPersistInPrivateBrowsingForDomain(@NonNull String, int);
+ method @AnyThread @NonNull public GeckoResult<Void> setCookieBannerModeForDomain(@NonNull String, int, boolean);
+ method @AnyThread public void setPermission(@NonNull GeckoSession.PermissionDelegate.ContentPermission, int);
+ method @AnyThread public void setPrivateBrowsingPermanentPermission(@NonNull GeckoSession.PermissionDelegate.ContentPermission, int);
+ }
+
+ public static class StorageController.ClearFlags {
+ ctor public ClearFlags();
+ field public static final long ALL = 512L;
+ field public static final long ALL_CACHES = 6L;
+ field public static final long AUTH_SESSIONS = 32L;
+ field public static final long COOKIES = 1L;
+ field public static final long DOM_STORAGES = 16L;
+ field public static final long IMAGE_CACHE = 4L;
+ field public static final long NETWORK_CACHE = 2L;
+ field public static final long PERMISSIONS = 64L;
+ field public static final long SITE_DATA = 471L;
+ field public static final long SITE_SETTINGS = 192L;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface StorageController.StorageControllerClearFlags {
+ }
+
+ public class WebExtension {
+ method @Nullable @UiThread public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate();
+ method @Nullable @UiThread public WebExtension.DownloadDelegate getDownloadDelegate();
+ method @Nullable @UiThread public WebExtension.TabDelegate getTabDelegate();
+ method @AnyThread public void setActionDelegate(@Nullable WebExtension.ActionDelegate);
+ method @UiThread public void setBrowsingDataDelegate(@Nullable WebExtension.BrowsingDataDelegate);
+ method @UiThread public void setDownloadDelegate(@Nullable WebExtension.DownloadDelegate);
+ method @UiThread public void setMessageDelegate(@Nullable WebExtension.MessageDelegate, @NonNull String);
+ method @UiThread public void setTabDelegate(@Nullable WebExtension.TabDelegate);
+ field public final long flags;
+ field @NonNull public final String id;
+ field public final boolean isBuiltIn;
+ field @NonNull public final String location;
+ field @NonNull public final WebExtension.MetaData metaData;
+ }
+
+ @AnyThread public static class WebExtension.Action {
+ ctor protected Action();
+ method @UiThread public void click();
+ method @NonNull public WebExtension.Action withDefault(@NonNull WebExtension.Action);
+ field @Nullable public final Integer badgeBackgroundColor;
+ field @Nullable public final String badgeText;
+ field @Nullable public final Integer badgeTextColor;
+ field @Nullable public final Boolean enabled;
+ field @Nullable public final Image icon;
+ field @Nullable public final String title;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.Action.ActionType {
+ }
+
+ public static interface WebExtension.ActionDelegate {
+ method @UiThread default public void onBrowserAction(@NonNull WebExtension, @Nullable GeckoSession, @NonNull WebExtension.Action);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession> onOpenPopup(@NonNull WebExtension, @NonNull WebExtension.Action);
+ method @UiThread default public void onPageAction(@NonNull WebExtension, @Nullable GeckoSession, @NonNull WebExtension.Action);
+ method @Nullable @UiThread default public GeckoResult<GeckoSession> onTogglePopup(@NonNull WebExtension, @NonNull WebExtension.Action);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.BlocklistState {
+ }
+
+ public static class WebExtension.BlocklistStateFlags {
+ ctor public BlocklistStateFlags();
+ field public static final int BLOCKED = 2;
+ field public static final int NOT_BLOCKED = 0;
+ field public static final int OUTDATED = 3;
+ field public static final int SOFTBLOCKED = 1;
+ field public static final int VULNERABLE_NO_UPDATE = 5;
+ field public static final int VULNERABLE_UPDATE_AVAILABLE = 4;
+ }
+
+ @UiThread public static interface WebExtension.BrowsingDataDelegate {
+ method @Nullable default public GeckoResult<Void> onClearDownloads(long);
+ method @Nullable default public GeckoResult<Void> onClearFormData(long);
+ method @Nullable default public GeckoResult<Void> onClearHistory(long);
+ method @Nullable default public GeckoResult<Void> onClearPasswords(long);
+ method @Nullable default public GeckoResult<WebExtension.BrowsingDataDelegate.Settings> onGetSettings();
+ }
+
+ @UiThread public static class WebExtension.BrowsingDataDelegate.Settings {
+ ctor @UiThread public Settings(int, long, long);
+ field public final long selectedTypes;
+ field public final int sinceUnixTimestamp;
+ field public final long toggleableTypes;
+ }
+
+ public static class WebExtension.BrowsingDataDelegate.Type {
+ ctor protected Type();
+ field public static final long CACHE = 1L;
+ field public static final long COOKIES = 2L;
+ field public static final long DOWNLOADS = 4L;
+ field public static final long FORM_DATA = 8L;
+ field public static final long HISTORY = 16L;
+ field public static final long LOCAL_STORAGE = 32L;
+ field public static final long PASSWORDS = 64L;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.BrowsingDataTypes {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.ContextFlags {
+ }
+
+ public static class WebExtension.CreateTabDetails {
+ ctor protected CreateTabDetails();
+ field @Nullable public final Boolean active;
+ field @Nullable public final String cookieStoreId;
+ field @Nullable public final Boolean discarded;
+ field @Nullable public final Integer index;
+ field @Nullable public final Boolean openInReaderMode;
+ field @Nullable public final Boolean pinned;
+ field @Nullable public final String url;
+ }
+
+ public static class WebExtension.DisabledFlags {
+ ctor public DisabledFlags();
+ field public static final int APP = 8;
+ field public static final int BLOCKLIST = 4;
+ field public static final int USER = 2;
+ }
+
+ public static class WebExtension.Download {
+ ctor protected Download(int);
+ method @Nullable @UiThread public GeckoResult<Void> update(@NonNull WebExtension.Download.Info);
+ field public static final int INTERRUPT_REASON_CRASH = 24;
+ field public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2;
+ field public static final int INTERRUPT_REASON_FILE_BLOCKED = 8;
+ field public static final int INTERRUPT_REASON_FILE_FAILED = 1;
+ field public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4;
+ field public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3;
+ field public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9;
+ field public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5;
+ field public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10;
+ field public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7;
+ field public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6;
+ field public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13;
+ field public static final int INTERRUPT_REASON_NETWORK_FAILED = 11;
+ field public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15;
+ field public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14;
+ field public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12;
+ field public static final int INTERRUPT_REASON_NO_INTERRUPT = 0;
+ field public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18;
+ field public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20;
+ field public static final int INTERRUPT_REASON_SERVER_FAILED = 16;
+ field public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21;
+ field public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17;
+ field public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19;
+ field public static final int INTERRUPT_REASON_USER_CANCELED = 22;
+ field public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23;
+ field public static final int STATE_COMPLETE = 2;
+ field public static final int STATE_INTERRUPTED = 1;
+ field public static final int STATE_IN_PROGRESS = 0;
+ field public final int id;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.Download.DownloadInterruptReason {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.Download.DownloadState {
+ }
+
+ public static interface WebExtension.Download.Info {
+ method @UiThread default public long bytesReceived();
+ method @UiThread default public boolean canResume();
+ method @Nullable @UiThread default public Long endTime();
+ method @Nullable @UiThread default public Integer error();
+ method @Nullable @UiThread default public Long estimatedEndTime();
+ method @UiThread default public boolean fileExists();
+ method @UiThread default public long fileSize();
+ method @NonNull @UiThread default public String filename();
+ method @NonNull @UiThread default public String mime();
+ method @UiThread default public boolean paused();
+ method @NonNull @UiThread default public String referrer();
+ method @UiThread default public long startTime();
+ method @UiThread default public int state();
+ method @UiThread default public long totalBytes();
+ }
+
+ public static interface WebExtension.DownloadDelegate {
+ method @AnyThread @Nullable default public GeckoResult<WebExtension.DownloadInitData> onDownload(@NonNull WebExtension, @NonNull WebExtension.DownloadRequest);
+ }
+
+ public static class WebExtension.DownloadInitData {
+ ctor public DownloadInitData(WebExtension.Download, WebExtension.Download.Info);
+ field @NonNull public final WebExtension.Download download;
+ field @NonNull public final WebExtension.Download.Info initData;
+ }
+
+ public static class WebExtension.DownloadRequest {
+ ctor protected DownloadRequest(WebExtension.DownloadRequest.Builder);
+ field public static final int CONFLICT_ACTION_OVERWRITE = 1;
+ field public static final int CONFLICT_ACTION_PROMPT = 2;
+ field public static final int CONFLICT_ACTION_UNIQUIFY = 0;
+ field public final boolean allowHttpErrors;
+ field public final int conflictActionFlag;
+ field public final int downloadFlags;
+ field @Nullable public final String filename;
+ field @NonNull public final WebRequest request;
+ field public final boolean saveAs;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.DownloadRequest.ConflictActionFlags {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.EnabledFlags {
+ }
+
+ public static class WebExtension.Flags {
+ ctor protected Flags();
+ field public static final long ALLOW_CONTENT_MESSAGING = 1L;
+ field public static final long NONE = 0L;
+ }
+
+ public static class WebExtension.InstallException extends Exception {
+ ctor protected InstallException();
+ field public final int code;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.InstallException.Codes {
+ }
+
+ public static class WebExtension.InstallException.ErrorCodes {
+ ctor protected ErrorCodes();
+ field public static final int ERROR_CORRUPT_FILE = -3;
+ field public static final int ERROR_FILE_ACCESS = -4;
+ field public static final int ERROR_INCORRECT_HASH = -2;
+ field public static final int ERROR_INCORRECT_ID = -7;
+ field public static final int ERROR_INVALID_DOMAIN = -8;
+ field public static final int ERROR_NETWORK_FAILURE = -1;
+ field public static final int ERROR_POSTPONED = -101;
+ field public static final int ERROR_SIGNEDSTATE_REQUIRED = -5;
+ field public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6;
+ field public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9;
+ field public static final int ERROR_USER_CANCELED = -100;
+ }
+
+ @UiThread public static interface WebExtension.MessageDelegate {
+ method @Nullable default public void onConnect(@NonNull WebExtension.Port);
+ method @Nullable default public GeckoResult<Object> onMessage(@NonNull String, @NonNull Object, @NonNull WebExtension.MessageSender);
+ }
+
+ @UiThread public static class WebExtension.MessageSender {
+ ctor protected MessageSender();
+ method public boolean isTopLevel();
+ field public static final int ENV_TYPE_CONTENT_SCRIPT = 2;
+ field public static final int ENV_TYPE_EXTENSION = 1;
+ field public final int environmentType;
+ field @Nullable public final GeckoSession session;
+ field @NonNull public final String url;
+ field @NonNull public final WebExtension webExtension;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.MessageSender.EnvType {
+ }
+
+ public class WebExtension.MetaData {
+ ctor protected MetaData();
+ field public final boolean allowedInPrivateBrowsing;
+ field @NonNull public final String baseUrl;
+ field public final int blocklistState;
+ field @Nullable public final String creatorName;
+ field @Nullable public final String creatorUrl;
+ field @Nullable public final String description;
+ field public final int disabledFlags;
+ field public final boolean enabled;
+ field @Nullable public final String homepageUrl;
+ field @NonNull public final Image icon;
+ field public final boolean isRecommended;
+ field @Nullable public final String name;
+ field public final boolean openOptionsPageInTab;
+ field @Nullable public final String optionsPageUrl;
+ field @NonNull public final String[] origins;
+ field @NonNull public final String[] permissions;
+ field public final int signedState;
+ field public final boolean temporary;
+ field @NonNull public final String version;
+ }
+
+ @UiThread public static class WebExtension.Port {
+ ctor protected Port();
+ method public void disconnect();
+ method public void postMessage(@NonNull JSONObject);
+ method public void setDelegate(@Nullable WebExtension.PortDelegate);
+ field @NonNull public final String name;
+ field @NonNull public final WebExtension.MessageSender sender;
+ }
+
+ @UiThread public static interface WebExtension.PortDelegate {
+ method @NonNull default public void onDisconnect(@NonNull WebExtension.Port);
+ method default public void onPortMessage(@NonNull Object, @NonNull WebExtension.Port);
+ }
+
+ public static class WebExtension.SessionController {
+ method @AnyThread @Nullable public WebExtension.ActionDelegate getActionDelegate(@NonNull WebExtension);
+ method @AnyThread @Nullable public WebExtension.MessageDelegate getMessageDelegate(@NonNull WebExtension, @NonNull String);
+ method @AnyThread @Nullable public WebExtension.SessionTabDelegate getTabDelegate(@NonNull WebExtension);
+ method @AnyThread public void setActionDelegate(@NonNull WebExtension, @Nullable WebExtension.ActionDelegate);
+ method @AnyThread public void setMessageDelegate(@NonNull WebExtension, @Nullable WebExtension.MessageDelegate, @NonNull String);
+ method @AnyThread public void setTabDelegate(@NonNull WebExtension, @Nullable WebExtension.SessionTabDelegate);
+ }
+
+ public static interface WebExtension.SessionTabDelegate {
+ method @NonNull @UiThread default public GeckoResult<AllowOrDeny> onCloseTab(@Nullable WebExtension, @NonNull GeckoSession);
+ method @NonNull @UiThread default public GeckoResult<AllowOrDeny> onUpdateTab(@NonNull WebExtension, @NonNull GeckoSession, @NonNull WebExtension.UpdateTabDetails);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.SignedState {
+ }
+
+ public static class WebExtension.SignedStateFlags {
+ ctor public SignedStateFlags();
+ field public static final int MISSING = 0;
+ field public static final int PRELIMINARY = 1;
+ field public static final int PRIVILEGED = 4;
+ field public static final int SIGNED = 2;
+ field public static final int SYSTEM = 3;
+ field public static final int UNKNOWN = -1;
+ }
+
+ public static interface WebExtension.TabDelegate {
+ method @Nullable @UiThread default public GeckoResult<GeckoSession> onNewTab(@NonNull WebExtension, @NonNull WebExtension.CreateTabDetails);
+ method @UiThread default public void onOpenOptionsPage(@NonNull WebExtension);
+ }
+
+ public static class WebExtension.UpdateTabDetails {
+ ctor protected UpdateTabDetails();
+ field @Nullable public final Boolean active;
+ field @Nullable public final Boolean autoDiscardable;
+ field @Nullable public final Boolean highlighted;
+ field @Nullable public final Boolean muted;
+ field @Nullable public final Boolean pinned;
+ field @Nullable public final String url;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtension.WebExtensionFlags {
+ }
+
+ public class WebExtensionController {
+ method @Nullable @UiThread public WebExtension.Download createDownload(int);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> disable(@NonNull WebExtension, int);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> enable(@NonNull WebExtension, int);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> ensureBuiltIn(@NonNull String, @Nullable String);
+ method @Nullable @UiThread public WebExtensionController.PromptDelegate getPromptDelegate();
+ method @AnyThread @NonNull public GeckoResult<WebExtension> install(@NonNull String);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> installBuiltIn(@NonNull String);
+ method @AnyThread @NonNull public GeckoResult<List<WebExtension>> list();
+ method @UiThread public void setAddonManagerDelegate(@Nullable WebExtensionController.AddonManagerDelegate);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> setAllowedInPrivateBrowsing(@NonNull WebExtension, boolean);
+ method @UiThread public void setDebuggerDelegate(@NonNull WebExtensionController.DebuggerDelegate);
+ method @UiThread public void setPromptDelegate(@Nullable WebExtensionController.PromptDelegate);
+ method @AnyThread public void setTabActive(@NonNull GeckoSession, boolean);
+ method @AnyThread @NonNull public GeckoResult<Void> uninstall(@NonNull WebExtension);
+ method @AnyThread @NonNull public GeckoResult<WebExtension> update(@NonNull WebExtension);
+ }
+
+ public static interface WebExtensionController.AddonManagerDelegate {
+ method @UiThread default public void onDisabled(@NonNull WebExtension);
+ method @UiThread default public void onDisabling(@NonNull WebExtension);
+ method @UiThread default public void onEnabled(@NonNull WebExtension);
+ method @UiThread default public void onEnabling(@NonNull WebExtension);
+ method @UiThread default public void onInstalled(@NonNull WebExtension);
+ method @UiThread default public void onInstalling(@NonNull WebExtension);
+ method @UiThread default public void onUninstalled(@NonNull WebExtension);
+ method @UiThread default public void onUninstalling(@NonNull WebExtension);
+ }
+
+ public static interface WebExtensionController.DebuggerDelegate {
+ method @UiThread default public void onExtensionListUpdated();
+ }
+
+ public static class WebExtensionController.EnableSource {
+ ctor public EnableSource();
+ field public static final int APP = 2;
+ field public static final int USER = 1;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtensionController.EnableSources {
+ }
+
+ @UiThread public static interface WebExtensionController.PromptDelegate {
+ method @Nullable default public GeckoResult<AllowOrDeny> onInstallPrompt(@NonNull WebExtension);
+ method @Nullable default public GeckoResult<AllowOrDeny> onOptionalPrompt(@NonNull WebExtension, @NonNull String[], @NonNull String[]);
+ method @Nullable default public GeckoResult<AllowOrDeny> onUpdatePrompt(@NonNull WebExtension, @NonNull WebExtension, @NonNull String[], @NonNull String[]);
+ }
+
+ @AnyThread public abstract class WebMessage {
+ ctor protected WebMessage(@NonNull WebMessage.Builder);
+ field @NonNull public final Map<String,String> headers;
+ field @NonNull public final String uri;
+ }
+
+ @AnyThread public abstract static class WebMessage.Builder {
+ method @NonNull public WebMessage.Builder addHeader(@NonNull String, @NonNull String);
+ method @NonNull public WebMessage.Builder header(@NonNull String, @NonNull String);
+ method @NonNull public WebMessage.Builder uri(@NonNull String);
+ }
+
+ public class WebNotification implements Parcelable {
+ method @UiThread public void click();
+ method @UiThread public void dismiss();
+ field public static final Parcelable.Creator<WebNotification> CREATOR;
+ field @Nullable public final String imageUrl;
+ field @Nullable public final String lang;
+ field public final boolean privateBrowsing;
+ field @NonNull public final boolean requireInteraction;
+ field public final boolean silent;
+ field @Nullable public final String source;
+ field @NonNull public final String tag;
+ field @Nullable public final String text;
+ field @Nullable public final String textDirection;
+ field @Nullable public final String title;
+ field @NonNull public final int[] vibrate;
+ }
+
+ public interface WebNotificationDelegate {
+ method @AnyThread default public void onCloseNotification(@NonNull WebNotification);
+ method @AnyThread default public void onShowNotification(@NonNull WebNotification);
+ }
+
+ public class WebPushController {
+ method @Nullable @UiThread public WebPushDelegate getDelegate();
+ method @UiThread public void onPushEvent(@NonNull String);
+ method @UiThread public void onPushEvent(@NonNull String, @Nullable byte[]);
+ method @UiThread public void onSubscriptionChanged(@NonNull String);
+ method @UiThread public void setDelegate(@Nullable WebPushDelegate);
+ }
+
+ public interface WebPushDelegate {
+ method @Nullable @UiThread default public GeckoResult<WebPushSubscription> onGetSubscription(@NonNull String);
+ method @Nullable @UiThread default public GeckoResult<WebPushSubscription> onSubscribe(@NonNull String, @Nullable byte[]);
+ method @Nullable @UiThread default public GeckoResult<Void> onUnsubscribe(@NonNull String);
+ }
+
+ public class WebPushSubscription implements Parcelable {
+ ctor public WebPushSubscription(@NonNull String, @NonNull String, @Nullable byte[], @NonNull byte[], @NonNull byte[]);
+ field public static final Parcelable.Creator<WebPushSubscription> CREATOR;
+ field @Nullable public final byte[] appServerKey;
+ field @NonNull public final byte[] authSecret;
+ field @NonNull public final byte[] browserPublicKey;
+ field @NonNull public final String endpoint;
+ field @NonNull public final String scope;
+ }
+
+ @AnyThread public class WebRequest extends WebMessage {
+ ctor public WebRequest(@NonNull String);
+ field public static final int CACHE_MODE_DEFAULT = 1;
+ field public static final int CACHE_MODE_FORCE_CACHE = 5;
+ field public static final int CACHE_MODE_NO_CACHE = 4;
+ field public static final int CACHE_MODE_NO_STORE = 2;
+ field public static final int CACHE_MODE_ONLY_IF_CACHED = 6;
+ field public static final int CACHE_MODE_RELOAD = 3;
+ field public final boolean beConservative;
+ field @Nullable public final ByteBuffer body;
+ field public final int cacheMode;
+ field @NonNull public final String method;
+ field @Nullable public final String referrer;
+ }
+
+ @AnyThread public static class WebRequest.Builder extends WebMessage.Builder {
+ ctor public Builder(@NonNull String);
+ method @NonNull public WebRequest.Builder beConservative(boolean);
+ method @NonNull public WebRequest.Builder body(@Nullable ByteBuffer);
+ method @NonNull public WebRequest.Builder body(@Nullable String);
+ method @NonNull public WebRequest build();
+ method @NonNull public WebRequest.Builder cacheMode(int);
+ method @NonNull public WebRequest.Builder method(@NonNull String);
+ method @NonNull public WebRequest.Builder referrer(@Nullable String);
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebRequest.CacheMode {
+ }
+
+ @AnyThread public class WebRequestError extends Exception {
+ ctor public WebRequestError(int, int);
+ ctor public WebRequestError(int, int, X509Certificate);
+ field public static final int ERROR_BAD_HSTS_CERT = 179;
+ field public static final int ERROR_CATEGORY_CONTENT = 4;
+ field public static final int ERROR_CATEGORY_NETWORK = 3;
+ field public static final int ERROR_CATEGORY_PROXY = 6;
+ field public static final int ERROR_CATEGORY_SAFEBROWSING = 7;
+ field public static final int ERROR_CATEGORY_SECURITY = 2;
+ field public static final int ERROR_CATEGORY_UNKNOWN = 1;
+ field public static final int ERROR_CATEGORY_URI = 5;
+ field public static final int ERROR_CONNECTION_REFUSED = 67;
+ field public static final int ERROR_CONTENT_CRASHED = 68;
+ field public static final int ERROR_CORRUPTED_CONTENT = 52;
+ field public static final int ERROR_DATA_URI_TOO_LONG = 117;
+ field public static final int ERROR_FILE_ACCESS_DENIED = 101;
+ field public static final int ERROR_FILE_NOT_FOUND = 85;
+ field public static final int ERROR_HTTPS_ONLY = 163;
+ field public static final int ERROR_INVALID_CONTENT_ENCODING = 84;
+ field public static final int ERROR_MALFORMED_URI = 53;
+ field public static final int ERROR_NET_INTERRUPT = 35;
+ field public static final int ERROR_NET_RESET = 147;
+ field public static final int ERROR_NET_TIMEOUT = 51;
+ field public static final int ERROR_OFFLINE = 115;
+ field public static final int ERROR_PORT_BLOCKED = 131;
+ field public static final int ERROR_PROXY_CONNECTION_REFUSED = 38;
+ field public static final int ERROR_REDIRECT_LOOP = 99;
+ field public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 71;
+ field public static final int ERROR_SAFEBROWSING_MALWARE_URI = 39;
+ field public static final int ERROR_SAFEBROWSING_PHISHING_URI = 87;
+ field public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 55;
+ field public static final int ERROR_SECURITY_BAD_CERT = 50;
+ field public static final int ERROR_SECURITY_SSL = 34;
+ field public static final int ERROR_UNKNOWN = 17;
+ field public static final int ERROR_UNKNOWN_HOST = 37;
+ field public static final int ERROR_UNKNOWN_PROTOCOL = 69;
+ field public static final int ERROR_UNKNOWN_PROXY_HOST = 54;
+ field public static final int ERROR_UNKNOWN_SOCKET_TYPE = 83;
+ field public static final int ERROR_UNSAFE_CONTENT_TYPE = 36;
+ field public final int category;
+ field @Nullable public final X509Certificate certificate;
+ field public final int code;
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebRequestError.Error {
+ }
+
+ @Retention(value=RetentionPolicy.SOURCE) public static interface WebRequestError.ErrorCategory {
+ }
+
+ @AnyThread public class WebResponse extends WebMessage {
+ ctor protected WebResponse(@NonNull WebResponse.Builder);
+ method public void setReadTimeoutMillis(long);
+ field public static final long DEFAULT_READ_TIMEOUT_MS = 30000L;
+ field @Nullable public final InputStream body;
+ field @Nullable public final X509Certificate certificate;
+ field public final boolean isSecure;
+ field public final boolean redirected;
+ field @Nullable public final boolean requestExternalApp;
+ field @Nullable public final boolean skipConfirmation;
+ field public final int statusCode;
+ }
+
+ @AnyThread public static class WebResponse.Builder extends WebMessage.Builder {
+ ctor public Builder(@NonNull String);
+ method @NonNull public WebResponse.Builder body(@NonNull InputStream);
+ method @NonNull public WebResponse build();
+ method @NonNull public WebResponse.Builder certificate(@NonNull X509Certificate);
+ method @NonNull public WebResponse.Builder isSecure(boolean);
+ method @NonNull public WebResponse.Builder redirected(boolean);
+ method @NonNull public WebResponse.Builder requestExternalApp(boolean);
+ method @NonNull public WebResponse.Builder skipConfirmation(boolean);
+ method @NonNull public WebResponse.Builder statusCode(int);
+ }
+
+}
+
diff --git a/mobile/android/geckoview/build.gradle b/mobile/android/geckoview/build.gradle
new file mode 100644
index 0000000000..8507714a76
--- /dev/null
+++ b/mobile/android/geckoview/build.gradle
@@ -0,0 +1,562 @@
+buildDir "${topobjdir}/gradle/build/mobile/android/geckoview"
+
+import groovy.json.JsonOutput
+
+apply plugin: 'com.android.library'
+apply plugin: 'checkstyle'
+apply plugin: 'kotlin-android'
+
+apply from: "${topsrcdir}/mobile/android/gradle/product_flavors.gradle"
+
+// The SDK binding generation tasks depend on the JAR creation task of the
+// :annotations project.
+evaluationDependsOn(':annotations')
+
+android {
+ buildToolsVersion project.ext.buildToolsVersion
+ compileSdkVersion project.ext.compileSdkVersion
+
+ useLibrary 'android.test.runner'
+ useLibrary 'android.test.base'
+ useLibrary 'android.test.mock'
+
+ defaultConfig {
+ targetSdkVersion project.ext.targetSdkVersion
+ minSdkVersion project.ext.minSdkVersion
+ manifestPlaceholders = project.ext.manifestPlaceholders
+ multiDexEnabled true
+
+ versionCode project.ext.versionCode
+ versionName project.ext.versionName
+ consumerProguardFiles 'proguard-rules.txt'
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField 'String', "GRE_MILESTONE", "\"${mozconfig.substs.GRE_MILESTONE}\""
+ buildConfigField 'String', "MOZ_APP_BASENAME", "\"${mozconfig.substs.MOZ_APP_BASENAME}\"";
+
+ // For the benefit of future archaeologists:
+ // GRE_BUILDID is exactly the same as MOZ_APP_BUILDID unless you're running
+ // on XULRunner, which is never the case on Android.
+ buildConfigField 'String', "MOZ_APP_BUILDID", "\"${project.ext.buildId}\"";
+ buildConfigField 'String', "MOZ_APP_ID", "\"${mozconfig.substs.MOZ_APP_ID}\"";
+ buildConfigField 'String', "MOZ_APP_NAME", "\"${mozconfig.substs.MOZ_APP_NAME}\"";
+ buildConfigField 'String', "MOZ_APP_VENDOR", "\"${mozconfig.substs.MOZ_APP_VENDOR}\"";
+ buildConfigField 'String', "MOZ_APP_VERSION", "\"${mozconfig.substs.MOZ_APP_VERSION}\"";
+ buildConfigField 'String', "MOZ_APP_DISPLAYNAME", "\"${mozconfig.substs.MOZ_APP_DISPLAYNAME}\"";
+ buildConfigField 'String', "MOZ_APP_UA_NAME", "\"${mozconfig.substs.MOZ_APP_UA_NAME}\"";
+ buildConfigField 'String', "MOZ_UPDATE_CHANNEL", "\"${mozconfig.substs.MOZ_UPDATE_CHANNEL}\"";
+
+ // MOZILLA_VERSION is oddly quoted from autoconf, but we don't have to handle it specially in Gradle.
+ buildConfigField 'String', "MOZILLA_VERSION", "\"${mozconfig.substs.MOZILLA_VERSION}\"";
+ buildConfigField 'String', "OMNIJAR_NAME", "\"${mozconfig.substs.OMNIJAR_NAME}\"";
+
+ // Keep in sync with actual user agent in nsHttpHandler::BuildUserAgent
+ buildConfigField 'String', "USER_AGENT_GECKOVIEW_MOBILE", "\"Mozilla/5.0 (Android \" + android.os.Build.VERSION.RELEASE + \"; Mobile; rv:109.0) Gecko/\" + ${mozconfig.defines.MOZILLA_UAVERSION} + \" Firefox/\" + ${mozconfig.defines.MOZILLA_UAVERSION}";
+ buildConfigField 'String', "USER_AGENT_GECKOVIEW_TABLET", "\"Mozilla/5.0 (Android \" + android.os.Build.VERSION.RELEASE + \"; Tablet; rv:109.0) Gecko/\" + ${mozconfig.defines.MOZILLA_UAVERSION} + \" Firefox/\" + ${mozconfig.defines.MOZILLA_UAVERSION}";
+
+ buildConfigField 'int', 'MIN_SDK_VERSION', mozconfig.substs.MOZ_ANDROID_MIN_SDK_VERSION;
+
+ // Is the underlying compiled C/C++ code compiled with --enable-debug?
+ buildConfigField 'boolean', 'DEBUG_BUILD', mozconfig.substs.MOZ_DEBUG ? 'true' : 'false';
+
+ // See this wiki page for more details about channel specific build defines:
+ // https://wiki.mozilla.org/Platform/Channel-specific_build_defines
+ // This makes no sense for GeckoView and should be removed as soon as possible.
+ buildConfigField 'boolean', 'RELEASE_OR_BETA', mozconfig.substs.RELEASE_OR_BETA ? 'true' : 'false';
+ // This makes no sense for GeckoView and should be removed as soon as possible.
+ buildConfigField 'boolean', 'NIGHTLY_BUILD', mozconfig.substs.NIGHTLY_BUILD ? 'true' : 'false';
+ // This makes no sense for GeckoView and should be removed as soon as possible.
+ buildConfigField 'boolean', 'MOZ_CRASHREPORTER', mozconfig.substs.MOZ_CRASHREPORTER ? 'true' : 'false';
+
+ buildConfigField 'int', 'MOZ_ANDROID_CONTENT_SERVICE_COUNT', mozconfig.substs.MOZ_ANDROID_CONTENT_SERVICE_COUNT;
+
+ // Official corresponds, roughly, to whether this build is performed on
+ // Mozilla's continuous integration infrastructure. You should disable
+ // developer-only functionality when this flag is set.
+ // This makes no sense for GeckoView and should be removed as soon as possible.
+ buildConfigField 'boolean', 'MOZILLA_OFFICIAL', mozconfig.substs.MOZILLA_OFFICIAL ? 'true' : 'false';
+
+ // This env variable signifies whether we are running an isolated process build.
+ buildConfigField 'boolean', 'MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS', mozconfig.substs.MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS ? 'true' : 'false';
+ }
+
+ project.configureProductFlavors.delegate = it
+ project.configureProductFlavors()
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
+ lintOptions {
+ abortOnError false
+ }
+
+ sourceSets {
+ main {
+ java {
+ if (!mozconfig.substs.MOZ_ANDROID_HLS_SUPPORT) {
+ exclude 'org/mozilla/gecko/media/GeckoHlsAudioRenderer.java'
+ exclude 'org/mozilla/gecko/media/GeckoHlsPlayer.java'
+ exclude 'org/mozilla/gecko/media/GeckoHlsRendererBase.java'
+ exclude 'org/mozilla/gecko/media/GeckoHlsVideoRenderer.java'
+ exclude 'org/mozilla/gecko/media/Utils.java'
+ }
+
+ if (mozconfig.substs.MOZ_WEBRTC) {
+ srcDir "${topsrcdir}/dom/media/systemservices/android_video_capture/java/src"
+ srcDir "${topsrcdir}/third_party/libwebrtc/sdk/android/api"
+ srcDir "${topsrcdir}/third_party/libwebrtc/sdk/android/src"
+ srcDir "${topsrcdir}/third_party/libwebrtc/rtc_base/java"
+ srcDir "${topsrcdir}/third_party/libwebrtc/modules/audio_device/android/java"
+ }
+
+ srcDir "${topobjdir}/mobile/android/geckoview/src/main/java"
+ }
+
+ resources {
+ if (mozconfig.substs.MOZ_ASAN) {
+ // If this is an ASAN build, include a `wrap.sh` for Android 8.1+ devices. See
+ // https://developer.android.com/ndk/guides/wrap-script.
+ srcDir "${topsrcdir}/mobile/android/geckoview/src/asan/resources"
+ }
+ }
+
+ assets {
+ }
+
+ debug {
+ manifest.srcFile "${topobjdir}/mobile/android/geckoview/src/main/AndroidManifest_overlay.xml"
+ }
+
+ release {
+ manifest.srcFile "${topobjdir}/mobile/android/geckoview/src/main/AndroidManifest_overlay.xml"
+ }
+ }
+
+ withGeckoBinaries {
+ assets {
+ // This should contain only `omni.ja`.
+ srcDir "${topobjdir}/dist/geckoview/assets"
+ }
+
+ jniLibs {
+ if (!mozconfig.substs.MOZ_ANDROID_FAT_AAR_ARCHITECTURES) {
+ srcDir "${topobjdir}/dist/geckoview/lib"
+ } else {
+ srcDir "${topobjdir}/dist/fat-aar/output/jni"
+ }
+ }
+ }
+ }
+}
+
+tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) {
+ // Translate Kotlin messages like "w: ..." and "e: ..." into
+ // "...: warning: ..." and "...: error: ...", to make Treeherder understand.
+ def listener = {
+ if (it.startsWith("e: warnings found")) {
+ return
+ }
+
+ if (it.startsWith('w: ') || it.startsWith('e: ')) {
+ def matches = (it =~ /([ew]): (.+): \((\d+), (\d+)\): (.*)/)
+ if (!matches) {
+ logger.quiet "kotlinc message format has changed!"
+ if (it.startsWith('w: ')) {
+ // For warnings, don't continue because we don't want to throw an
+ // exception. For errors, we want the exception so that the new error
+ // message format gets translated properly.
+ return
+ }
+ }
+ def (_, type, file, line, column, message) = matches[0]
+ type = (type == 'w') ? 'warning' : 'error'
+ // Use logger.lifecycle, which does not go through stderr again.
+ logger.lifecycle "$file:$line:$column: $type: $message"
+ }
+ } as StandardOutputListener
+
+ kotlinOptions {
+ allWarningsAsErrors = true
+ jvmTarget = JavaVersion.VERSION_11
+ }
+
+ doFirst {
+ logging.addStandardErrorListener(listener)
+ }
+ doLast {
+ logging.removeStandardErrorListener(listener)
+ }
+}
+
+configurations {
+ withGeckoBinariesApi {
+ outgoing {
+ if (!mozconfig.substs.MOZ_ANDROID_GECKOVIEW_LITE) {
+ // The omni build provides glean-native
+ capability("org.mozilla.telemetry:glean-native:${project.ext.gleanVersion}")
+ }
+ afterEvaluate {
+ // Implicit capability
+ capability("org.mozilla.geckoview:${getArtifactId()}:${project.ext.versionNumber}")
+ }
+ }
+ }
+ // TODO: This is a workaround for a bug that was fixed in Gradle 7.
+ // The variant resolver _should_ pick the RuntimeOnly configuration when building
+ // the tests as those define the implicit :geckoview capability but it doesn't,
+ // so we manually define it here.
+ withGeckoBinariesRuntimeOnly {
+ outgoing {
+ afterEvaluate {
+ // Implicit capability
+ capability("org.mozilla.geckoview:geckoview:${project.ext.versionNumber}")
+ }
+ }
+ }
+}
+
+dependencies {
+ implementation "androidx.annotation:annotation:1.6.0"
+ implementation "androidx.legacy:legacy-support-v4:1.0.0"
+
+ implementation "com.google.android.gms:play-services-fido:20.0.1"
+ implementation "org.yaml:snakeyaml:2.0"
+
+ implementation "androidx.lifecycle:lifecycle-common:2.6.1"
+ implementation "androidx.lifecycle:lifecycle-process:2.6.1"
+
+ if (mozconfig.substs.MOZ_ANDROID_HLS_SUPPORT) {
+ implementation project(":exoplayer2")
+ }
+
+ testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.robolectric:robolectric:4.10.1'
+ testImplementation 'org.mockito:mockito-core:5.3.1'
+
+ androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
+ androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
+ androidTestImplementation 'androidx.test:runner:1.5.2'
+ androidTestImplementation 'androidx.test:rules:1.5.0'
+ androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.0'
+
+ androidTestImplementation 'com.koushikdutta.async:androidasync:3.1.0'
+
+ androidTestImplementation 'androidx.multidex:multidex:2.0.1'
+}
+
+apply from: "${topsrcdir}/mobile/android/gradle/with_gecko_binaries.gradle"
+
+android.libraryVariants.all { variant ->
+ // See the notes in mobile/android/app/build.gradle for details on including
+ // Gecko binaries and the Omnijar.
+ if ((variant.productFlavors*.name).contains('withGeckoBinaries')) {
+ configureVariantWithGeckoBinaries(variant)
+ }
+
+ // Javadoc and Sources JAR configuration cribbed from
+ // https://github.com/mapbox/mapbox-gl-native/blob/d169ea55c1cfa85cd8bf19f94c5f023569f71810/platform/android/MapboxGLAndroidSDK/build.gradle#L85
+ // informed by
+ // https://code.tutsplus.com/tutorials/creating-and-publishing-an-android-library--cms-24582,
+ // and amended from numerous Stackoverflow posts.
+ def name = variant.name
+ def javadoc = task "javadoc${name.capitalize()}"(type: Javadoc) {
+ failOnError = false
+ description = "Generate Javadoc for build variant $name"
+ destinationDir = new File(destinationDir, variant.baseName)
+
+ // The javadoc task will not re-run if the previous run is still up-to-date,
+ // this is a problem for the javadoc lint, which needs to read the output of the task
+ // to determine if there are warnings or errors. To force that we pass a -Pandroid-lint
+ // parameter to all lints that can be used here to force running the task every time.
+ outputs.upToDateWhen {
+ !project.hasProperty('android-lint')
+ }
+
+ doFirst {
+ classpath = files(variant.javaCompileProvider.get().classpath.files)
+ }
+
+ def results = []
+ def listener = {
+ if (!it.toLowerCase().contains("warning:") && !it.toLowerCase().contains("error:")) {
+ // Likely not an error or a warning
+ return
+ }
+ // Like '/abs/path/to/topsrcdir/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java:480: warning: no @return'
+ def matches = (it =~ /(.+):(\d+):.*(warning|error)(.*)/)
+ if (!matches) {
+ // could not parse, let's add it anyway since it's a warning or error
+ results << [path: "parsing-failed", lineno: 0, level: "error", message: it]
+ return
+ }
+ def (_, file, line, level, message) = matches[0]
+ results << [path: file, lineno: line, level: level, message: message]
+ } as StandardOutputListener
+
+ doFirst {
+ logging.addStandardErrorListener(listener)
+ }
+
+ doLast {
+ logging.removeStandardErrorListener(listener)
+
+ // We used to treat Javadoc warnings as errors here; now we rely on the
+ // `android-javadoc` linter to fail in the face of Javadoc warnings.
+ def resultsJson = JsonOutput.toJson(results)
+
+ file("$buildDir/reports").mkdirs()
+ file("$buildDir/reports/javadoc-results-${name}.json").write(resultsJson)
+ }
+
+ source = variant.sourceSets.collect({ it.java.srcDirs })
+ exclude '**/R.java', '**/BuildConfig.java'
+ include 'org/mozilla/geckoview/**.java'
+ options.addPathOption('sourcepath', ':').setValue(
+ variant.sourceSets.collect({ it.java.srcDirs }).flatten() +
+ variant.generateBuildConfigProvider.get().sourceOutputDir.asFile.get() +
+ variant.aidlCompileProvider.get().sourceOutputDir.asFile.get()
+ )
+ options.addStringOption("Xmaxwarns", "1000")
+
+ classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
+ classpath += variant.javaCompileProvider.get().classpath
+
+ options.memberLevel = JavadocMemberLevel.PROTECTED
+ options.source = 11
+ options.links("https://developer.android.com/reference")
+
+ options.docTitle = "GeckoView ${mozconfig.substs.MOZ_APP_VERSION} API"
+ options.header = "GeckoView ${mozconfig.substs.MOZ_APP_VERSION} API"
+ options.noTimestamp = true
+ options.noQualifiers = ['java.lang']
+ options.tags = ['hide:a:']
+ }
+
+ def javadocJar = task("javadocJar${name.capitalize()}", type: Jar, dependsOn: javadoc) {
+ archiveClassifier = 'javadoc'
+ from javadoc.destinationDir
+ }
+
+ // This task is used by `mach android geckoview-docs`.
+ task("javadocCopyJar${name.capitalize()}", type: Copy) {
+ from(javadocJar.destinationDirectory) {
+ include 'geckoview-*-javadoc.jar'
+ rename { _ -> 'geckoview-javadoc.jar' }
+ }
+ into javadocJar.destinationDirectory
+ dependsOn javadocJar
+ }
+
+ def sourcesJar = task("sourcesJar${name.capitalize()}", type: Jar) {
+ classifier 'sources'
+ description = "Generate Javadoc for build variant $name"
+ destinationDirectory =
+ file("${topobjdir}/mobile/android/geckoview/sources/${variant.baseName}")
+ from files(variant.sourceSets.collect({ it.java.srcDirs }).flatten())
+ }
+
+ task("checkstyle${name.capitalize()}", type: Checkstyle) {
+ classpath = variant.javaCompileProvider.get().classpath
+ // TODO: cleanup and include all sources
+ source = ['src/main/java/']
+ include '**/*.java'
+
+ }
+}
+
+checkstyle {
+ configDirectory = file(".")
+ configFile = file("checkstyle.xml")
+ toolVersion = "8.36.2"
+}
+
+android.libraryVariants.all { variant ->
+ if (variant.name == mozconfig.substs.GRADLE_ANDROID_GECKOVIEW_VARIANT_NAME) {
+ configureLibraryVariantWithJNIWrappers(variant, "Generated")
+ }
+}
+
+android.libraryVariants.all { variant ->
+ // At this point, the Android-Gradle plugin has created all the Android
+ // tasks and configurations. This is the time for us to declare additional
+ // Glean files to package into AAR files. This packs `metrics.yaml` in the
+ // root of the AAR, sibling to `AndroidManifest.xml` and `classes.jar`. By
+ // default, consumers of the AAR will ignore this file, but consumers that
+ // look for it can find it (provided GeckoView is a `module()` dependency
+ // and not a `project()` dependency.) Under the hood this uses that the
+ // task provided by `packageLibraryProvider` task is a Maven `Zip` task,
+ // and we can simply extend its inputs. See
+ // https://android.googlesource.com/platform/tools/base/+/0cbe8846f7d02c0bb6f07156b9f4fde16d96d329/build-system/gradle-core/src/main/java/com/android/build/gradle/tasks/BundleAar.kt#94.
+ variant.packageLibraryProvider.get().from("${topsrcdir}/toolkit/components/telemetry/geckoview/streaming/metrics.yaml")
+}
+
+apply plugin: 'maven-publish'
+
+version = getVersionNumber()
+group = 'org.mozilla.geckoview'
+
+def getArtifactId() {
+ def id = "geckoview" + project.ext.artifactSuffix
+
+ if (!mozconfig.substs.MOZ_ANDROID_GECKOVIEW_LITE) {
+ id += "-omni"
+ }
+
+ if (mozconfig.substs.MOZILLA_OFFICIAL && !mozconfig.substs.MOZ_ANDROID_FAT_AAR_ARCHITECTURES) {
+ // In automation, per-architecture artifacts identify
+ // the architecture; multi-architecture artifacts don't.
+ // When building locally, we produce a "skinny AAR" with
+ // one target architecture masquerading as a "fat AAR"
+ // to enable Gradle composite builds to substitute this
+ // project into consumers easily.
+ id += "-${mozconfig.substs.ANDROID_CPU_ARCH}"
+ }
+
+ return id
+}
+
+publishing {
+ publications {
+ android.libraryVariants.all { variant ->
+ "${variant.name}"(MavenPublication) {
+ from components.findByName(variant.name)
+
+ pom {
+ afterEvaluate {
+ artifactId = getArtifactId()
+ }
+
+ url = 'https://geckoview.dev'
+
+ licenses {
+ license {
+ name = 'The Mozilla Public License, v. 2.0'
+ url = 'http://mozilla.org/MPL/2.0/'
+ distribution = 'repo'
+ }
+ }
+
+ scm {
+ if (mozconfig.substs.MOZ_INCLUDE_SOURCE_INFO) {
+ // URL is like "https://hg.mozilla.org/mozilla-central/rev/1e64b8a0c546a49459d404aaf930d5b1f621246a".
+ connection = "scm::hg::${mozconfig.substs.MOZ_SOURCE_REPO}"
+ url = mozconfig.substs.MOZ_SOURCE_URL
+ tag = mozconfig.substs.MOZ_SOURCE_CHANGESET
+ } else {
+ // Default to mozilla-central.
+ connection = 'scm::hg::https://hg.mozilla.org/mozilla-central/'
+ url = 'https://hg.mozilla.org/mozilla-central/'
+ }
+ }
+ }
+
+ // Javadoc and sources for developer ergononomics.
+ artifact tasks["javadocJar${variant.name.capitalize()}"]
+ artifact tasks["sourcesJar${variant.name.capitalize()}"]
+ }
+ }
+ }
+ repositories {
+ maven {
+ url = "${topobjdir}/gradle/maven"
+ }
+ }
+}
+
+// This is all related to the withGeckoBinaries approach; see
+// mobile/android/gradle/with_gecko_binaries.gradle.
+afterEvaluate {
+ // The bundle tasks are only present when the particular configuration is
+ // being built, so this task might not exist. (This is due to the way the
+ // Android Gradle plugin defines things during configuration.)
+ def bundleWithGeckoBinaries = tasks.findByName('bundleWithGeckoBinariesReleaseAar')
+ if (!bundleWithGeckoBinaries) {
+ return
+ }
+
+ // Remove default configuration, which is the release configuration, when
+ // we're actually building withGeckoBinaries. This makes `gradle install`
+ // install the withGeckoBinaries artifacts, not the release artifacts (which
+ // are withoutGeckoBinaries and not suitable for distribution.)
+ def Configuration archivesConfig = project.getConfigurations().getByName('archives')
+ archivesConfig.artifacts.removeAll { it.extension.equals('aar') }
+
+ // For now, ensure Kotlin is only used in tests.
+ android.sourceSets.all { sourceSet ->
+ if (sourceSet.name.startsWith('test') || sourceSet.name.startsWith('androidTest')) {
+ return
+ }
+ (sourceSet.java.srcDirs + sourceSet.kotlin.srcDirs).each {
+ if (!fileTree(it, { include '**/*.kt' }).empty) {
+ throw new GradleException("Kotlin used in non-test directory ${it.path}")
+ }
+ }
+ }
+}
+
+// Bug 1353055 - Strip 'vars' debugging information to agree with moz.build.
+apply from: "${topsrcdir}/mobile/android/gradle/debug_level.gradle"
+android.libraryVariants.all configureVariantDebugLevel
+
+// There's nothing specific to the :geckoview project here -- this just needs to
+// be somewhere where the Android plugin is available so that we can fish the
+// path to "android.jar".
+task("generateSDKBindings", type: JavaExec) {
+ classpath project(':annotations').jar.archivePath
+ classpath project(':annotations').compileJava.classpath
+ classpath project(':annotations').sourceSets.main.runtimeClasspath
+
+ // To use the lint APIs: "Lint must be invoked with the System property
+ // com.android.tools.lint.bindir pointing to the ANDROID_SDK tools
+ // directory"
+ systemProperties = [
+ 'com.android.tools.lint.bindir': "${android.sdkDirectory}/tools",
+ ]
+
+ mainClass = 'org.mozilla.gecko.annotationProcessors.SDKProcessor'
+ // We only want to generate bindings for the main framework JAR,
+ // but not any of the additional android.test libraries.
+ args android.bootClasspath.findAll { it.getName().startsWith('android.jar') }
+ args 29
+ args "${topobjdir}/widget/android/bindings"
+
+ // Configure the arguments at evaluation-time, not at configuration-time.
+ doFirst {
+ // From -Pgenerate_sdk_bindings_args=... on command line; missing in
+ // `android-gradle-dependencies` toolchain task.
+ if (project.hasProperty('generate_sdk_bindings_args')) {
+ args project.generate_sdk_bindings_args.split(';')
+ }
+ }
+
+ workingDir "${topsrcdir}/widget/android/bindings"
+
+ dependsOn project(':annotations').jar
+}
+
+apply plugin: 'org.mozilla.apilint'
+
+apiLint {
+ // TODO: Change this to `org` after hiding org.mozilla.gecko
+ packageFilter = 'org.mozilla.geckoview'
+ changelogFileName = 'src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md'
+ skipClassesRegex = [
+ '^org.mozilla.geckoview.BuildConfig$',
+ '^org.mozilla.geckoview.R$',
+ ]
+ lintFilters = ['GV']
+ deprecationAnnotation = 'org.mozilla.geckoview.DeprecationSchedule'
+ libraryVersion = mozconfig.substs.MOZILLA_VERSION.split('\\.')[0] as Integer
+ allowedPackages = [
+ 'java',
+ 'android',
+ 'androidx',
+ 'org.json',
+ 'org.mozilla.geckoview',
+ ]
+}
diff --git a/mobile/android/geckoview/checkstyle-suppressions.xml b/mobile/android/geckoview/checkstyle-suppressions.xml
new file mode 100644
index 0000000000..4326882f99
--- /dev/null
+++ b/mobile/android/geckoview/checkstyle-suppressions.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE suppressions PUBLIC
+ "-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
+ "https://checkstyle.org/dtds/suppressions_1_2.dtd">
+
+<suppressions>
+ <suppress id="checkstyle:javadocmethod"
+ files="org[/\\]mozilla[/\\]gecko[/\\]"/>
+</suppressions>
diff --git a/mobile/android/geckoview/checkstyle.xml b/mobile/android/geckoview/checkstyle.xml
new file mode 100644
index 0000000000..d858bf090d
--- /dev/null
+++ b/mobile/android/geckoview/checkstyle.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<!DOCTYPE module PUBLIC
+ "-//Puppy Crawl//DTD Check Configuration 1.2//EN"
+ "http://www.puppycrawl.com/dtds/configuration_1_2.dtd">
+
+<module name="Checker">
+ <property name="charset" value="UTF-8"/>
+ <module name="SuppressionFilter">
+ <property name="file" value="${config_loc}/checkstyle-suppressions.xml"/>
+ <property name="optional" value="false"/>
+ </module>
+
+ <module name="TreeWalker">
+ <module name="FinalParameters">
+ <property name="tokens" value="METHOD_DEF, CTOR_DEF, LITERAL_CATCH, FOR_EACH_CLAUSE"/>
+ </module>
+ <module name="FinalLocalVariableCheck">
+ <property name="tokens" value="VARIABLE_DEF, PARAMETER_DEF"/>
+ <property name="validateEnhancedForLoopVariable" value="true"/>
+ </module>
+ <module name="ParameterName"/>
+ <module name="StaticVariableName"/>
+ <module name="OneStatementPerLine"/>
+ <module name="AvoidStarImport"/>
+ <module name="UnusedImports"/>
+ <module name="SuppressWarningsHolder"/>
+ <module name="JavadocMethod">
+ <property name="id" value="checkstyle:javadocmethod"/>
+ <property name="scope" value="public"/>
+ </module>
+
+ <module name="MemberName">
+ <!-- Private members must use mHungarianNotation -->
+ <property name="format" value="m[A-Z][A-Za-z]*$"/>
+ <property name="applyToPrivate" value="true" />
+ <property name="applyToPublic" value="false" />
+ <property name="applyToPackage" value="false" />
+ <property name="applyToProtected" value="false" />
+ </module>
+
+ <module name="ClassTypeParameterName">
+ <property name="format" value="^[A-Z][A-Za-z]*$"/>
+ </module>
+ <module name="InterfaceTypeParameterName">
+ <property name="format" value="^[A-Z][A-Za-z]*$"/>
+ </module>
+ <module name="LocalVariableName"/>
+
+ <module name="OuterTypeFilename"/>
+ <module name="WhitespaceAfter">
+ <property name="tokens" value="COMMA, SEMI"/>
+ </module>
+ <module name="OneTopLevelClass"/>
+ </module>
+
+ <module name="SuppressWarningsFilter"/>
+</module>
diff --git a/mobile/android/geckoview/proguard-rules.txt b/mobile/android/geckoview/proguard-rules.txt
new file mode 100644
index 0000000000..52c221ca6d
--- /dev/null
+++ b/mobile/android/geckoview/proguard-rules.txt
@@ -0,0 +1,180 @@
+# Modified from https://robotsandpencils.com/blog/use-proguard-android-library/.
+
+# Preserve all annotations.
+
+-keepattributes *Annotation*
+
+# Preserve all .class method names.
+
+-keepclassmembernames class * {
+ java.lang.Class class$(java.lang.String);
+ java.lang.Class class$(java.lang.String, boolean);
+}
+
+# Preserve all native method names and the names of their classes.
+
+-keepclasseswithmembernames,includedescriptorclasses class * {
+ native <methods>;
+}
+
+# Preserve the special static methods that are required in all enumeration
+# classes.
+
+-keepclassmembers class * extends java.lang.Enum {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+# Explicitly preserve all serialization members. The Serializable interface
+# is only a marker interface, so it wouldn't save them.
+# You can comment this out if your library doesn't use serialization.
+# If your code contains serializable classes that have to be backward
+# compatible, please refer to the manual.
+
+-keepclassmembers class * implements java.io.Serializable {
+ static final long serialVersionUID;
+ static final java.io.ObjectStreamField[] serialPersistentFields;
+ private void writeObject(java.io.ObjectOutputStream);
+ private void readObject(java.io.ObjectInputStream);
+ java.lang.Object writeReplace();
+ java.lang.Object readResolve();
+}
+
+# Preserve all View implementations and their special context constructors.
+
+-keep public class * extends android.view.View {
+ public <init>(android.content.Context);
+ public <init>(android.content.Context, android.util.AttributeSet);
+ public <init>(android.content.Context, android.util.AttributeSet, int);
+ public void set*(...);
+}
+
+# Keep setters in Views so that animations can still work.
+# See http://proguard.sourceforge.net/manual/examples.html#beans
+# From tools/proguard/proguard-android.txt.
+-keepclassmembers public class * extends android.view.View {
+ void set*(***);
+ *** get*();
+}
+
+# Preserve all classes that have special context constructors, and the
+# constructors themselves.
+
+-keepclasseswithmembers class * {
+ public <init>(android.content.Context, android.util.AttributeSet);
+}
+
+# Preserve the special fields of all Parcelable implementations.
+
+-keepclassmembers class * implements android.os.Parcelable {
+ static android.os.Parcelable$Creator CREATOR;
+}
+
+# Preserve static fields of inner classes of R classes that might be accessed
+# through introspection.
+
+-keepclassmembers class **.R$* {
+ public static <fields>;
+}
+
+# GeckoView specific rules.
+
+# Keep everthing in org.mozilla.geckoview
+-keep class org.mozilla.geckoview.** { *; }
+
+-keep class org.mozilla.gecko.SysInfo {
+ *;
+}
+
+-keep class org.mozilla.gecko.mozglue.JNIObject {
+ *;
+}
+
+-keep class * extends org.mozilla.gecko.mozglue.JNIObject {
+ *;
+}
+
+# Keep the annotation.
+-keep @interface org.mozilla.gecko.annotation.JNITarget
+
+# Keep classes tagged with the annotation.
+-keep @org.mozilla.gecko.annotation.JNITarget class *
+
+# Keep all members of an annotated class.
+-keepclassmembers @org.mozilla.gecko.annotation.JNITarget class * {
+ *;
+}
+
+# Keep annotated members of any class.
+-keepclassmembers class * {
+ @org.mozilla.gecko.annotation.JNITarget *;
+}
+
+# Keep classes which contain at least one annotated element. Split over two directives
+# because, according to the developer of ProGuard, "the option -keepclasseswithmembers
+# doesn't combine well with the '*' wildcard" (And, indeed, using it causes things to
+# be deleted that we want to keep.)
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.JNITarget <methods>;
+}
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.JNITarget <fields>;
+}
+
+# Keep WebRTC targets.
+-keep @interface org.mozilla.gecko.annotation.WebRTCJNITarget
+-keep @org.mozilla.gecko.annotation.WebRTCJNITarget class *
+-keepclassmembers class * {
+ @org.mozilla.gecko.annotation.WebRTCJNITarget *;
+}
+-keepclassmembers @org.mozilla.gecko.annotation.WebRTCJNITarget class * {
+ *;
+}
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.WebRTCJNITarget <methods>;
+}
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.WebRTCJNITarget <fields>;
+}
+
+# Keep generator-targeted entry points.
+-keep @interface org.mozilla.gecko.annotation.WrapForJNI
+-keep @org.mozilla.gecko.annotation.WrapForJNI class *
+-keepclassmembers,includedescriptorclasses class * {
+ @org.mozilla.gecko.annotation.WrapForJNI *;
+}
+-keepclasseswithmembers,includedescriptorclasses class * {
+ @org.mozilla.gecko.annotation.WrapForJNI <methods>;
+}
+-keepclasseswithmembers,includedescriptorclasses class * {
+ @org.mozilla.gecko.annotation.WrapForJNI <fields>;
+}
+
+# Keep all members of an annotated class.
+-keepclassmembers,includedescriptorclasses @org.mozilla.gecko.annotation.WrapForJNI class * {
+ *;
+}
+
+# Keep Reflection targets.
+-keep @interface org.mozilla.gecko.annotation.ReflectionTarget
+-keep @org.mozilla.gecko.annotation.ReflectionTarget class *
+-keepclassmembers class * {
+ @org.mozilla.gecko.annotation.ReflectionTarget *;
+}
+-keepclassmembers @org.mozilla.gecko.annotation.ReflectionTarget class * {
+ *;
+}
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.ReflectionTarget <methods>;
+}
+-keepclasseswithmembers class * {
+ @org.mozilla.gecko.annotation.ReflectionTarget <fields>;
+}
+
+# Avoid "Warning: org.yaml.snakeyaml.scanner.ScannerImpl: can't find
+# referenced method 'java.nio.ByteBuffer flip()' in library class
+# java.nio.ByteBuffer".
+# Between Java 1.8 and 1.9, the signature of `flip()` changed, which
+# trips up proguard.
+
+-dontwarn org.yaml.snakeyaml.scanner.ScannerImpl
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.mozilla.geckoview.test">
+
+ <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
+ <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
+ <uses-permission android:name="android.permission.ACCESS_MOCK_LOCATION"/>
+ <uses-permission android:name="android.permission.CAMERA"/>
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.RECORD_AUDIO"/>
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
+ <uses-permission android:name="android.permission.WRITE_SETTINGS"/>
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
+
+ <application
+ android:allowBackup="true"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme"
+ android:name="androidx.multidex.MultiDexApplication">
+ <activity android:name=".GeckoViewTestActivity" android:exported="true"/>
+ <!-- This is used for crash handling in GeckoSessionTestRule -->
+ <service
+ android:name=".TestCrashHandler"
+ android:enabled="true"
+ android:exported="false"
+ android:process=":crash">
+ </service>
+
+ <!-- This is needed for ParentCrashTest -->
+ <service
+ android:name=".crash.RuntimeCrashTestService"
+ android:enabled="true"
+ android:exported="false"
+ android:process=":crashtest">
+ </service>
+
+ <!-- Used to run multiple runtimes during tests -->
+ <service android:name=".TestRuntimeService$instance0" android:enabled="true" android:exported="false" android:process=":runtime0" />
+ <service android:name=".TestRuntimeService$instance1" android:enabled="true" android:exported="false" android:process=":runtime1" />
+
+ <service android:name=".TrackingPermissionService" android:enabled="true" android:exported="false" android:process=":tp" />
+
+ <provider android:name="org.mozilla.geckoview.test.TestContentProvider"
+ android:authorities="org.mozilla.geckoview.test.provider"
+ android:grantUriPermissions="true"
+ android:exported="false">
+ </provider>
+ </application>
+</manifest>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png
Binary files 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 @@
+<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 500 500" height="500px" id="Layer_1" version="1.1" viewBox="0 0 500 500" width="500px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M131.889,150.061v63.597h-27.256 c-20.079,0-36.343,16.263-36.343,36.342v181.711c0,20.078,16.264,36.34,36.343,36.34h290.734c20.078,0,36.345-16.262,36.345-36.34 V250c0-20.079-16.267-36.342-36.345-36.342h-27.254v-63.597c0-65.232-52.882-118.111-118.112-118.111 S131.889,84.828,131.889,150.061z M177.317,213.658v-63.597c0-40.157,32.525-72.685,72.683-72.685 c40.158,0,72.685,32.528,72.685,72.685v63.597H177.317z M213.658,313.599c0-20.078,16.263-36.341,36.342-36.341 s36.341,16.263,36.341,36.341c0,12.812-6.634,24.079-16.625,30.529c0,0,3.55,21.446,7.542,46.699 c0,7.538-6.087,13.625-13.629,13.625h-27.258c-7.541,0-13.627-6.087-13.627-13.625l7.542-46.699 C220.294,337.678,213.658,326.41,213.658,313.599z" fill="#010101" fill-rule="evenodd"/></svg> \ 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": ["<all_urls>"],
+ "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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <script
+ type="text/javascript"
+ src="test-open-popup-browser-action.js"
+ ></script>
+ </head>
+ <body>
+ <body style="height: 100%">
+ <p>Hello, world!</p>
+ </body>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <script
+ type="text/javascript"
+ src="test-open-popup-page-action.js"
+ ></script>
+ </head>
+ <body>
+ <body style="height: 100%">
+ <p>Hello, world!</p>
+ </body>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <script src="test-popup-messaging.js"></script>
+ </head>
+ <body>
+ <h1>HELLO</h1>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <script src="test-popup.js"></script>
+ </head>
+ <body>
+ <h1>HELLO</h1>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png
Binary files 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 @@
+<?xml version="1.0" ?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg enable-background="new 0 0 500 500" height="500px" id="Layer_1" version="1.1" viewBox="0 0 500 500" width="500px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><path clip-rule="evenodd" d="M131.889,150.061v63.597h-27.256 c-20.079,0-36.343,16.263-36.343,36.342v181.711c0,20.078,16.264,36.34,36.343,36.34h290.734c20.078,0,36.345-16.262,36.345-36.34 V250c0-20.079-16.267-36.342-36.345-36.342h-27.254v-63.597c0-65.232-52.882-118.111-118.112-118.111 S131.889,84.828,131.889,150.061z M177.317,213.658v-63.597c0-40.157,32.525-72.685,72.683-72.685 c40.158,0,72.685,32.528,72.685,72.685v63.597H177.317z M213.658,313.599c0-20.078,16.263-36.341,36.342-36.341 s36.341,16.263,36.341,36.341c0,12.812-6.634,24.079-16.625,30.529c0,0,3.55,21.446,7.542,46.699 c0,7.538-6.087,13.625-13.629,13.625h-27.258c-7.541,0-13.627-6.087-13.627-13.625l7.542-46.699 C220.294,337.678,213.658,326.41,213.658,313.599z" fill="#010101" fill-rule="evenodd"/></svg> \ 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi
Binary files 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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <h1>Hello World!</h1>
+ <script src="tab-script.js"></script>
+ </body>
+</html>
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", "<all_urls>"]
+}
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <h1>Hello World!</h1>
+ <script src="tab-script.js"></script>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png
Binary files 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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <h1>Hello, World!</h1>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <meta name="viewport" content="initial-scale=1.0" />
+ <script type="text/javascript" src="request-permission.js"></script>
+ </head>
+ <body style="height: 100%">
+ <p>Hello, world!</p>
+ </body>
+</html>
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": ["<all_urls>"],
+ "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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div contenteditable role="combobox" aria-label="ARIA 1.0 combobox"></div>
+ <div role="combobox">
+ <input type="text" aria-label="ARIA 1.1 combobox" />
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <label>
+ <input type="checkbox" aria-describedby="desc" />many option
+ </label>
+ <div id="desc">description</div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <input value="hello cruel world" id="input" />
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <ul>
+ <li>One</li>
+ <li><a href="#">Two</a></li>
+ </ul>
+ <ul>
+ <li>
+ 1
+ <ul>
+ <li>1.1</li>
+ <li>1.2</li>
+ </ul>
+ </li>
+ </ul>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <button
+ onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded') == 'false')"
+ aria-expanded="false"
+ >
+ button
+ </button>
+ </body>
+</html>
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 @@
+<!DOCTYPE html><html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a href=\"%23\">preamble</a>
+ <h1>Fried cheese</h1><p>with club sauce.</p>
+ <a href="#"><h2>Popcorn shrimp</h2></a><button>with club sauce.</button>
+ <h3>Chicken fingers</h3><p>with spicy club sauce.</p>
+</body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <a href="/">a with href</a>
+ <a>a with no attributes</a>
+ <a name="anchor">a with name</a>
+ <a onclick=";">a with onclick</a>
+ <span role="link">span with role link</span>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div aria-live="polite" aria-atomic="true" id="container">
+ The time is
+ <p>3pm</p>
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div aria-live="polite"><p id="to_show">I will be shown</p></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <img
+ src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
+ aria-live="polite"
+ aria-labelledby="l1"
+ />
+ <span id="l1">Hello</span>
+ <span id="l2">Goodbye</span>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div aria-live="polite" aria-atomic="true">
+ This picture is
+ <img
+ src="data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
+ alt="happy"
+ />
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div id="to_change" aria-live="polite"></div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <style>
+ body {
+ margin: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ iframe {
+ height: 100%;
+ }
+ </style>
+ </head>
+ <body>
+ Some stuff
+ <iframe src="../hello.html" id="iframe"></iframe>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <p>Hello <a href="foo">sweet</a>, sweet <span>world</span></p>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <div><p id="to_show">I will be shown</p></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <input type="range" aria-label="Rating" min="1" max="10" value="4" /><input
+ type="range"
+ aria-label="Stars"
+ min="1"
+ max="5"
+ step="0.5"
+ value="4.5"
+ /><input
+ type="range"
+ aria-label="Percent"
+ min="0"
+ max="1"
+ step="0.01"
+ value="0.83"
+ />
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <style>
+ body {
+ margin: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ iframe {
+ height: 100%;
+ }
+ </style>
+ </head>
+ <body>
+ Some stuff
+ <iframe
+ src="https://example.org/tests/junit/hello.html"
+ id="iframe"
+ ></iframe>
+ </body>
+</html>
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 @@
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width initial-scale=1" />
+<body style="margin: 0">
+ <div style="height: 100vh"></div>
+ <button>Hello</button>
+ <p style="margin: 0">
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+ tempor incididunt ut labore et dolore magna aliqua.
+ </p>
+</body>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <ul style="list-style-type: none" role="listbox">
+ <li
+ id="li"
+ role="option"
+ onclick="this.setAttribute('aria-selected',
+ this.getAttribute('aria-selected') == 'true' ? 'false' : 'true')"
+ >
+ 1
+ </li>
+ <li role="option" aria-selected="false">2</li>
+ </ul>
+ <li id="outsideSelectable" role="option" tabindex="0">
+ outside selectable
+ </li>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <input aria-label="Name" aria-describedby="desc" value="Tobias" />
+ <div id="desc">description</div>
+ <input aria-label="Last" value="Funke" required />
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <label for="name">Name:</label
+ ><input id="name" type="text" value="Julie" /><button>Submit</button>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Address form</title>
+ </head>
+ <body>
+ <form>
+ <input autocomplete="name" id="name" />
+ <input autocomplete="given-name" id="givenName" />
+ <input autocomplete="additional-name" id="additionalName" />
+ <input autocomplete="family-name" id="familyName" />
+ <input autocomplete="street-address" id="streetAddress" />
+ <input autocomplete="country" id="country" />
+ <input autocomplete="postal-code" id="postalCode" />
+ <input autocomplete="organization" id="organization" />
+ <input autocomplete="email" id="email" />
+ <input autocomplete="tel" id="tel" />
+ <input type="submit" value="Submit" />
+ </form>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3
Binary files 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 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>WEBM Video</title>
+ </head>
+ <body>
+ <video preload autoplay>
+ <source src="videos/gizmo.webm"></source>
+ </video>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Bad Video Path</title>
+ </head>
+ <body>
+ <video controls preload>
+ <source src="videos/fileDoesNotExist.ogg"></source>
+ </video>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body onbeforeunload="return beforeUnload()">
+ <a id="navigateAway" href="./hello.html">Click Me</a>
+ <a id="navigateAway2" href="./hello2.html">Click Me</a>
+ <script>
+ function beforeUnload() {
+ return "Please don't leave.";
+ }
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Form Autofill Test: Credit Card</title>
+ </head>
+ <body>
+ <form id="form1">
+ <input autocomplete="cc-name" id="name" />
+ <input autocomplete="cc-number" id="number" />
+ <input autocomplete="cc-exp-month" id="expMonth" />
+ <input autocomplete="cc-exp-year" id="expYear" />
+ <input type="submit" value="Submit" />
+ </form>
+ <!-- form2 uses a single expiration date field -->
+ <form id="form2">
+ <input autocomplete="cc-name" id="name" />
+ <input autocomplete="cc-number" id="number" />
+ <input autocomplete="cc-exp" id="exp" />
+ <input type="submit" value="Submit" />
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <meta name="viewport" content="initial-scale=1.0" />
+ </head>
+ <body style="height: 100%" onclick="window.location.reload()">
+ <p>Hello, world!</p>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <meta name="viewport" content="initial-scale=1.0" />
+ </head>
+ <body style="height: 100%">
+ <p>Hello, world!</p>
+ <script>
+ document.body.addEventListener("click", () => {
+ navigator.clipboard
+ .readText()
+ .then(() => {
+ window.alert("allow");
+ })
+ .catch(() => {
+ window.alert("deny");
+ });
+ });
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Color Grid</title>
+ </head>
+ <style>
+ body {
+ margin: 0;
+ }
+ .container {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ }
+ .box {
+ height: 100vh;
+ width: 33.33vw;
+ }
+ .red {
+ background-color: rgb(255, 0, 0);
+ color-adjust: exact;
+ }
+ .green {
+ background-color: rgb(0, 255, 0);
+ color-adjust: exact;
+ }
+ .blue {
+ background-color: rgb(0, 0, 255);
+ color-adjust: exact;
+ }
+ </style>
+
+ <body>
+ <div class="container">
+ <div class="red box"></div>
+ <div class="green box"></div>
+ <div class="blue box"></div>
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Orange Print Background</title>
+ </head>
+ <style>
+ .box {
+ height: 100vh;
+ width: 100vw;
+ }
+ @media screen {
+ .background {
+ background-color: rgb(0, 0, 255);
+ color-adjust: exact;
+ }
+ }
+ @media print {
+ .background {
+ background-color: rgb(255, 113, 57);
+ color-adjust: exact;
+ }
+ }
+ </style>
+
+ <body>
+ <div class="box background"></div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Colours</title>
+ </head>
+ <!-- background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. !-->
+ <body
+ style="
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ background: url('/assets/www/transparent.gif'),
+ linear-gradient(135deg, red, white);
+ "
+ ></body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Audio</title>
+ </head>
+ <style>
+ body {
+ margin: 0;
+ }
+ </style>
+ <body>
+ <div class="center-audio">
+ <audio controls src="audio/owl.mp3">
+ Your browser does not support the
+ <code>audio</code> element.
+ </audio>
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Blob Buffered</title>
+ </head>
+ <body>
+ <video id="video" controls preload></video>
+ </body>
+ <script>
+ window.addEventListener("DOMContentLoaded", function (e) {
+ const video = document.getElementById("video");
+ const mediaSource = new MediaSource();
+ video.src = URL.createObjectURL(mediaSource);
+ mediaSource.addEventListener("sourceopen", createBuffer);
+
+ function createBuffer(event) {
+ const mediaSource = event.target;
+ const assetURL = "/assets/www/videos/gizmo.webm";
+ const codec = 'video/webm; codecs="opus"';
+ const sourceBuffer = mediaSource.addSourceBuffer(codec);
+
+ function addBuffer(response) {
+ sourceBuffer.addEventListener("updateend", function () {
+ mediaSource.endOfStream();
+ });
+ sourceBuffer.appendBuffer(response);
+ }
+
+ fetchVideoData(assetURL, addBuffer);
+ }
+
+ function fetchVideoData(assetURL, videoArrayBuffer) {
+ const request = new XMLHttpRequest();
+ request.open("get", assetURL);
+ request.responseType = "arraybuffer";
+ request.onload = function () {
+ videoArrayBuffer(request.response);
+ };
+ request.send();
+ }
+ });
+ </script>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Blob</title>
+ </head>
+ <body>
+ <div id="image_container"></div>
+ </body>
+ <script>
+ window.addEventListener("DOMContentLoaded", function (e) {
+ const svg = `<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
+ <circle cx="50" cy="50" r="50" stroke="orange" fill="transparent" stroke-width="5"/>
+ </svg>`;
+ const image = document.createElement("img");
+ const blob = new Blob([svg], { type: "image/svg+xml" });
+ image.src = URL.createObjectURL(blob);
+ image.alt = "An orange circle.";
+ document.getElementById("image_container").appendChild(image);
+ });
+ </script>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Image</title>
+ </head>
+ <body>
+ <img id="image" src="images/test.gif" alt="Test Image" />
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Nested Image</title>
+ </head>
+ <body>
+ <div>
+ <div>
+ <img id="image" src="images/test.gif" alt="Test Image" />
+ </div>
+ </div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Context Menu Test Link</title>
+ </head>
+ <style>
+ #hello {
+ font-size: 20vw;
+ }
+ </style>
+ <body>
+ <a href="hello.html" title="Hello Link Title" id="hello">Hello World</a>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height">
+ <title>Context Menu Test Video</title>
+ </head>
+ <body>
+ <video controls preload>
+ <source src="videos/short.mp4"></source>
+ </video>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Link with a giant data URI</title>
+ </head>
+ <body>
+ <a href="insert uri here" id="smallLink">Open small link</a>
+ <a href="insert uri here" id="largeLink">Open large link</a>
+ <img src="/assets/www/images/test.gif" id="image" />
+ <script language="JavaScript">
+ var imageLoaded = false;
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <script>
+ const blob = new Blob(["Downloaded Data"], { type: "text/plain" });
+ const element = document.createElement("a");
+ const uri = URL.createObjectURL(blob);
+ element.href = uri;
+ element.download = "download.txt";
+ element.style.display = "none";
+ document.body.appendChild(element);
+ element.click();
+ URL.revokeObjectURL(uri);
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>Fixed bottom element</title>
+ </head>
+ <!-- background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. !-->
+ <body
+ style="
+ overflow: hidden;
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ background: url('/assets/www/transparent.gif'),
+ linear-gradient(135deg, blue, blue);
+ "
+ >
+ <div
+ id="bottom-banner"
+ style="
+ width: 100%;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ background-color: lime;
+ height: 10%;
+ "
+ ></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, minimum-scale=0.5" />
+<style>
+ html {
+ width: 100%;
+ height: 100%;
+ scrollbar-width: none;
+ }
+ body {
+ width: 200%;
+ height: 2000px;
+ margin: 0;
+ padding: 0;
+ }
+
+ #fixed-element {
+ width: 100%;
+ height: 200%;
+ position: fixed;
+ top: 0px;
+ background-color: green;
+ }
+</style>
+<div id="fixed-element"></div>
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 @@
+<!DOCTYPE html>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, minimum-scale=0.5" />
+<style>
+ html {
+ width: 100%;
+ height: 100%;
+ scrollbar-width: none;
+ }
+ body {
+ width: 200%;
+ height: 2000px;
+ margin: 0;
+ padding: 0;
+ }
+
+ #fixed-element {
+ width: 100%;
+ height: 200vh;
+ position: fixed;
+ top: 0px;
+ background-color: green;
+ }
+</style>
+<div id="fixed-element"></div>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form
+ id="form1"
+ name="form1"
+ action="form_blank.html"
+ method="get"
+ target="_blank"
+ >
+ <input type="text" id="search" value="foo" />
+ <input type="submit" />
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" />
+ <input type="email" id="email1" value="@" />
+ <input type="number" id="number1" value="0" />
+ <input type="tel" id="tel1" value="0" />
+ <input type="submit" value="submit" />
+ </form>
+ <input type="Text" id="user2" value="foo" />
+ <input type="PassWord" id="pass2" maxlength="8" value="foo" />
+ <input type="button" id="button1" value="foo" />
+ <input type="checkbox" id="checkbox1" />
+ <input type="search" id="search1" />
+ <input type="url" id="url1" />
+ <input type="hidden" id="hidden1" value="foo" />
+
+ <iframe id="iframe"></iframe>
+ </body>
+ <script>
+ addEventListener("load", function (e) {
+ if (window.parent === window) {
+ document.getElementById("iframe").contentWindow.location.href =
+ window.location.href;
+ }
+ });
+ </script>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms2</title>
+ </head>
+ <body>
+ <form>
+ <fieldset>
+ <input type="text" id="firstname" />
+ <input type="text" id="lastname" />
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" autofocus />
+ </fieldset>
+ </form>
+ <iframe id="iframe" src="forms2_iframe.html"></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms2 iframe</title>
+ </head>
+ <body>
+ <form>
+ <fieldset>
+ <input type="text" id="firstname" />
+ <input type="text" id="lastname" />
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" autofocus />
+ </fieldset>
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" placeholder="username" />
+ <input type="password" id="pass1" placeholder="password" />
+ <input type="submit" value="submit" />
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" />
+ <input type="password" id="pass1" autocomplete="new-password" />
+ <input type="submit" value="submit" />
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" />
+ <input type="email" id="email1" value="@" />
+ <input type="number" id="number1" value="0" />
+ <input type="tel" id="tel1" value="0" />
+ <input type="submit" value="submit" />
+ </form>
+ <input type="Text" id="user2" value="foo" />
+ <input type="PassWord" id="pass2" maxlength="8" value="foo" />
+ <input type="button" id="button1" value="foo" />
+ <input type="checkbox" id="checkbox1" />
+ <input type="search" id="search1" />
+ <input type="url" id="url1" />
+ <input type="hidden" id="hidden1" value="foo" />
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form>
+ <input type="text" autocomplete="email" autofocus />
+ <input type="text" autocomplete="username" />
+ <input type="password" />
+ <input type="submit" value="submit" />
+ </form>
+ <iframe id="iframe" src="forms_autocomplete_iframe.html"></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form>
+ <input type="text" autocomplete="email" autofocus />
+ <input type="text" autocomplete="username" />
+ <input type="password" />
+ <input type="submit" value="submit" />
+ </form>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms ID Value</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="password" id="value" />
+ </form>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms iframe</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" />
+ <input type="email" id="email1" value="@" />
+ <input type="number" id="number1" value="0" />
+ <input type="tel" id="tel1" value="0" />
+ <input type="submit" value="submit" />
+ </form>
+ <input type="Text" id="user2" value="foo" />
+ <input type="PassWord" id="pass2" maxlength="8" value="foo" />
+ <input type="button" id="button1" value="foo" />
+ <input type="checkbox" id="checkbox1" />
+ <input type="search" id="search1" />
+ <input type="url" id="url1" />
+ <input type="hidden" id="hidden1" value="foo" />
+ </body>
+ <script>
+ const params = new URL(document.location).searchParams;
+
+ function getEventInterface(event) {
+ if (event instanceof document.defaultView.InputEvent) {
+ return "InputEvent";
+ }
+ if (event instanceof document.defaultView.UIEvent) {
+ return "UIEvent";
+ }
+ if (event instanceof document.defaultView.Event) {
+ return "Event";
+ }
+ return "Unknown";
+ }
+
+ function getData(key, value) {
+ return new Promise(resolve =>
+ document.querySelector(key).addEventListener(
+ "input",
+ event => {
+ resolve([key, event.target.value, value, getEventInterface(event)]);
+ },
+ { once: true }
+ )
+ );
+ }
+
+ window.addEventListener("message", async event => {
+ const { data, source, origin } = event;
+ source.postMessage(await getData(data.key, data.value), origin);
+ });
+ </script>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Forms</title>
+ <meta name="viewport" content="minimum-scale=1,width=device-width" />
+ </head>
+ <body>
+ <form id="form1">
+ <input type="text" id="user1" value="foo" />
+ <input type="password" id="pass1" value="foo" />
+ <input type="email" id="email1" value="@" />
+ <input type="number" id="number1" value="0" />
+ <input type="tel" id="tel1" value="0" />
+ <input type="submit" value="submit" />
+ </form>
+ <input type="Text" id="user2" value="foo" />
+ <input type="PassWord" id="pass2" maxlength="8" value="foo" />
+ <input type="button" id="button1" value="foo" />
+ <input type="checkbox" id="checkbox1" />
+ <input type="search" id="search1" />
+ <input type="url" id="url1" />
+ <input type="hidden" id="hidden1" value="foo" />
+
+ <iframe
+ id="iframe"
+ src="http://example.org/tests/junit/forms_iframe.html"
+ ></iframe>
+ </body>
+ <script>
+ const params = new URL(document.location).searchParams;
+ const iframe = document.getElementById("iframe").contentWindow;
+
+ function getEventInterface(event) {
+ if (event instanceof document.defaultView.InputEvent) {
+ return "InputEvent";
+ }
+ if (event instanceof document.defaultView.UIEvent) {
+ return "UIEvent";
+ }
+ if (event instanceof document.defaultView.Event) {
+ return "Event";
+ }
+ return "Unknown";
+ }
+
+ function getData(key, value) {
+ return new Promise(resolve =>
+ document.querySelector(key).addEventListener(
+ "input",
+ event => {
+ resolve([key, event.target.value, value, getEventInterface(event)]);
+ },
+ { once: true }
+ )
+ );
+ }
+
+ window.getDataForAllFrames = function (key, value) {
+ const data = [];
+ data.push(
+ new Promise(resolve =>
+ window.addEventListener(
+ "message",
+ event => {
+ resolve(event.data);
+ },
+ { once: true }
+ )
+ )
+ );
+ iframe.postMessage({ key, value }, "*");
+ data.push(getData(key, value));
+ return Promise.all(data);
+ };
+ </script>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Fullscreen</title>
+ </head>
+ <body>
+ <div id="fullscreen">Fullscreen Div</div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>GetUserMedia from cross-origin iframe: the container document</title>
+ </head>
+ <body>
+ <iframe
+ id="iframe_no_allow"
+ src="http://127.0.0.1:4245/assets/www/getusermedia_xorigin_iframe.html"
+ ></iframe>
+ <iframe
+ id="iframe"
+ allow="camera;microphone"
+ src="http://127.0.0.1:4245/assets/www/getusermedia_xorigin_iframe.html"
+ ></iframe>
+ <script>
+ "use strict";
+
+ let iframeWindow;
+ let generation = 1;
+
+ async function Send(obj) {
+ obj.gen = generation++;
+ iframeWindow.postMessage(obj, "http://127.0.0.1:4245");
+ while (true) {
+ const {
+ data: { gen, result },
+ } = await new Promise(r => (window.onmessage = r));
+ if (gen == obj.gen) {
+ return result;
+ }
+ }
+ }
+
+ function Start(constraints) {
+ if (iframeWindow) {
+ return "iframe mode already decided";
+ }
+ iframeWindow = document.getElementById("iframe").contentWindow;
+ return Send({ name: "start", constraints });
+ }
+
+ function StartNoAllow(constraints) {
+ if (iframeWindow) {
+ return "iframe mode already decided";
+ }
+ iframeWindow = document.getElementById("iframe_no_allow").contentWindow;
+ return Send({ name: "start", constraints });
+ }
+
+ async function Stop() {
+ const result = await Send({ name: "stop" });
+ iframeWindow = undefined;
+ return result;
+ }
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>GetUserMedia from cross-origin iframe: the iframe document</title>
+ </head>
+ <body>
+ <script>
+ "use strict";
+
+ let stream;
+ window.addEventListener(
+ "message",
+ async ({ data: { name, gen, constraints } }) => {
+ if (name == "start") {
+ try {
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
+ Send({ gen, result: "ok" });
+ } catch (e) {
+ Send({ gen, result: `${e}` });
+ }
+ } else if (name == "stop") {
+ const result = !!stream;
+ if (stream) {
+ for (const t of stream.getTracks()) {
+ t.stop();
+ }
+ stream = undefined;
+ }
+ Send({ gen, result });
+ }
+ }
+ );
+
+ function Send(obj) {
+ window.parent.postMessage(obj, "http://localhost:4245");
+ }
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <link rel="manifest" href="manifest.webmanifest" />
+ </head>
+ <body>
+ <p>Hello, world!</p>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world! Again!</title>
+ </head>
+ <body>
+ <p>Hello, world! Again!</p>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf
Binary files 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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hung Script</title>
+ </head>
+ <body>
+ <div id="content"></div>
+ </body>
+ <script>
+ var start = new Date().getTime();
+ document.getElementById("content").innerHTML = "Started";
+ // eslint-disable-next-line no-empty
+ while (new Date().getTime() - start < 5000) {}
+ document.getElementById("content").innerHTML = "Finished";
+ </script>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ iframe {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ border: none;
+ display: block;
+ }
+ </style>
+ <iframe
+ frameborder="0"
+ srcdoc="<!DOCTYPE HTML>
+ <html>
+ <style>
+ html, body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style='width: 100%; height: 100%; background-color: green;'></div>
+ <script>
+ if (parent.document.location.search.startsWith('?event')) {
+ document.querySelector('div').addEventListener('touchstart', e => {
+ if (parent.document.location.search == '?event-prevent') {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+ </html>"
+ >
+ </iframe>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ iframe {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ border: none;
+ display: block;
+ }
+ </style>
+ <iframe
+ frameborder="0"
+ srcdoc="<!DOCTYPE HTML>
+ <html>
+ <style>
+ html, body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style='width: 100%; height: 500vh; background-color: green;'></div>
+ <script>
+ if (parent.document.location.search.startsWith('?event')) {
+ document.querySelector('div').addEventListener('touchstart', e => {
+ if (parent.document.location.search == '?event-prevent') {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+ </html>"
+ >
+ </iframe>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html,
+ body {
+ margin: 0px;
+ padding: 0px;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ iframe {
+ margin: 0px;
+ padding: 0px;
+ height: 98vh;
+ width: 100%;
+ border: none;
+ display: block;
+ }
+ </style>
+ <iframe
+ frameborder="0"
+ srcdoc="<!DOCTYPE HTML>
+ <html>
+ <style>
+ html, body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style='width: 100%; height: 100%; background-color: green;'></div>
+ <script>
+ if (parent.document.location.search.startsWith('?event')) {
+ document.querySelector('div').addEventListener('touchstart', e => {
+ if (parent.document.location.search == '?event-prevent') {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+ </html>"
+ >
+ </iframe>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html,
+ body {
+ margin: 0px;
+ padding: 0px;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ iframe {
+ margin: 0px;
+ padding: 0px;
+ height: 98vh;
+ width: 100%;
+ border: none;
+ display: block;
+ }
+ </style>
+ <iframe
+ frameborder="0"
+ srcdoc="<!DOCTYPE HTML>
+ <html>
+ <style>
+ html, body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style='width: 100%; height: 500vh; background-color: green;'></div>
+ <script>
+ if (parent.document.location.search.startsWith('?event')) {
+ document.querySelector('div').addEventListener('touchstart', e => {
+ if (parent.document.location.search == '?event-prevent') {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+ </html>"
+ >
+ </iframe>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <p>Hello, world! From Top Level.</p>
+ <iframe src="hello.html"></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ </head>
+ <body>
+ Some stuff
+ <iframe
+ src="http://expired.example.com/"
+ width="100%"
+ height="100%"
+ ></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ </head>
+ <body>
+ Some stuff
+ <iframe
+ src="http://example.org/tests/junit/simple_redirect.sjs?http://example.org"
+ ></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ </head>
+ <body>
+ Some stuff
+ <iframe src="http://jigsaw.w3.org/HTTP/300/301.html"></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <p>Hello, world! From Top Level.</p>
+ <iframe src="foo://bar"></iframe>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/images/test.gif
Binary files 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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Inputs</title>
+ <script>
+ class CustomTextBox extends HTMLElement {
+ constructor() {
+ super();
+
+ this.attachShadow({ mode: "open" });
+ const wrapper = document.createElement("span");
+ this.textbox = wrapper.appendChild(document.createElement("input"));
+ this.textbox.value = "adipisci";
+ this.shadowRoot.append(wrapper);
+ }
+
+ focus() {
+ this.textbox.focus();
+ }
+
+ select() {
+ this.textbox.select();
+ }
+
+ setSelectionRange(start, end) {
+ this.textbox.setSelectionRange(start, end);
+ }
+
+ get selectionStart() {
+ return this.textbox.selectionStart;
+ }
+
+ get selectionEnd() {
+ return this.textbox.selectionEnd;
+ }
+
+ get value() {
+ return this.textbox.value;
+ }
+ }
+ customElements.define("x-input", CustomTextBox);
+ </script>
+ </head>
+ <body>
+ <div id="text">lorem</div>
+ <input type="text" id="input" value="ipsum" />
+ <textarea id="textarea">dolor</textarea>
+ <div id="contenteditable" contenteditable="true">sit</div>
+ <iframe id="iframe" src="selectionAction_frame.html"></iframe>
+ <iframe id="designmode" src="selectionAction_frame.html"></iframe>
+ <iframe
+ id="iframe-xorigin"
+ src="http://127.0.0.1:4245/assets/www/selectionAction_frame_xorigin.html"
+ ></iframe>
+ <x-input id="x-input"></x-input>
+ </body>
+ <script>
+ addEventListener("load", function () {
+ document.getElementById("iframe").contentDocument.body.textContent =
+ "amet";
+ var designmode = document.getElementById("designmode");
+ designmode.contentDocument.body.textContent = "consectetur";
+ designmode.contentDocument.designMode = "on";
+ });
+ </script>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Links</title>
+ <style>
+ :link {
+ color: rgb(0, 0, 255);
+ }
+
+ :visited {
+ color: rgb(255, 0, 0);
+ }
+ </style>
+ </head>
+ <body>
+ <ul>
+ <li><a id="mozilla" href="https://mozilla.org">Mozilla</a></li>
+ <li><a id="firefox" href="https://getfirefox.com">Get Firefox!</a></li>
+ <li><a id="bugzilla" href="https://bugzilla.mozilla.org">Bugzilla</a></li>
+ <li>
+ <a id="testpilot" href="https://testpilot.firefox.com">Test Pilot</a>
+ </li>
+ <li>
+ <a id="fxa" href="https://accounts.firefox.com">Firefox Accounts</a>
+ </li>
+ </ul>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Lorem ipsum</title>
+ </head>
+ <body>
+ <p style="font-family: monospace; width: 20ch">
+ 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
+ <a href="#">anim id</a> est laborum.
+ </p>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <title>MediaSessionDefaultTest1</title>
+ </head>
+ <body>
+ <script>
+ const audio1 = document.createElement("audio");
+ audio1.src = "audio/owl.mp3";
+
+ window.onload = () => {
+ audio1.play();
+ };
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <title>MediaSessionDOMTest1</title>
+ </head>
+ <body>
+ <script>
+ function updatePositionState(event) {
+ if (event.target != active) {
+ return;
+ }
+ navigator.mediaSession.setPositionState({
+ duration: parseFloat(event.target.duration),
+ position: parseFloat(event.target.currentTime),
+ playbackRate: 1,
+ });
+ }
+
+ function updateMetadata() {
+ navigator.mediaSession.metadata = active.metadata;
+ }
+
+ function getTrack(offset) {
+ console.log("" + active.id + " " + offset);
+ const nextId = Math.min(
+ tracks.length - 1,
+ Math.max(0, parseInt(active.id) + offset)
+ );
+ return tracks[nextId];
+ }
+
+ navigator.mediaSession.setActionHandler("play", async () => {
+ updateMetadata();
+ await active.play();
+ });
+
+ navigator.mediaSession.setActionHandler("pause", () => {
+ active.pause();
+ });
+
+ navigator.mediaSession.setActionHandler("previoustrack", () => {
+ active = getTrack(-1);
+ });
+
+ navigator.mediaSession.setActionHandler("nexttrack", () => {
+ active = getTrack(1);
+ });
+
+ const audio1 = document.createElement("audio");
+ audio1.src = "audio/owl.mp3";
+ audio1.addEventListener("timeupdate", updatePositionState);
+ audio1.metadata = new window.MediaMetadata({
+ title: "hoot",
+ artist: "owl",
+ album: "hoots",
+ artwork: [
+ {
+ src: "images/test.gif",
+ type: "image/gif",
+ sizes: "265x199",
+ },
+ ],
+ });
+ audio1.id = 0;
+
+ const audio2 = document.createElement("audio");
+ audio2.src = "audio/owl.mp3";
+ audio2.addEventListener("timeupdate", updatePositionState);
+ audio2.metadata = new window.MediaMetadata({
+ title: "hoot2",
+ artist: "stillowl",
+ album: "dahoots",
+ artwork: [
+ {
+ src: "images/test.gif",
+ type: "image/gif",
+ sizes: "265x199",
+ },
+ ],
+ });
+ audio2.id = 1;
+
+ const audio3 = document.createElement("audio");
+ audio3.src = "audio/owl.mp3";
+ audio3.addEventListener("timeupdate", updatePositionState);
+ audio3.metadata = new window.MediaMetadata({
+ title: "hoot3",
+ artist: "immaowl",
+ album: "mahoots",
+ artwork: [
+ {
+ src: "images/test.gif",
+ type: "image/gif",
+ sizes: "265x199",
+ },
+ ],
+ });
+ audio3.id = 2;
+
+ const tracks = [audio1, audio2, audio3];
+ let active = audio1;
+
+ window.onload = async () => {
+ active = getTrack(0);
+ updateMetadata();
+ await active.play();
+ };
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>MetaTags</title>
+ <meta property="twitter:description" content="twitter:description" />
+ <meta property="og:description" content="og:description" />
+ <meta name="description" content="description" />
+ <meta name="unknown:tag" content="unknown:tag" />
+ <meta property="og:image" content="https://test.com/og-image.jpg" />
+ <meta
+ property="twitter:image"
+ content="https://test.com/twitter-image.jpg"
+ />
+ <meta property="og:image:url" content="https://test.com/og-image-url" />
+ <meta name="thumbnail" content="https://test.com/thumbnail.jpg" />
+ </head>
+ <body></body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <meta name="viewport" content="initial-scale=1.0" />
+ </head>
+ <body style="height: 100%" onmousemove="window.location.reload()">
+ <p>Hello, world!</p>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>MP4 Video</title>
+ </head>
+ <body>
+ <video controls preload>
+ <source src="videos/short.mp4"></source>
+ </video>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <a
+ id="targetBlankLink"
+ target="_blank"
+ rel="opener"
+ href="newSession_child.html"
+ >target="_blank"</a
+ >
+ <a
+ id="noOpenerLink"
+ target="_blank"
+ rel="noopener"
+ href="http://example.com"
+ >rel="noopener"</a
+ >
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <p>I'm the child</p>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <h3>Nothing here</h3>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>OGG Video</title>
+ </head>
+ <body>
+ <video controls preload>
+ <source src="videos/video.ogg"></source>
+ </video>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ overscroll-behavior: auto none;
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 100vh"></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ overscroll-behavior: auto;
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 100vh"></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ overscroll-behavior: none auto;
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 100vh"></div>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div
+ id="scroll"
+ style="
+ width: 100%;
+ height: 100vh;
+ overscroll-behavior: none;
+ overflow-y: scroll;
+ "
+ >
+ <div style="height: 200vh"></div>
+ </div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ </head>
+ <body>
+ <p>Launching popup...</p>
+ <script>
+ window.open("hello.html");
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Orange Print Background Removal</title>
+ </head>
+ <style>
+ .box {
+ height: 200vh;
+ width: 100vw;
+ }
+ @media screen {
+ .background {
+ background-color: rgb(0, 0, 255);
+ color-adjust: exact;
+ }
+ }
+ @media print {
+ .background {
+ background-color: rgb(255, 113, 57);
+ color-adjust: exact;
+ }
+ }
+ </style>
+
+ <body>
+ <div id="content" class="box background"></div>
+ </body>
+
+ <!-- The window.print should freeze the page before removing the content, so the background should remain present. -->
+ <button
+ id="print-button"
+ onclick="window.print(); document.getElementById('content').remove()"
+ >
+ Print
+ </button>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" content="width=device-width, height=device-height" />
+ <title>Print iframes</title>
+ </head>
+ <style>
+ .box {
+ height: 200vh;
+ width: 100vw;
+ }
+ @media screen {
+ .background {
+ background-color: rgb(255, 0, 0);
+ color-adjust: exact;
+ }
+ }
+ @media print {
+ .background {
+ background-color: rgb(0, 255, 0);
+ color-adjust: exact;
+ }
+ }
+ </style>
+
+ <body>
+ <div id="content" class="box background"></div>
+ </body>
+
+ <!-- The window.print should freeze the page before removing the content, so the background should remain present. -->
+ <button
+ id="print-button-page"
+ onclick="window.print(); document.getElementById('content').remove()"
+ >
+ Print
+ </button>
+
+ <iframe id="iframe" src="print_content_change.html"></iframe>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <select id="selectexample">
+ <option>1</option>
+ <option>2</option>
+ </select>
+
+ <input type="date" id="dateexample" />
+
+ <input type="month" id="monthexample" />
+
+ <input type="week" id="weekexample" />
+
+ <input type="time" id="timeexample" />
+
+ <input type="datetime-local" id="datetimelocalexample" />
+
+ <input type="color" id="colorexample" value="#ffffff" />
+
+ <input type="file" id="fileexample" accept="image/*,.pdf" capture="user" />
+
+ <datalist id="colorlist">
+ <option>#000000</option>
+ <option>#808080</option>
+ </datalist>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Push API test</title>
+ </head>
+ <body>
+ <p>Hello, world!</p>
+ <script src="push.js"></script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="content-type" content="text/html; charset=windows-1252" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <style>
+ html {
+ scrollbar-width: none;
+ }
+ body {
+ background: red;
+ margin: 0;
+ }
+ .tall {
+ height: 600vh;
+ background: green;
+ }
+ </style>
+ </head>
+ <body>
+ <div class="tall"></div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>no title</title>
+ <script>
+ // If we have a query string, save it to the local storage.
+ if (window.location.search.length) {
+ const value = window.location.search.substr(1);
+ localStorage.setItem("ctx", value);
+ }
+
+ // Set the title to reflect the local storage value.
+ document.title = "storage=" + localStorage.getItem("ctx");
+ </script>
+ </head>
+ <body></body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body>
+ <form action="hello.html" method="post">
+ <input id="text" />
+ <button id="submit">Submit</button>
+ </form>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ height: 100%;
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 100%; background-color: green"></div>
+ <script>
+ if (document.location.search.startsWith("?event")) {
+ document.querySelector("div").addEventListener("touchstart", e => {
+ if (document.location.search == "?event-prevent") {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 100vh; background-color: green"></div>
+ <script>
+ if (document.location.search.startsWith("?event")) {
+ document.querySelector("div").addEventListener("touchstart", e => {
+ if (document.location.search == "?event-prevent") {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 98vh; background-color: green"></div>
+ <script>
+ if (document.location.search.startsWith("?event")) {
+ document.querySelector("div").addEventListener("touchstart", e => {
+ if (document.location.search == "?event-prevent") {
+ e.preventDefault();
+ }
+ });
+ }
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Hello, world!</title>
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
+ <style>
+ p {
+ height: 300vh;
+ }
+ </style>
+ </head>
+ <body>
+ <form id="form">
+ <input type="text" id="name" />
+ </form>
+ <p>Hello, world!</p>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ overscroll-behavior: auto;
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ #scroll {
+ /* set a different overscroll-behavior to make this container different from
+ the root scrollElement. */
+ overscroll-behavior: contain auto;
+ }
+ </style>
+ <body>
+ <div id="scroll" style="width: 100%; height: 100vh; overflow-y: scroll">
+ <div style="height: 200vh"></div>
+ </div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=0.5" />
+ <style type="text/css">
+ body {
+ margin: 0;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+
+ #one {
+ background-color: red;
+ width: 200vw;
+ height: 33vh;
+ }
+
+ #two {
+ background-color: green;
+ width: 200vw;
+ height: 33vh;
+ }
+
+ #three {
+ background-color: blue;
+ width: 200vw;
+ height: 33vh;
+ }
+
+ #four {
+ background-color: purple;
+ width: 200vw;
+ height: 200vh;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="one"></div>
+ <div id="two"></div>
+ <div id="three"></div>
+ <div id="four"></div>
+ <script>
+ document.getElementById("two").addEventListener("touchstart", e => {
+ console.log("preventing default");
+ e.preventDefault();
+ });
+
+ document.getElementById("three").addEventListener("touchstart", e => {
+ console.log("not preventing default");
+ });
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<select size="2" id="multiple">
+ <option>ABC</option>
+ <option>DEF</option>
+ <option>GHI</option>
+</select>
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 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<select multiple id="multiple">
+ <option>ABC</option>
+ <option>DEF</option>
+ <option>GHI</option>
+</select>
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 @@
+<!DOCTYPE html>
+<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+<select id="simple">
+ <option>ABC</option>
+ <option>DEF</option>
+</select>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <body></body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <script>
+ window.addEventListener("message", e => {
+ switch (e.data.type) {
+ case "focus":
+ window.focus();
+ break;
+
+ case "select": {
+ const text = document.body.firstChild;
+ document
+ .getSelection()
+ .setBaseAndExtent(text, 0, text, e.data.length);
+ break;
+ }
+
+ case "selectedOffset": {
+ const sel = document.getSelection();
+ const text = document.body.firstChild;
+ if (sel.anchorNode !== text || sel.focusNode !== text) {
+ window.parent.postMessage([-1, -1], "*");
+ } else {
+ window.parent.postMessage(
+ [sel.anchorOffset, sel.focusOffset],
+ "*"
+ );
+ }
+ break;
+ }
+
+ case "content":
+ window.parent.postMessage(document.body.textContent, "*");
+ break;
+ }
+ });
+ </script>
+ </head>
+ <body onload="document.body.textContent = 'elit'"></body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>showDynamicToolbar test content</title>
+ <script>
+ document.addEventListener("click", function () {
+ document.body.style.position = "fixed";
+ });
+ </script>
+ </head>
+ <body>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ <p>Paragraph</p>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ </head>
+ <header><title>Title1</title></header>
+ <body>
+ <script>
+ addEventListener("load", function () {
+ setTimeout(function () {
+ document.title = "Title2";
+ }, 100);
+ });
+ </script>
+ </body>
+ <iframe src="hello.html"></iframe>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div id="one" style="width: 100%; height: 100vh; touch-action: none"></div>
+ <script>
+ document.getElementById("one").addEventListener("wheel", e => {
+ console.log("preventing default");
+ e.preventDefault();
+ });
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, user-scalable=no" />
+ <style>
+ html {
+ height: 100%;
+ width: 100%;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+ body {
+ width: 100%;
+ margin: 0px;
+ padding: 0px;
+ }
+ </style>
+ <body>
+ <div style="width: 100%; height: 50vh; touch-action: auto"></div>
+ <script>
+ const searchParams = new URLSearchParams(location.search);
+ let div = document.querySelector("div");
+ if (searchParams.has("subframe")) {
+ const scrolledContents = document.createElement("div");
+ scrolledContents.style.height = "100%";
+
+ div.appendChild(scrolledContents);
+ div.style.overflow = "auto";
+
+ div = scrolledContents;
+ }
+ if (searchParams.has("scrollable")) {
+ // Scrollable for dynamic toolbar purposes.
+ div.style.height = "100vh";
+ }
+ div.style.touchAction = searchParams.get("touch-action");
+ if (searchParams.has("event")) {
+ div.addEventListener("touchstart", e => {});
+ }
+ </script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta
+ name="viewport"
+ content="height=device-height,width=device-width,initial-scale=1.0"
+ />
+ <style type="text/css">
+ body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0px;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+
+ #one {
+ background-color: red;
+ width: 100%;
+ height: 33%;
+ }
+
+ #two {
+ background-color: green;
+ width: 100%;
+ height: 33%;
+ }
+
+ #three {
+ background-color: blue;
+ width: 100%;
+ height: 33%;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="one"></div>
+ <div id="two"></div>
+ <div id="three"></div>
+ <script>
+ document.getElementById("two").addEventListener("touchstart", e => {
+ console.log("preventing default");
+ e.preventDefault();
+ });
+
+ document.getElementById("three").addEventListener("touchstart", e => {
+ console.log("not preventing default");
+ });
+ </script>
+ </body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <style>
+ body,
+ html {
+ margin: 0;
+ padding: 0;
+ }
+ </style>
+ </head>
+ <body>
+ <iframe src="http://127.0.0.1:4245/assets/www/touch.html"></iframe>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=0.5" />
+ <style type="text/css">
+ body {
+ margin: 0;
+ /* background contains one extra transparent.gif because we want trick the
+ contentful paint detection; We want to make sure the background is loaded
+ before the test starts so we always wait for the contentful paint timestamp
+ to exist, however, gradient isn't considered as contentful per spec, so Gecko
+ wouldn't generate a timestamp for it. Hence, we added a transparent gif
+ to the image list to trick the detection. */
+ background: url("/assets/www/transparent.gif"),
+ linear-gradient(135deg, red, white);
+ }
+
+ #one {
+ background-color: red;
+ width: 200%;
+ height: 100vh;
+ }
+ #two {
+ background-color: blue;
+ width: 200%;
+ height: 800vh;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="one"></div>
+ <div id="two"></div>
+ <script>
+ document.getElementById("two").addEventListener("touchstart", e => {});
+ </script>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf
Binary files 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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Trackers</title>
+ </head>
+ <body>
+ <p>Trackers</p>
+
+ <!-- test-track-simple -->
+ <script src="http://trackertest.org/tracker.js"></script>
+ <script src="https://tracking.example.com/tracker.js"></script>
+ <script src="https://itisatracker.org/tracker.js"></script>
+ </body>
+</html>
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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg
Binary files 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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta
+ name="viewport"
+ content="width=device-width, initial-scale=1.0, viewport-fit=cover"
+ />
+ <style type="text/css">
+ #wide {
+ background-color: rgb(200, 0, 0);
+ width: 100%;
+ height: 40px;
+ }
+ </style>
+ </head>
+ <body>
+ <div id="wide"></div>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>WebM Video</title>
+ </head>
+ <body>
+ <video controls preload>
+ <source src="videos/gizmo.webm"></source>
+ </video>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Open Window test</title>
+ </head>
+ <body>
+ <p>Hello, world!</p>
+ <script type="text/javascript" src="./open_window.js"></script>
+ </body>
+</html>
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 @@
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <title>Open Window test target</title>
+ </head>
+ <body>
+ <p>Hello, world!</p>
+ </body>
+</html>
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<Void> 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<String> lines, List<Character> threadNames, int dataLength) {
+ HashMap<Character, Integer> 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<Character> threadNames = Arrays.asList('A', 'B');
+ final int writeCount = 1000;
+ final CompletableFuture<Void> 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<String> 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<AccessibilityNodeInfo>()
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<String>() }) {
+ 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<Void>()
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ return null
+ }
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ 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<Void>()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ 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<Void>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ 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<Void>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ sessionRule.delegateDuringNextWait(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>>? {
+ 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<CreditCardSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Array<CreditCard>>? {
+ return GeckoResult.fromValue(savedCC)
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onCreditCardSelect(session: GeckoSession, prompt: AutocompleteRequest<CreditCardSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled(count = 1)
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ 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<Address>, 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<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ 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<AddressSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Address>(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>(
+ 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<Address>(savedAddress)
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onAddressFetch(): GeckoResult<Array<Address>>? {
+ return GeckoResult.fromValue(savedAddresses.toTypedArray())
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onAddressSelect(session: GeckoSession, prompt: AutocompleteRequest<AddressSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Array<LoginEntry>>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+
+ val user1 = "user1x"
+ val pass1 = "pass1x"
+ val pass2 = "pass1up"
+ val guid = "test-guid"
+ val savedLogins = mutableListOf<LoginEntry>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+
+ 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<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ 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<Void>()
+
+ 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<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ 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<Array<CreditCard>>? {
+ 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<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ 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<Void>()
+
+ 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<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ 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<CreditCard>()
+
+ mainSession.loadTestPath(CC_FORM_HTML_PATH)
+ mainSession.waitForPageStop()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onCreditCardFetch(): GeckoResult<Array<CreditCard>> {
+ 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<CreditCardSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse> {
+ 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<Void>()
+
+ 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<LoginEntry>(savedLogin)
+
+ if (autofillEnabled) {
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ 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<Array<LoginEntry>>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginEntry>(savedLogin)
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginEntry>()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+ val usedHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ var logins = mutableListOf<LoginEntry>()
+
+ 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<Void>()
+
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginEntry>()
+
+ val saveHandled1 = GeckoResult<Void>()
+ val saveHandled2 = GeckoResult<Void>()
+ val selectHandled = GeckoResult<Void>()
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ assertThat("Domain should match", domain, equalTo("localhost"))
+
+ var logins = mutableListOf<LoginEntry>()
+
+ 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<Void>()
+
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+ val selectHandled = GeckoResult<Void>()
+ var numSelects = 0
+
+ sessionRule.delegateUntilTestEnd(object : StorageDelegate {
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<LoginEntry>>? {
+ 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<LoginSelectOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<LoginSaveOption>,
+ ): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Array<LoginEntry>>? {
+ return GeckoResult.fromValue(savedLogins)
+ }
+ })
+
+ val result = GeckoResult<PromptDelegate.PromptResponse>()
+ val promptInstanceDelegate = object : PromptDelegate.PromptInstanceDelegate {
+ override fun onPromptDismiss(prompt: PromptDelegate.BasePrompt) {
+ result.complete(prompt.dismiss())
+ }
+ }
+
+ val promptHandled = GeckoResult<Void>()
+ mainSession.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled
+ override fun onLoginSelect(session: GeckoSession, prompt: AutocompleteRequest<LoginSelectOption>): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Array<out Any>> = 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<CharSequence>()
+
+ // 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<String>()
+
+ 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<CharSequence>()
+ 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 <T> assertThat(reason: String, v: T, m: Matcher<in T>) = sessionRule.checkThat(reason, v, m)
+ fun <T> assertInAutomationThat(reason: String, v: T, m: Matcher<in T>) =
+ if (sessionRule.env.isAutomation) {
+ assertThat(reason, v, m)
+ } else {
+ assumeThat(reason, v, m)
+ }
+
+ init {
+ if (!noErrorCollector) {
+ sessionRule.errorCollector = errors
+ }
+ }
+
+ fun <T> 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<V> JSONObject.asMap(): Map<String?, V?> {
+ val result = HashMap<String?, V?>()
+ for (key in this.keys()) {
+ result[key] = this[key] as V
+ }
+ return result
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ fun<T> Any?.asJSList(): List<T> {
+ val array = this.asJsonArray()
+ val result = ArrayList<T>()
+
+ 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<Void>()
+ val newSessionCrash = GeckoResult<Void>()
+
+ // ...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<Void>()
+ val newSessionKilled = GeckoResult<Void>()
+
+ 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<AllowOrDeny>? {
+ return null
+ }
+
+ @AssertCalled(false)
+ override fun onNewSession(session: GeckoSession, uri: String): GeckoResult<GeckoSession>? {
+ 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<GeckoSession>? {
+ 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<Void>()
+ 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<Void>()
+ 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<SlowScriptResponse> {
+ 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<SlowScriptResponse> {
+ 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<SlowScriptResponse> {
+ 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<SlowScriptResponse> {
+ 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<Bitmap>, 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 <body> and an element which covers up the <body> 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<WebExtension.Action>()
+ val pageActionDefaultResult = GeckoResult<WebExtension.Action>()
+
+ val windowPortResult = GeckoResult<WebExtension.Port>()
+ val backgroundPortResult = GeckoResult<WebExtension.Port>()
+
+ 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<Void>()
+
+ 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<Void>()
+
+ 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<Void>()
+
+ 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<Void>()
+
+ // 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<Void>()
+
+ 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<Void>()
+
+ 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<Void>()
+ val png38 = GeckoResult<Void>()
+ val png19 = GeckoResult<Void>()
+ val png10 = GeckoResult<Void>()
+
+ 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<Void>()
+
+ 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<Void>()
+ 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<Void>()
+ mainSession.webExtensionController.setActionDelegate(
+ extension!!,
+ object : WebExtension.ActionDelegate {
+ override fun onOpenPopup(
+ extension: WebExtension,
+ popupAction: WebExtension.Action,
+ ): GeckoResult<GeckoSession>? {
+ 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<Void>()
+
+ 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<WebExtension.Action>()
+
+ testActionApi(
+ """{
+ "action": "setPopup",
+ "popup": "test-popup.html"
+ }""",
+ ) { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+
+ actionResult.complete(action)
+ }
+
+ val togglePopup = GeckoResult<Void>()
+ val action = sessionRule.waitForResult(actionResult)
+
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onTogglePopup(
+ extension: WebExtension,
+ popupAction: WebExtension.Action,
+ ): GeckoResult<GeckoSession>? {
+ 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<Void>()
+ 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<WebExtension.Action>()
+ 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<String>()
+ val messageResult = GeckoResult<List<String>>()
+ val portResult = GeckoResult<WebExtension.Port>()
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<GeckoSession>? {
+ 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<String>()
+
+ 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<Void>()
+ 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<WebExtension.Action>()
+ testActionApi(
+ """{
+ "action": "setPopup",
+ "popup": "test-popup.html"
+ }""",
+ ) { action ->
+ assertEquals(action.title, "Test action default")
+ assertEquals(action.enabled, true)
+ actionResult.complete(action)
+ }
+
+ val togglePopup = GeckoResult<Void>()
+ val action = sessionRule.waitForResult(actionResult)
+ extension!!.setActionDelegate(object : WebExtension.ActionDelegate {
+ override fun onTogglePopup(
+ extension: WebExtension,
+ popupAction: WebExtension.Action,
+ ): GeckoResult<GeckoSession>? {
+ 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<GeckoSession.PermissionDelegate.ContentPermission>,
+ ) {
+ // 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<Integer> 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<List<Integer>> 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<List<Integer>> result = GeckoResult.allOf();
+
+ result.accept(
+ value -> {
+ assertThat("Value should match", value.isEmpty(), is(true));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfNull() {
+ final GeckoResult<List<Integer>> result = GeckoResult.allOf((List<GeckoResult<Integer>>) null);
+
+ result.accept(
+ value -> {
+ assertThat("Value should match", value, equalTo(null));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @Test
+ @UiThreadTest
+ public void allOfMany() {
+ final GeckoResult<Integer> pending1 = new GeckoResult<>();
+ final GeckoResult<Integer> pending2 = new GeckoResult<>();
+
+ final GeckoResult<List<Integer>> 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<Integer> deferred = new GeckoResult<>();
+ deferred.complete(42);
+ deferred.complete(43);
+ }
+
+ @Test(expected = IllegalStateException.class)
+ @UiThreadTest
+ public void completeMultipleExceptions() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ deferred.completeExceptionally(new Exception("boom"));
+ deferred.completeExceptionally(new Exception("boom again"));
+ }
+
+ @Test(expected = IllegalStateException.class)
+ @UiThreadTest
+ public void completeMixed() {
+ final GeckoResult<Integer> deferred = new GeckoResult<>();
+ deferred.complete(42);
+ deferred.completeExceptionally(new Exception("boom again"));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ @UiThreadTest
+ public void completeExceptionallyNull() {
+ new GeckoResult<Integer>().completeExceptionally(null);
+ }
+
+ @Test
+ @UiThreadTest
+ public void completeThreaded() {
+ final GeckoResult<Integer> 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<Integer> 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<Integer> 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<Integer> 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<Handler> 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<Integer> 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<Void>().poll(1);
+ }
+
+ @UiThreadTest
+ @Test(expected = TimeoutException.class)
+ public void pollTimeoutWithLooper() throws Throwable {
+ new GeckoResult<Void>().poll(1);
+ }
+
+ @UiThreadTest
+ @Test(expected = IllegalThreadStateException.class)
+ public void pollWithLooper() throws Throwable {
+ new GeckoResult<Void>().poll();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelNoDelegate() {
+ final GeckoResult<Void> result = new GeckoResult<Void>();
+ result
+ .cancel()
+ .accept(
+ value -> {
+ assertThat("Cancellation should fail", value, equalTo(false));
+ done();
+ });
+ waitUntilDone();
+ }
+
+ private GeckoResult<Integer> createCancellableResult() {
+ final GeckoResult<Integer> result = new GeckoResult<>();
+ result.setCancellationDelegate(
+ new GeckoResult.CancellationDelegate() {
+ @Override
+ public GeckoResult<Boolean> cancel() {
+ return GeckoResult.fromValue(true);
+ }
+ });
+
+ return result;
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelSuccess() {
+ final GeckoResult<Integer> 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<Integer> 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<Integer> result = createCancellableResult();
+ final GeckoResult<Integer> 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<Integer> result =
+ new GeckoResult<Integer>()
+ .then(value -> createCancellableResult())
+ .then(value -> new GeckoResult<Integer>());
+
+ result
+ .cancel()
+ .accept(
+ value -> {
+ assertThat("Cancel should fail", value, equalTo(false));
+ done();
+ });
+
+ waitUntilDone();
+ }
+
+ @UiThreadTest
+ @Test
+ public void cancelChildParentComplete() {
+ final GeckoResult<Integer> result =
+ GeckoResult.fromValue(42)
+ .then(value -> createCancellableResult())
+ .then(value -> new GeckoResult<Integer>());
+
+ 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<Integer>) 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 <T> GeckoResult<T>.pollDefault(): T? =
+ this.poll(env.defaultTimeoutMillis)
+
+class GeckoResultTestKotlin {
+ class MockException : RuntimeException()
+
+ @Test fun pollIncompleteWithValue() {
+ val result = GeckoResult<Int>()
+ 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<Void>()
+
+ 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<String>? {
+ 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<PermissionDelegate.ContentPermission>) {
+ }
+ })
+ }
+
+ @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<PromptDelegate.PromptResponse> {
+ // Return a GeckoResult that we will never complete, so it hangs.
+ val res = GeckoResult<PromptDelegate.PromptResponse>()
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse> {
+ 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<Int>() {
+ 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<Int>() {
+ 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<GeckoSession>, low: List<GeckoSession>) {
+ 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<Int>): List<Int> {
+ 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<AutofillValue>()
+ 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<String>()
+
+ 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<MockViewStructure?>(0, { null })
+ var childIndex = 0
+ var hints: Array<out String>? = 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<out String>?) {
+ 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<out CharSequence>?) {}
+ 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<Pair<String, String>>? {
+ 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<Int> {
+ return GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ }
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ 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<Boolean>? {
+ 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<String>,
+ ): GeckoResult<BooleanArray>? {
+ 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<Int>?,
+ val heights: Array<Int>?,
+)
+
+@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<TestImage>): 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<InputResultDetail> {
+ 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<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ 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<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback,
+ ) {
+ callback.grant()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice>,
+ ) {
+ 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<JSONObject>()
+ 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<JSONObject>()
+
+ 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<JSONObject>()
+ 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<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ 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<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback,
+ ) {
+ callback.grant()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 1)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<MediaDelegate.RecordingDevice>,
+ ) {
+ 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<out GeckoSession.PermissionDelegate.MediaSource>?,
+ audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback,
+ ) {
+ callback.reject()
+ }
+
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ callback: GeckoSession.PermissionDelegate.Callback,
+ ) {
+ callback.reject()
+ }
+ })
+
+ mainSession.delegateDuringNextWait(object : MediaDelegate {
+ @GeckoSessionTestRule.AssertCalled(count = 0)
+ override fun onRecordingStatusChanged(
+ session: GeckoSession,
+ devices: Array<org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice>,
+ ) {}
+ })
+
+ 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<JSONObject>()
+ 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<JSONObject>()
+ 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<Void>())
+ val onMetadataCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+ val onPlayCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+ val onPauseCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+
+ // 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<Void>())
+ val onPlayCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+ val onPauseCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+
+ // 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<Void>()),
+ arrayOf(GeckoResult<Void>()),
+ )
+ val onMetadataCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ )
+ val onPlayCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ )
+ val onPauseCalled = arrayOf(
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ ),
+ )
+
+ // 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<Void>()
+ val onPlayCalled = GeckoResult<Void>()
+ val onPauseCalled = GeckoResult<Void>()
+ val onFullscreenCalled = arrayOf(
+ GeckoResult<Void>(),
+ GeckoResult<Void>(),
+ )
+
+ // 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<Void>()
+ 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<Void>()
+ 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<String, String> 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<String, String> 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<String, String> 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<String> 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<String> 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<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ assertThat(map.size(), is(2));
+
+ final List<String> 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<String, String> 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<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ final Set<String> 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<String, String> 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<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+ map.add("test", "value2");
+ map.add("test2", "value3");
+
+ final Map<String, List<String>> 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<String, String> map = new MultiMap<>();
+ map.add("test", "value1");
+
+ assertThat(map.get("test").size(), is(1));
+
+ // Existing key test
+ final List<String> 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<String> 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<String>? {
+ 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<String>? {
+ 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<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ 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<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ 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<String>? {
+ 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<String>? {
+ return null
+ }
+
+ override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<AllowOrDeny>? {
+ // 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<String>? {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<String>? {
+ 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<String>? {
+ 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<String>? {
+ 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<String>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<GeckoSession>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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 = "<html><head><title>TheTitle</title></head><body>TheBody</body></html>"
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<AllowOrDeny>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<GeckoSession>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<AllowOrDeny>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<GeckoSession>? {
+ return null
+ }
+ })
+
+ mainSession.goForward()
+ sessionRule.waitForPageStop()
+
+ sessionRule.forCallbacksDuringWait(object : NavigationDelegate {
+ @AssertCalled(count = 0, order = [1])
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<GeckoSession>? {
+ return null
+ }
+ })
+ }
+
+ @Test fun onLoadUri_returnTrueCancelsLoad() {
+ sessionRule.delegateDuringNextWait(object : NavigationDelegate {
+ @AssertCalled(count = 2)
+ override fun onLoadRequest(
+ session: GeckoSession,
+ request: LoadRequest,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<GeckoSession> {
+ 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<AllowOrDeny>? {
+ // 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<String>? {
+ return null
+ }
+ })
+ }
+
+ private fun loadUriHeaderTest(
+ headers: Map<String?, String?>,
+ additional: Map<String?, String?>,
+ 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<String>()
+
+ 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<String>()
+
+ assertThat(
+ "Headers should match",
+ expected as Map<String?, String?>,
+ 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<GeckoSession> {
+ 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<AllowOrDeny> {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ currentUrl = url
+ }
+
+ override fun onLoadError(session: GeckoSession, uri: String?, error: WebRequestError): GeckoResult<String>? {
+ 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<AllowOrDeny>? {
+ assertThat(
+ "Load should not be direct",
+ request.isDirectNavigation,
+ equalTo(false),
+ )
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<AllowOrDeny>? {
+ return null
+ }
+
+ @AssertCalled(count = 1)
+ override fun onLocationChange(
+ session: GeckoSession,
+ url: String?,
+ perms: MutableList<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<AllowOrDeny>? {
+ 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<String>? {
+ 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<AllowOrDeny>? {
+ 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<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(false)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ 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<AllowOrDeny>? {
+ return GeckoResult.allow()
+ }
+
+ @AssertCalled(false)
+ override fun onLoadError(
+ session: GeckoSession,
+ uri: String?,
+ error: WebRequestError,
+ ): GeckoResult<String>? {
+ 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<AllowOrDeny>? {
+ 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<PermissionDelegate.ContentPermission>,
+ ) {
+ 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<Int>? {
+ 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<Void>()
+ 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<PermissionDelegate.ContentPermission>) {
+ // 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<PermissionDelegate.ContentPermission>) {
+ // 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<GeckoSession> {
+ ThreadUtils.assertOnUiThread()
+ return GeckoResult.fromValue(null)
+ }
+ })
+ }
+
+ @Test
+ fun openWindowSameSession() {
+ sessionRule.delegateUntilTestEnd(object : ServiceWorkerDelegate {
+ @AssertCalled(count = 1)
+ override fun onOpenWindow(url: String): GeckoResult<GeckoSession> {
+ 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<PermissionDelegate.ContentPermission>) {
+ 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<GeckoSession> {
+ 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<PermissionDelegate.ContentPermission>) {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<Int> {
+ 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<Int> {
+ 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<Bitmap>? {
+ val bitmaps: ArrayList<Bitmap> = 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<out MediaSource>?,
+ audio: Array<out MediaSource>?,
+ 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<out MediaSource>?,
+ audio: Array<out MediaSource>?,
+ 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<Int> {
+ 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<out String>?,
+ 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<ContentPermission>) {
+ 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<Int> {
+ return GeckoResult.fromValue(ContentPermission.VALUE_DENY)
+ }
+
+ @AssertCalled(count = 0)
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array<out String>?,
+ 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<ContentPermission>) {
+ 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<ContentPermission>,
+ ) {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<ContentPermission>) {
+ 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<Int> {
+ 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<Int> {
+ 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<Int> {
+ 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<Int> {
+ val pixelResult = GeckoResult<Int>()
+ // 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<String>(), arrayOf<String>())
+ 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<ContentPermission>) {
+ 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<ContentPermission>,
+ ) {
+ 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<ContentPermission>) {
+ 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<ContentPermission>) {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<AllowOrDeny>? {
+ 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<GeckoSession>? {
+ 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<PromptDelegate.PromptResponse> {
+ 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<Array<Autocomplete.LoginEntry>>? {
+ 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<PromptResponse>? {
+ 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<PromptDelegate.BasePrompt>()
+ 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<PromptResponse>? {
+ return GeckoResult.fromValue(prompt.confirm("foo", "bar"))
+ }
+
+ @AssertCalled
+ override fun onLoginFetch(domain: String): GeckoResult<Array<Autocomplete.LoginEntry>>? {
+ return GeckoResult.fromValue(arrayOf())
+ }
+
+ @AssertCalled
+ override fun onLoginSave(
+ session: GeckoSession,
+ request: PromptDelegate.AutocompleteRequest<Autocomplete.LoginSaveOption>,
+ ): GeckoResult<PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ // 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse> {
+ 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<Void>()
+ 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<PromptDelegate.PromptResponse>()
+ val promptResult2 = GeckoResult<PromptDelegate.PromptResponse>()
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onRepostConfirmPrompt(session: GeckoSession, prompt: PromptDelegate.RepostConfirmPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ // 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onChoicePrompt(session: GeckoSession, prompt: PromptDelegate.ChoicePrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>()
+ 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<PromptDelegate.PromptResponse>? {
+ 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 =
+ "<option>foo</option><option>bar</option><option>baz</option>";
+ }, 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<PromptDelegate.PromptResponse>()
+ 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<PromptDelegate.PromptResponse>? {
+ 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<Void>()
+ 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<PromptDelegate.PromptResponse>()
+ val promptResult2 = GeckoResult<PromptDelegate.PromptResponse>()
+
+ sessionRule.delegateUntilTestEnd(object : PromptDelegate {
+ @AssertCalled(count = 2)
+ override fun onBeforeUnloadPrompt(session: GeckoSession, prompt: PromptDelegate.BeforeUnloadPrompt): GeckoResult<PromptDelegate.PromptResponse>? {
+ // 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> 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 <input type=month>. 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<PromptDelegate.PromptResponse> {
+ assertThat("<input type=month> 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<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> 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<PromptDelegate.PromptResponse>()
+ 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<PromptDelegate.PromptResponse> {
+ assertThat("<input type=date> 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<PromptDelegate.PromptResponse>()
+ 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<PromptDelegate.PromptResponse> {
+ assertThat("<input type=month> 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<PromptDelegate.PromptResponse> {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<PromptDelegate.PromptResponse>? {
+ 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<String>? {
+ 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<Bitmap>, 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<GeckoResult<Bitmap>>()
+ 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<Throwable> { 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<Array<out Any>> = 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", "<bold>text</bold>") {
+ 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", "<bold>pasted</bold>") {
+ 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<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny> {
+ 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<PromptDelegate.PromptResponse> {
+ 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<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate, PromptDelegate {
+ @AssertCalled(count = 1, order = [1])
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ 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<PromptDelegate.PromptResponse> {
+ 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<Void>()
+ mainSession.delegateDuringNextWait(object : SelectionActionDelegate {
+ @AssertCalled(count = 1)
+ override fun onShowClipboardPermissionRequest(
+ session: GeckoSession,
+ perm: ClipboardPermission,
+ ):
+ GeckoResult<AllowOrDeny>? {
+ 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<Int, Int>
+ }
+
+ /** 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<Int, Int> 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<Int, Int> 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<Int, Int> 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<Int, Int> 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<out String>,
+ ) = { 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<String>) = { _: 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<GeckoSession> {
+ return QueuedWeakReference<GeckoSession>(GeckoSession())
+ }
+
+ waitUntilCollected(createSession())
+ }
+
+ @Test fun collectAfterClose() {
+ fun createSession(): QueuedWeakReference<GeckoSession> {
+ val s = GeckoSession()
+ s.open(sessionRule.runtime)
+ s.close()
+ return QueuedWeakReference<GeckoSession>(s)
+ }
+
+ waitUntilCollected(createSession())
+ }
+
+ @Test fun collectOpen() {
+ fun createSession(): QueuedWeakReference<GeckoSession> {
+ val s = GeckoSession()
+ s.open(sessionRule.runtime)
+ return QueuedWeakReference<GeckoSession>(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<T> @JvmOverloads constructor(
+ obj: T,
+ var queue: ReferenceQueue<T> = ReferenceQueue(),
+ ) : WeakReference<T>(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<Long>(401, 12, 1, 109, 2000)
+ val receivedHistograms = mutableListOf<Long>()
+ val histogram = GeckoResult<Void>()
+ val stringScalar = GeckoResult<Void>()
+ val booleanScalar = GeckoResult<Void>()
+ val longScalar = GeckoResult<Void>()
+
+ 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<String>) {
+ 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<Boolean>) {
+ 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<Long>) {
+ 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<GeckoBundle> 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<GeckoBundle> 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.
+ *
+ * <p>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<T> {
+ public boolean isConnected = false;
+ public GeckoResult<Void> disconnected = new GeckoResult<>();
+ public GeckoResult<Void> started = new GeckoResult<>();
+ public GeckoResult<Void> quitted = new GeckoResult<>();
+ public final Context context;
+ public final Class<T> service;
+
+ private final File mProfileFolder;
+ private final GeckoBundle mTestData;
+ private final ClientHandler mClientHandler = new ClientHandler();
+ private Messenger mMessenger;
+ private Messenger mServiceMessenger;
+ private GeckoResult<Void> mPageStop = null;
+
+ private Map<String, GeckoResult<GeckoBundle>> mPendingMessages = new HashMap<>();
+
+ protected RuntimeInstance(
+ final Context context, final Class<T> service, final File profileFolder) {
+ this(context, service, profileFolder, null);
+ }
+
+ protected RuntimeInstance(
+ final Context context,
+ final Class<T> service,
+ final File profileFolder,
+ final GeckoBundle testData) {
+ this.context = context;
+ this.service = service;
+ mProfileFolder = profileFolder;
+ mTestData = testData;
+ }
+
+ public static <T> RuntimeInstance<T> start(
+ final Context context, final Class<T> service, final File profileFolder) {
+ RuntimeInstance<T> 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<GeckoBundle> sendMessageInternal(
+ final int message, final GeckoBundle bundle, final GeckoResult<GeckoBundle> 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<Void> waitForPageStop() {
+ if (mPageStop == null) {
+ mPageStop = new GeckoResult<>();
+ }
+ return mPageStop;
+ }
+
+ protected GeckoResult<GeckoBundle> query(final int message) {
+ return query(message, null);
+ }
+
+ protected GeckoResult<GeckoBundle> query(final int message, final GeckoBundle bundle) {
+ final GeckoResult<GeckoBundle> 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<Void> 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<Void> 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<Void> 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<Void> 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<Array<out Any>> = 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<Int, Int>
+ 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<Boolean>() {
+ 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<ContentPermission> perms) {
+ for (ContentPermission perm : perms) {
+ if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) {
+ mContentPermission = perm;
+ }
+ }
+ }
+ });
+ }
+
+ @Override
+ protected GeckoResult<GeckoBundle> 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<TrackingPermissionService> {
+ 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<Integer> 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<Bitmap>, 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<Array<out Any>> = 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<Void>()
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<WebExtension>): Map<String, WebExtension> {
+ val map = HashMap<String, WebExtension>()
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<Void>()
+
+ 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<Any>? {
+ 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<Void>()
+ var tabsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ 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<Void>()
+ var tabsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ 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<Void>()
+ val tabsExtension = sessionRule.waitForResult(
+ controller.installBuiltIn(TABS_CREATE_REMOVE_BACKGROUND),
+ )
+
+ tabsExtension.tabDelegate = object : WebExtension.TabDelegate {
+ override fun onNewTab(source: WebExtension, details: WebExtension.CreateTabDetails): GeckoResult<GeckoSession> {
+ val extensionCreatedSession = sessionRule.createClosedSession(mainSession.settings)
+
+ extensionCreatedSession.webExtensionController.setTabDelegate(
+ tabsExtension,
+ object : WebExtension.SessionTabDelegate {
+ override fun onCloseTab(source: WebExtension?, session: GeckoSession): GeckoResult<AllowOrDeny> {
+ 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<Void>()
+ 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<AllowOrDeny> {
+ 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<JSONObject> {
+ 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<JSONObject> {
+ val uuid = UUID.randomUUID().toString()
+ json.put("uuid", uuid)
+ port.postMessage(json)
+
+ val response = GeckoResult<JSONObject>()
+ 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<WebExtension.Port>()
+ 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<WebExtension.BrowsingDataDelegate.Settings>? {
+ 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<Void>? {
+ 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<Void>? {
+ 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<Void>? {
+ 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<Void>? {
+ 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<Void>? {
+ 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<Void>? {
+ assertThat(
+ "timestamp should match",
+ sinceUnixTimestamp,
+ equalTo(0L),
+ )
+ return null
+ }
+
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ 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<Void>? {
+ assertThat(
+ "timestamp should match",
+ sinceUnixTimestamp,
+ equalTo(0L),
+ )
+ return null
+ }
+
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ 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<Void>? {
+ assertThat(
+ "timestamp should match",
+ sinceUnixTimestamp,
+ equalTo(0L),
+ )
+ return GeckoResult.fromException(RuntimeException("Not authorized passwords."))
+ }
+
+ @AssertCalled
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void>? {
+ 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<WebExtension.BrowsingDataDelegate.Settings>? {
+ 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<AllowOrDeny> {
+ return GeckoResult.allow()
+ }
+ })
+
+ val extension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/browsing-data.xpi"),
+ )
+
+ val accumulator = mutableListOf<String>()
+ val result = GeckoResult<List<String>>()
+
+ 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<Void> {
+ register("downloads", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null)
+ }
+
+ override fun onClearFormData(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("formData", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null)
+ }
+
+ override fun onClearHistory(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ register("history", sinceUnixTimestamp)
+ return GeckoResult.fromValue(null)
+ }
+
+ override fun onClearPasswords(sinceUnixTimestamp: Long): GeckoResult<Void> {
+ 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<Void>()
+
+ sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
+ @AssertCalled
+ override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<Void>()
+
+ class TabDelegate(
+ val result: GeckoResult<Void>,
+ val extension: WebExtension,
+ val expectedSession: GeckoSession,
+ ) :
+ WebExtension.SessionTabDelegate {
+ override fun onCloseTab(
+ source: WebExtension?,
+ session: GeckoSession,
+ ): GeckoResult<AllowOrDeny> {
+ 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<Void>()
+
+ 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<AllowOrDeny> {
+ 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<String>()
+ session.webExtensionController.setMessageDelegate(
+ extension,
+ object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<String>()
+ val portResult = GeckoResult<WebExtension.Port>()
+ session.webExtensionController.setMessageDelegate(
+ extension,
+ object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<String>()
+ 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<Void>()
+ 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<AllowOrDeny> {
+ 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<Void>()
+ 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<Any>? {
+ // 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<Void>()
+
+ 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<Any>? {
+ 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<Void>()
+
+ 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<Any>? {
+ 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<Void>()
+ val portIframe = GeckoResult<Void>()
+ val messageTopLevel = GeckoResult<Void>()
+ val messageIframe = GeckoResult<Void>()
+
+ 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<Any>? {
+ 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<String>()
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<String>()
+ var extension: WebExtension? = null
+
+ val messageDelegate = object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<AllowOrDeny> {
+ 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<PermissionDelegate.ContentPermission>) {
+ 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<Boolean>()
+
+ mainSession.delegateUntilTestEnd(object : NavigationDelegate, ProgressDelegate {
+ override fun onLocationChange(session: GeckoSession, url: String?, perms: MutableList<PermissionDelegate.ContentPermission>) {
+ 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<AllowOrDeny> {
+ 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<String>, origins: Array<String>): GeckoResult<AllowOrDeny> {
+ 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<String>()
+ mainSession.webExtensionController.setMessageDelegate(
+ extension,
+ object : WebExtension.MessageDelegate {
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult<Any>? {
+ 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<String>()
+ val message2 = sessionRule.waitForResult(result)
+ assertThat("Permission request should be accepted.", message2, equalTo("true"))
+
+ mainSession.synthesizeTap(50, 50)
+ result = GeckoResult<String>()
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<String>,
+ newOrigins: Array<String>,
+ ): GeckoResult<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<String>,
+ newOrigins: Array<String>,
+ ): GeckoResult<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<AllowOrDeny> {
+ 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<Void>()
+ var optionsExtension: WebExtension? = null
+ val tabDelegate = object : WebExtension.TabDelegate {
+ @AssertCalled(count = 1)
+ override fun onNewTab(
+ source: WebExtension,
+ details: WebExtension.CreateTabDetails,
+ ): GeckoResult<GeckoSession> {
+ 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<Void>()
+ 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<AllowOrDeny> {
+ return GeckoResult.allow()
+ }
+ })
+
+ val webExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/download-flags-true.xpi"),
+ )
+
+ val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<DownloadInitData>? {
+ 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<AllowOrDeny> {
+ return GeckoResult.allow()
+ }
+ })
+
+ val webExtension = sessionRule.waitForResult(
+ controller.install("https://example.org/tests/junit/download-flags-false.xpi"),
+ )
+
+ val assertOnDownloadCalled = GeckoResult<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<DownloadInitData>? {
+ 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<Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
+ 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<JSONObject>()
+
+ val thirdUpdateReceived = GeckoResult<JSONObject>()
+ val messageDelegate = object : MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
+ 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<WebExtension.Download>()
+ val downloadDelegate = object : DownloadDelegate {
+ override fun onDownload(source: WebExtension, request: DownloadRequest): GeckoResult<WebExtension.DownloadInitData>? {
+ 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<String>()
+ val messageDelegate = object : MessageDelegate {
+ override fun onMessage(nativeApp: String, message: Any, sender: MessageSender): GeckoResult<Any>? {
+ 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<Int>? {
+ 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<Void>()
+
+ 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<Void>()
+ 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<Void>()
+
+ 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<WebNotification>()
+ 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<WebNotification>()
+ 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<Void>()
+ 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<Void>()
+ 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<WebNotification>()
+
+ 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<WebNotification>()
+
+ 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<Int>? {
+ 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<Void>()
+ 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<WebPushSubscription>? {
+ return GeckoResult.fromValue(storedSubscription)
+ }
+
+ override fun onUnsubscribe(scope: String): GeckoResult<Void>? {
+ storedSubscription = null
+ return GeckoResult.fromValue(null)
+ }
+
+ override fun onSubscribe(scope: String, appServerKey: ByteArray?): GeckoResult<WebPushSubscription>? {
+ 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 <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+/* 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.
+ *
+ * <p>Enable tracking protection for a particular test:
+ *
+ * <pre>
+ * &#64;Setting.List(&#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false"))
+ * &#64;Test public void test() { ... }
+ * </pre>
+ *
+ * <p>Use multiple settings:
+ *
+ * <pre>
+ * &#64;Setting.List({&#64;Setting(key = Setting.Key.USE_PRIVATE_MODE,
+ * value = "true"),
+ * &#64;Setting(key = Setting.Key.USE_TRACKING_PROTECTION,
+ * value = "false")})
+ * </pre>
+ */
+ @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.
+ *
+ * <p>{@code @AssertCalled} asserts the method must be called at least once.
+ *
+ * <p>{@code @AssertCalled(false)} asserts the method must not be called.
+ *
+ * <p>{@code @AssertCalled(order = 2)} asserts the method must be called once and after any other
+ * method with order number less than 2.
+ *
+ * <p>{@code @AssertCalled(order = {2, 4})} asserts order number 2 for first call and order number
+ * 4 for any subsequent calls.
+ *
+ * <p>{@code @AssertCalled(count = 2)} asserts two calls total in any order with respect to other
+ * calls.
+ *
+ * <p>{@code @AssertCalled(count = 2, order = 2)} asserts two calls, both with order number 2.
+ *
+ * <p>{@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<T> {
+ 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<T> {
+ public final Class<T> delegate;
+ private final DelegateRegistrar<T> mRegister;
+ private final DelegateRegistrar<T> mUnregister;
+ private final T mProxy;
+ private boolean mRegistered;
+
+ public ExternalDelegate(
+ final Class<T> delegate,
+ final T impl,
+ final DelegateRegistrar<T> register,
+ final DelegateRegistrar<T> 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<Pair<GeckoSession, Method>, MethodCall> mDelegates = new HashMap<>();
+ private final List<ExternalDelegate<?>> 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<GeckoSession, Method> 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 <T> ExternalDelegate<T> addExternalDelegate(
+ @NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> 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<T> externalDelegate =
+ new ExternalDelegate<>(delegate, impl, register, unregister);
+ mExternalDelegates.add(externalDelegate);
+ mAllDelegates.add(delegate);
+ return externalDelegate;
+ }
+
+ @NonNull
+ public List<ExternalDelegate<?>> getExternalDelegates() {
+ return mExternalDelegates;
+ }
+
+ /** Generate a JS function to set new prefs and return a set of saved prefs. */
+ public void setPrefs(final @NonNull Map<String, ?> prefs) {
+ mOldPrefs =
+ (JSONObject)
+ webExtensionApiCall(
+ "SetPrefs",
+ args -> {
+ final JSONObject existingPrefs =
+ mOldPrefs != null ? mOldPrefs : new JSONObject();
+
+ final JSONObject newPrefs = new JSONObject();
+ for (final Map.Entry<String, ?> 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<MethodCall> 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<Class<?>> 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<Class<?>> 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<Intent> 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<ContentPermission> perms) {}
+
+ @Override
+ public void onShutdown() {}
+
+ @Override
+ public GeckoResult<GeckoSession> 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<GeckoSession> mSubSessions = new HashSet<>();
+
+ protected ErrorCollector mErrorCollector;
+ protected GeckoSession mMainSession;
+ protected Object mCallbackProxy;
+ protected Set<Class<?>> mNullDelegates;
+ protected Set<Class<?>> mAllDelegates;
+ protected List<CallRecord> 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<GeckoSession, SurfaceTexture> mDisplayTextures = new HashMap<>();
+ protected Map<GeckoSession, Surface> mDisplaySurfaces = new HashMap<>();
+ protected Map<GeckoSession, GeckoDisplay> 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 <T> void checkThat(final String reason, final T value, final Matcher<? super T> 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<Class<?>> getCurrentDelegates() {
+ final List<ExternalDelegate<?>> waitDelegates = mWaitScopeDelegates.getExternalDelegates();
+ final List<ExternalDelegate<?>> testDelegates = mTestScopeDelegates.getExternalDelegates();
+
+ final Set<Class<?>> 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<Annotation> 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<CallRecord> 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<Class<?>> 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<WebExtension> 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<Throwable> 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<MethodCall> 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<MethodCall> 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<MethodCall> 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<MethodCall> 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<MethodCall> 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<MethodCall> 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<GeckoSession, WebExtension.Port> 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<String, EvalJSResult> mPendingMessages = new HashMap<>();
+ MultiMap<WebExtension.Port, String> 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.
+ *
+ * <p>
+ *
+ * <p>Asserts that {@code foo} is equal to {@code "bar"} during the first call and {@code "baz"}
+ * during the second call:
+ *
+ * <pre>{@code assertThat("Foo should match", foo, equalTo(forEachCall("bar",
+ * "baz")));}</pre>
+ *
+ * @param values Input array
+ * @return Value from input array indexed by the current call counter.
+ */
+ @SafeVarargs
+ public final <T> 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<String> getRequestedLocales() {
+ try {
+ final JSONArray locales = (JSONArray) webExtensionApiCall("GetRequestedLocales", null);
+ final List<String> 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 <T> 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<String, ?> 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<String, ?> 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 <T> void addExternalDelegateUntilTestEnd(
+ @NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ final ExternalDelegate<T> 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 <T> void addExternalDelegateUntilTestEnd(
+ @NonNull final KClass<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> 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 <T> void addExternalDelegateDuringNextWait(
+ @NonNull final Class<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> unregister,
+ @NonNull final T impl) {
+ final ExternalDelegate<T> 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 <T> void addExternalDelegateDuringNextWait(
+ @NonNull final KClass<T> delegate,
+ @NonNull final DelegateRegistrar<T> register,
+ @NonNull final DelegateRegistrar<T> 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 <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ public <T> T waitForResult(@NonNull final GeckoResult<T> 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 <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ private <T> T waitForResult(@NonNull final GeckoResult<T> 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<Boolean> metric) {
+ if (delegate != null) {
+ delegate.onBooleanScalar(metric);
+ }
+ }
+
+ @Override
+ public void onStringScalar(@NonNull final RuntimeTelemetry.Metric<String> metric) {
+ if (delegate != null) {
+ delegate.onStringScalar(metric);
+ }
+ }
+
+ @Override
+ public void onLongScalar(@NonNull final RuntimeTelemetry.Metric<Long> 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<AsyncHttpServerResponse>()
+
+ 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<String>
+ 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 <T> The type of the value held by the {@link GeckoResult}
+ * @return The value of the completed {@link GeckoResult}.
+ */
+ public static <T> T waitForResult(@NonNull final GeckoResult<T> result, final long timeout)
+ throws Throwable {
+ final ResultHolder<T> holder = new ResultHolder<>(result);
+
+ waitForCondition(() -> holder.isComplete, timeout);
+
+ if (holder.error != null) {
+ throw holder.error;
+ }
+
+ return holder.value;
+ }
+
+ private static class ResultHolder<T> {
+ public T value;
+ public Throwable error;
+ public boolean isComplete;
+
+ public ResultHolder(final GeckoResult<T> 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png
Binary files 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
--- /dev/null
+++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png
Binary files 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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <color name="colorPrimary">#3F51B5</color>
+ <color name="colorPrimaryDark">#303F9F</color>
+ <color name="colorAccent">#FF4081</color>
+</resources>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<resources>
+ <string name="app_name">GeckoView Test Runner</string>
+</resources>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<resources>
+ <!-- Base application theme. -->
+ <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar">
+ <!-- Customize your theme here. -->
+ </style>
+</resources>
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 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.mozilla.geckoview">
+
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
+ <uses-permission android:name="android.permission.HIGH_SAMPLING_RATE_SENSORS"/>
+ <uses-permission android:name="android.permission.INTERNET"/>
+ <uses-permission android:name="android.permission.WAKE_LOCK"/>
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+
+ <uses-feature
+ android:name="android.hardware.location"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.location.gps"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.touchscreen"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera.autofocus"
+ android:required="false"/>
+
+ <uses-feature
+ android:name="android.hardware.audio.low_latency"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.microphone"
+ android:required="false"/>
+ <uses-feature
+ android:name="android.hardware.camera.any"
+ android:required="false"/>
+
+ <!-- GeckoView requires OpenGL ES 2.0 -->
+ <uses-feature
+ android:glEsVersion="0x00020000"
+ android:required="true"/>
+
+ <application>
+ <service
+ android:name="org.mozilla.gecko.media.MediaManager"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":media">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$gmplugin"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":gmplugin">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$socket"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":socket">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$gpu"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":gpu">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$utility"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":utility">
+ </service>
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$ipdlunittest"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="false"
+ android:process=":ipdlunittest">
+ </service>
+ </application>
+
+</manifest>
diff --git a/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja b/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja
new file mode 100644
index 0000000000..a2bf0efb7f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/AndroidManifest_overlay.jinja
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="org.mozilla.geckoview">
+ <application>
+ {% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %}
+ <service
+ android:name="org.mozilla.gecko.process.GeckoChildProcessServices$tab{{ id }}"
+ android:enabled="true"
+ android:exported="false"
+ android:isolatedProcess="{{ 'true' if MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS else 'false' }}"
+ android:process=":tab{{ id }}">
+ </service>
+ {% endfor %}
+ </application>
+</manifest>
diff --git a/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/IGeckoEditableChild.aidl
new file mode 100644
index 0000000000..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<Gamepad> sGamepads = new SparseArray<>();
+ private static final SparseArray<List<KeyEvent>> sPendingGamepads = new SparseArray<>();
+ private static InputManager.InputDeviceListener sListener;
+ private static Timer sPollTimer;
+
+ private AndroidGamepadManager() {}
+
+ @WrapForJNI
+ private static void start(final Context context) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ doStart(context);
+ }
+ });
+ }
+
+ /* package */ static void doStart(final Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ scanForGamepads();
+ addDeviceListener(context);
+ sStarted = true;
+ }
+ }
+
+ @WrapForJNI
+ private static void stop(final Context context) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ doStop(context);
+ }
+ });
+ }
+
+ /* package */ static void doStop(final Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (sStarted) {
+ removeDeviceListener(context);
+ sPendingGamepads.clear();
+ sGamepads.clear();
+ sStarted = false;
+ }
+ }
+
+ /* package */ static void handleGamepadAdded(final int deviceId, final byte[] gamepadHandle) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return;
+ }
+
+ final List<KeyEvent> pending = sPendingGamepads.get(deviceId);
+ if (pending == null) {
+ removeGamepad(deviceId);
+ return;
+ }
+
+ sPendingGamepads.remove(deviceId);
+ sGamepads.put(deviceId, new Gamepad(gamepadHandle, deviceId));
+ // Handle queued KeyEvents
+ for (final KeyEvent ev : pending) {
+ handleKeyEvent(ev);
+ }
+ }
+
+ private static float sDeadZoneThresholdOverride = 1e-2f;
+
+ private static boolean isValueInDeadZone(final MotionEvent event, final int axis) {
+ final float threshold;
+ if (sDeadZoneThresholdOverride >= 0) {
+ threshold = sDeadZoneThresholdOverride;
+ } else {
+ final InputDevice.MotionRange range = event.getDevice().getMotionRange(axis);
+ threshold = range.getFlat() + range.getFuzz();
+ }
+ final float value = event.getAxisValue(axis);
+ return (Math.abs(value) < threshold);
+ }
+
+ private static float deadZone(final MotionEvent ev, final int axis) {
+ if (isValueInDeadZone(ev, axis)) {
+ return 0.0f;
+ }
+ return ev.getAxisValue(axis);
+ }
+
+ private static void mapDpadAxis(
+ final Gamepad gamepad, final boolean pressed, final float value, final int which) {
+ if (pressed != gamepad.dpad[which]) {
+ gamepad.dpad[which] = pressed;
+ onButtonChange(gamepad.handle, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value));
+ }
+ }
+
+ public static boolean handleMotionEvent(final MotionEvent ev) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return false;
+ }
+
+ final Gamepad gamepad = sGamepads.get(ev.getDeviceId());
+ if (gamepad == null) {
+ // Not a device we care about.
+ return false;
+ }
+
+ // First check the analog stick axes
+ final boolean[] valid = new boolean[Axis.values().length];
+ final float[] axes = new float[Axis.values().length];
+ boolean anyValidAxes = false;
+ for (final Axis axis : Axis.values()) {
+ final float value = deadZone(ev, axis.axis);
+ final int i = axis.ordinal();
+ if (value != gamepad.axes[i]) {
+ axes[i] = value;
+ gamepad.axes[i] = value;
+ valid[i] = true;
+ anyValidAxes = true;
+ }
+ }
+ if (anyValidAxes) {
+ // Send an axismove event.
+ onAxisChange(gamepad.handle, valid, axes);
+ }
+
+ // Map triggers to buttons.
+ if (gamepad.triggerAxes != null) {
+ for (final Trigger trigger : Trigger.values()) {
+ final int i = trigger.ordinal();
+ final int axis = gamepad.triggerAxes[i];
+ final float value = deadZone(ev, axis);
+ if (value != gamepad.triggers[i]) {
+ gamepad.triggers[i] = value;
+ final boolean pressed = value > TRIGGER_PRESSED_THRESHOLD;
+ onButtonChange(gamepad.handle, trigger.button, pressed, value);
+ }
+ }
+ }
+ // Map d-pad to buttons.
+ for (final DpadAxis dpadaxis : DpadAxis.values()) {
+ final float value = deadZone(ev, dpadaxis.axis);
+ mapDpadAxis(gamepad, value < 0.0f, value, dpadaxis.negativeButton);
+ mapDpadAxis(gamepad, value > 0.0f, value, dpadaxis.positiveButton);
+ }
+ return true;
+ }
+
+ public static boolean handleKeyEvent(final KeyEvent ev) {
+ ThreadUtils.assertOnUiThread();
+ if (!sStarted) {
+ return false;
+ }
+
+ final int deviceId = ev.getDeviceId();
+ final List<KeyEvent> pendingGamepad = sPendingGamepads.get(deviceId);
+ if (pendingGamepad != null) {
+ // Queue up key events for pending devices.
+ pendingGamepad.add(ev);
+ return true;
+ }
+
+ if (sGamepads.get(deviceId) == null) {
+ final InputDevice device = ev.getDevice();
+ if (device != null
+ && (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ // This is a gamepad we haven't seen yet.
+ addGamepad(device);
+ sPendingGamepads.get(deviceId).add(ev);
+ return true;
+ }
+ // Not a device we care about.
+ return false;
+ }
+
+ int key = -1;
+ for (final Button button : Button.values()) {
+ if (button.button == ev.getKeyCode()) {
+ key = button.ordinal();
+ break;
+ }
+ }
+ if (key == -1) {
+ // Not a key we know how to handle.
+ return false;
+ }
+ if (ev.getRepeatCount() > 0) {
+ // We would handle this key, but we're not interested in
+ // repeats. Eat it.
+ return true;
+ }
+
+ final Gamepad gamepad = sGamepads.get(deviceId);
+ final boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN;
+ onButtonChange(gamepad.handle, key, pressed, pressed ? 1.0f : 0.0f);
+ return true;
+ }
+
+ private static void scanForGamepads() {
+ final int[] deviceIds = InputDevice.getDeviceIds();
+ if (deviceIds == null) {
+ return;
+ }
+ for (int i = 0; i < deviceIds.length; i++) {
+ final InputDevice device = InputDevice.getDevice(deviceIds[i]);
+ if (device == null) {
+ continue;
+ }
+ if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD) {
+ continue;
+ }
+ addGamepad(device);
+ }
+ }
+
+ private static void addGamepad(final InputDevice device) {
+ sPendingGamepads.put(device.getId(), new ArrayList<KeyEvent>());
+ final byte[] gamepadId = nativeAddGamepad();
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ handleGamepadAdded(device.getId(), gamepadId);
+ }
+ });
+ }
+
+ private static void removeGamepad(final int deviceId) {
+ final Gamepad gamepad = sGamepads.get(deviceId);
+ nativeRemoveGamepad(gamepad.handle);
+ sGamepads.remove(deviceId);
+ }
+
+ private static void addDeviceListener(final Context context) {
+ sListener =
+ new InputManager.InputDeviceListener() {
+ @Override
+ public void onInputDeviceAdded(final int deviceId) {
+ final InputDevice device = InputDevice.getDevice(deviceId);
+ if (device == null) {
+ return;
+ }
+ if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ addGamepad(device);
+ }
+ }
+
+ @Override
+ public void onInputDeviceRemoved(final int deviceId) {
+ if (sPendingGamepads.get(deviceId) != null) {
+ // Got removed before Gecko's ack reached us.
+ // gamepadAdded will deal with it.
+ sPendingGamepads.remove(deviceId);
+ return;
+ }
+ if (sGamepads.get(deviceId) != null) {
+ removeGamepad(deviceId);
+ }
+ }
+
+ @Override
+ public void onInputDeviceChanged(final int deviceId) {}
+ };
+ final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ im.registerInputDeviceListener(sListener, ThreadUtils.getUiHandler());
+ }
+
+ private static void removeDeviceListener(final Context context) {
+ final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE);
+ im.unregisterInputDeviceListener(sListener);
+ sListener = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java
new file mode 100644
index 0000000000..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<? extends Service> handlerService;
+
+ /**
+ * Get the root exception from the 'cause' chain of an exception.
+ *
+ * @param exc An exception
+ * @return The root exception
+ */
+ public static Throwable getRootException(final Throwable exc) {
+ Throwable cause;
+ Throwable result = exc;
+ for (cause = exc; cause != null; cause = cause.getCause()) {
+ result = cause;
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the standard stack trace string of an exception.
+ *
+ * @param exc An exception
+ * @return The exception stack trace.
+ */
+ public static String getExceptionStackTrace(final Throwable exc) {
+ final StringWriter sw = new StringWriter();
+ final PrintWriter pw = new PrintWriter(sw);
+ exc.printStackTrace(pw);
+ pw.flush();
+ return sw.toString();
+ }
+
+ /** Terminate the current process. */
+ public static void terminateProcess() {
+ Process.killProcess(Process.myPid());
+ }
+
+ /** Create and register a CrashHandler for all threads and thread groups. */
+ public CrashHandler(final Class<? extends Service> handlerService) {
+ this((Context) null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for all threads and thread groups.
+ *
+ * @param appContext A Context for retrieving application information.
+ */
+ public CrashHandler(final Context appContext, final Class<? extends Service> handlerService) {
+ this.appContext = appContext;
+ this.handlerThread = null;
+ this.handlerService = handlerService;
+ this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler();
+ Thread.setDefaultUncaughtExceptionHandler(this);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ */
+ public CrashHandler(final Thread thread, final Class<? extends Service> handlerService) {
+ this(thread, null, handlerService);
+ }
+
+ /**
+ * Create and register a CrashHandler for a particular thread.
+ *
+ * @param thread A thread to register the CrashHandler
+ * @param appContext A Context for retrieving application information.
+ */
+ public CrashHandler(
+ final Thread thread,
+ final Context appContext,
+ final Class<? extends Service> handlerService) {
+ this.appContext = appContext;
+ this.handlerThread = thread;
+ this.handlerService = handlerService;
+ this.systemUncaughtHandler = thread.getUncaughtExceptionHandler();
+ thread.setUncaughtExceptionHandler(this);
+ }
+
+ /** Unregister this CrashHandler for exception handling. */
+ public void unregister() {
+ unregistered = true;
+
+ // Restore the previous handler if we are still the topmost handler.
+ // If not, we are part of a chain of handlers, and we cannot just restore the previous
+ // handler, because that would replace whatever handler that's above us in the chain.
+
+ if (handlerThread != null) {
+ if (handlerThread.getUncaughtExceptionHandler() == this) {
+ handlerThread.setUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ } else {
+ if (Thread.getDefaultUncaughtExceptionHandler() == this) {
+ Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler);
+ }
+ }
+ }
+
+ /**
+ * Record an exception stack in logs.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ */
+ public static void logException(final Thread thread, final Throwable exc) {
+ try {
+ Log.e(
+ LOGTAG,
+ ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD "
+ + thread.getId()
+ + " (\""
+ + thread.getName()
+ + "\")",
+ exc);
+
+ if (MAIN_THREAD != thread) {
+ Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:");
+ for (final StackTraceElement ste : MAIN_THREAD.getStackTrace()) {
+ Log.e(LOGTAG, " " + ste.toString());
+ }
+ }
+ } catch (final Throwable e) {
+ // If something throws here, we want to continue to report the exception,
+ // so we catch all exceptions and ignore them.
+ }
+ }
+
+ private static long getCrashTime() {
+ return System.currentTimeMillis() / 1000;
+ }
+
+ private static long getStartupTime() {
+ // Process start time is also the proc file modified time.
+ final long uptimeMins = (new File("/proc/self/cmdline")).lastModified();
+ if (uptimeMins == 0L) {
+ return getCrashTime();
+ }
+ return uptimeMins / 1000;
+ }
+
+ private static String getJavaPackageName() {
+ return CrashHandler.class.getPackage().getName();
+ }
+
+ private static String getProcessName() {
+ try {
+ final FileReader reader = new FileReader("/proc/self/cmdline");
+ final char[] buffer = new char[64];
+ try {
+ if (reader.read(buffer) > 0) {
+ // cmdline is delimited by '\0', and we want the first token.
+ final int nul = Arrays.asList(buffer).indexOf('\0');
+ return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim();
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (final IOException e) {
+ }
+
+ return null;
+ }
+
+ protected String getAppPackageName() {
+ final Context context = getAppContext();
+
+ if (context != null) {
+ return context.getPackageName();
+ }
+
+ // Package name is also the process name in most cases.
+ final String processName = getProcessName();
+ if (processName != null) {
+ return processName;
+ }
+
+ // Fallback to using CrashHandler's package name.
+ return getJavaPackageName();
+ }
+
+ protected Context getAppContext() {
+ return appContext;
+ }
+
+ /**
+ * Get the crash "extras" to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return "Extras" in the from of a Bundle
+ */
+ protected Bundle getCrashExtras(final Thread thread, final Throwable exc) {
+ final Context context = getAppContext();
+ final Bundle extras = new Bundle();
+ final String pkgName = getAppPackageName();
+
+ extras.putLong("CrashTime", getCrashTime());
+ extras.putLong("StartupTime", getStartupTime());
+ extras.putString("Android_ProcessName", getProcessName());
+ extras.putString("Android_PackageName", pkgName);
+
+ final String notes = GeckoAppShell.getAppNotes();
+ if (notes != null) {
+ extras.putString("Notes", notes);
+ }
+
+ if (context != null) {
+ final PackageManager pkgMgr = context.getPackageManager();
+ try {
+ final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0);
+ extras.putString("Version", pkgInfo.versionName);
+ extras.putInt("BuildID", pkgInfo.versionCode);
+ extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000);
+ } catch (final PackageManager.NameNotFoundException e) {
+ Log.i(LOGTAG, "Error getting package info", e);
+ }
+ }
+
+ extras.putString("JavaStackTrace", getExceptionStackTrace(exc));
+ return extras;
+ }
+
+ /**
+ * Get the crash minidump content to be reported.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Minidump content
+ */
+ protected byte[] getCrashDump(final Thread thread, final Throwable exc) {
+ return new byte[0]; // No minidump.
+ }
+
+ protected static String normalizeUrlString(final String str) {
+ if (str == null) {
+ return "";
+ }
+ return Uri.encode(str);
+ }
+
+ /**
+ * Get the server URL to send the crash report to.
+ *
+ * @param extras The crash extras Bundle
+ */
+ protected String getServerUrl(final Bundle extras) {
+ return String.format(
+ DEFAULT_SERVER_URL,
+ normalizeUrlString(extras.getString("ProductID")),
+ normalizeUrlString(extras.getString("Version")),
+ normalizeUrlString(extras.getString("BuildID")));
+ }
+
+ /**
+ * Launch the crash reporter activity that sends the crash report to the server.
+ *
+ * @param dumpFile Path for the minidump file
+ * @param extraFile Path for the crash extra file
+ * @return Whether the crash reporter was successfully launched
+ */
+ protected boolean launchCrashReporter(final String dumpFile, final String extraFile) {
+ try {
+ final Context context = getAppContext();
+ final ProcessBuilder pb;
+
+ if (handlerService == null) {
+ Log.w(LOGTAG, "No crash handler service defined, unable to report crash");
+ return false;
+ }
+
+ if (context != null) {
+ final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED);
+ intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile);
+ intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile);
+ intent.putExtra(
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ intent.setClass(context, handlerService);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ return true;
+ }
+
+ final int deviceSdkVersion = Build.VERSION.SDK_INT;
+ if (deviceSdkVersion < 17) {
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ "startservice",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + handlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ } else {
+ final String startServiceCommand;
+ if (deviceSdkVersion >= 26) {
+ startServiceCommand = "start-foreground-service";
+ } else {
+ startServiceCommand = "startservice";
+ }
+
+ pb =
+ new ProcessBuilder(
+ "/system/bin/am",
+ startServiceCommand,
+ "--user", /* USER_CURRENT_OR_SELF */
+ "-3",
+ "-a",
+ GeckoRuntime.ACTION_CRASHED,
+ "-n",
+ getAppPackageName() + '/' + handlerService.getName(),
+ "--es",
+ GeckoRuntime.EXTRA_MINIDUMP_PATH,
+ dumpFile,
+ "--es",
+ GeckoRuntime.EXTRA_EXTRAS_PATH,
+ extraFile,
+ "--es",
+ GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE,
+ GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN);
+ }
+
+ pb.start().waitFor();
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error launching crash reporter", e);
+ return false;
+
+ } catch (final InterruptedException e) {
+ Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e);
+ // Fall-through
+ }
+ return true;
+ }
+
+ /**
+ * Report an exception to Socorro.
+ *
+ * @param thread The exception thread
+ * @param exc An exception
+ * @return Whether the exception was successfully reported
+ */
+ @SuppressLint("SdCardPath")
+ protected boolean reportException(final Thread thread, final Throwable exc) {
+ final Context context = getAppContext();
+ final String id = UUID.randomUUID().toString();
+
+ // Use the cache directory under the app directory to store crash files.
+ final File dir;
+ if (context != null) {
+ dir = context.getCacheDir();
+ } else {
+ dir = new File("/data/data/" + getAppPackageName() + "/cache");
+ }
+
+ dir.mkdirs();
+ if (!dir.exists()) {
+ return false;
+ }
+
+ final File dmpFile = new File(dir, id + ".dmp");
+ final File extraFile = new File(dir, id + ".extra");
+
+ try {
+ // Write out minidump file as binary.
+
+ final byte[] minidump = getCrashDump(thread, exc);
+ final FileOutputStream dmpStream = new FileOutputStream(dmpFile);
+ try {
+ dmpStream.write(minidump);
+ } finally {
+ dmpStream.close();
+ }
+
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Error writing minidump file", e);
+ return false;
+ }
+
+ try {
+ // Write out crash extra file as text.
+
+ final Bundle extras = getCrashExtras(thread, exc);
+ final String url = getServerUrl(extras);
+ extras.putString("ServerURL", url);
+
+ final JSONObject json = new JSONObject();
+ for (final String key : extras.keySet()) {
+ json.put(key, extras.get(key));
+ }
+
+ final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile));
+ try {
+ extraWriter.write(json.toString());
+ } finally {
+ extraWriter.close();
+ }
+ } catch (final IOException | JSONException e) {
+ Log.e(LOGTAG, "Error writing extra file", e);
+ return false;
+ }
+
+ return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath());
+ }
+
+ /**
+ * Implements the default behavior for handling uncaught exceptions.
+ *
+ * @param thread The exception thread
+ * @param exc An uncaught exception
+ */
+ @Override
+ public void uncaughtException(final Thread thread, final Throwable exc) {
+ if (this.crashing) {
+ // Prevent possible infinite recusions.
+ return;
+ }
+
+ Thread resolvedThread = thread;
+ if (resolvedThread == null) {
+ // Gecko may pass in null for thread to denote the current thread.
+ resolvedThread = Thread.currentThread();
+ }
+
+ try {
+ Throwable rootException = exc;
+ if (!this.unregistered) {
+ // Only process crash ourselves if we have not been unregistered.
+
+ this.crashing = true;
+ rootException = getRootException(exc);
+ logException(resolvedThread, rootException);
+
+ if (reportException(resolvedThread, rootException)) {
+ // Reporting succeeded; we can terminate our process now.
+ return;
+ }
+ }
+
+ if (systemUncaughtHandler != null) {
+ // Follow the chain of uncaught handlers.
+ systemUncaughtHandler.uncaughtException(resolvedThread, rootException);
+ }
+ } finally {
+ terminateProcess();
+ }
+ }
+
+ public static CrashHandler createDefaultCrashHandler(final Context context) {
+ return new CrashHandler(context, null) {
+ @Override
+ protected Bundle getCrashExtras(final Thread thread, final Throwable exc) {
+ final Bundle extras = super.getCrashExtras(thread, exc);
+
+ extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME);
+ extras.putString("ProductID", BuildConfig.MOZ_APP_ID);
+ extras.putString("Version", BuildConfig.MOZ_APP_VERSION);
+ extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID);
+ extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR);
+ extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL);
+ return extras;
+ }
+
+ @Override
+ public boolean reportException(final Thread thread, final Throwable exc) {
+ if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
+ // Only use Java crash reporter if enabled on official build.
+ return super.reportException(thread, exc);
+ }
+ return false;
+ }
+ };
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java
new file mode 100644
index 0000000000..0aacef39a4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java
@@ -0,0 +1,96 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.util.Log;
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// This class implements the functionality needed to find third-party root
+// certificates that have been added to the android CA store.
+public class EnterpriseRoots {
+ private static final String LOGTAG = "EnterpriseRoots";
+
+ // Gecko calls this function from C++ to find third-party root certificates
+ // it can use as trust anchors for TLS connections.
+ @WrapForJNI
+ private static byte[][] gatherEnterpriseRoots() {
+
+ // The KeyStore "AndroidCAStore" contains the certificates we're
+ // interested in.
+ final KeyStore ks;
+ try {
+ ks = KeyStore.getInstance("AndroidCAStore");
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "getInstance() failed", kse);
+ return new byte[0][0];
+ }
+ try {
+ ks.load(null);
+ } catch (final CertificateException ce) {
+ Log.e(LOGTAG, "load() failed", ce);
+ return new byte[0][0];
+ } catch (final IOException ioe) {
+ Log.e(LOGTAG, "load() failed", ioe);
+ return new byte[0][0];
+ } catch (final NoSuchAlgorithmException nsae) {
+ Log.e(LOGTAG, "load() failed", nsae);
+ return new byte[0][0];
+ }
+ // Given the KeyStore, we get an identifier for each object in it. For
+ // each one that is a Certificate, we try to distinguish between
+ // entries that shipped with the OS and entries that were added by the
+ // user or an administrator. The former we ignore and the latter we
+ // collect in an array of byte arrays and return.
+ final Enumeration<String> aliases;
+ try {
+ aliases = ks.aliases();
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "aliases() failed", kse);
+ return new byte[0][0];
+ }
+ final ArrayList<byte[]> roots = new ArrayList<byte[]>();
+ while (aliases.hasMoreElements()) {
+ final String alias = aliases.nextElement();
+ final boolean isCertificate;
+ try {
+ isCertificate = ks.isCertificateEntry(alias);
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "isCertificateEntry() failed", kse);
+ continue;
+ }
+ // Built-in certificate aliases start with "system:", whereas
+ // 3rd-party certificate aliases start with "user:". It's
+ // unfortunate to be relying on this implementation detail, but
+ // there appears to be no other way to differentiate between the
+ // two.
+ if (isCertificate && alias.startsWith("user:")) {
+ final Certificate certificate;
+ try {
+ certificate = ks.getCertificate(alias);
+ } catch (final KeyStoreException kse) {
+ Log.e(LOGTAG, "getCertificate() failed", kse);
+ continue;
+ }
+ try {
+ roots.add(certificate.getEncoded());
+ } catch (final CertificateEncodingException cee) {
+ Log.e(LOGTAG, "getEncoded() failed", cee);
+ }
+ }
+ }
+ Log.d(LOGTAG, "found " + roots.size() + " enterprise roots");
+ return roots.toArray(new byte[0][0]);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
new file mode 100644
index 0000000000..647ac5bc09
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java
@@ -0,0 +1,588 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.os.Handler;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.mozilla.gecko.annotation.ReflectionTarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.geckoview.GeckoResult;
+
+@RobocopTarget
+public final class EventDispatcher extends JNIObject {
+ private static final String LOGTAG = "GeckoEventDispatcher";
+
+ private static final EventDispatcher INSTANCE = new EventDispatcher();
+
+ /**
+ * The capacity of a HashMap is rounded up to the next power-of-2. Every time the size of the map
+ * goes beyond 75% of the capacity, the map is rehashed. Therefore, to empirically determine the
+ * initial capacity that avoids rehashing, we need to determine the initial size, divide it by
+ * 75%, and round up to the next power-of-2.
+ */
+ private static final int DEFAULT_UI_EVENTS_COUNT = 128; // Empirically measured
+
+ private static class Message {
+ final String type;
+ final GeckoBundle bundle;
+ final EventCallback callback;
+
+ Message(final String type, final GeckoBundle bundle, final EventCallback callback) {
+ this.type = type;
+ this.bundle = bundle;
+ this.callback = callback;
+ }
+ }
+
+ // GeckoBundle-based events.
+ private final MultiMap<String, BundleEventListener> mListeners =
+ new MultiMap<>(DEFAULT_UI_EVENTS_COUNT);
+ private Deque<Message> mPendingMessages = new ArrayDeque<>();
+
+ private boolean mAttachedToGecko;
+ private final NativeQueue mNativeQueue;
+ private final String mName;
+
+ private static Map<String, EventDispatcher> sDispatchers = new HashMap<>();
+
+ @ReflectionTarget
+ @WrapForJNI(calledFrom = "gecko")
+ public static EventDispatcher getInstance() {
+ return INSTANCE;
+ }
+
+ /**
+ * Gets a named EventDispatcher.
+ *
+ * <p>Named EventDispatchers can be used to communicate to Gecko's corresponding named
+ * EventDispatcher.
+ *
+ * <p>Messages for named EventDispatcher are queued by default when no listener is present. Queued
+ * messages will be released automatically when a listener is attached.
+ *
+ * <p>A named EventDispatcher needs to be disposed manually by calling {@link #shutdown} when it
+ * is not needed anymore.
+ *
+ * @param name Name for this EventDispatcher.
+ * @return the existing named EventDispatcher for a given name or a newly created one if it
+ * doesn't exist.
+ */
+ @ReflectionTarget
+ @WrapForJNI(calledFrom = "gecko")
+ public static EventDispatcher byName(final String name) {
+ synchronized (sDispatchers) {
+ EventDispatcher dispatcher = sDispatchers.get(name);
+
+ if (dispatcher == null) {
+ dispatcher = new EventDispatcher(name);
+ sDispatchers.put(name, dispatcher);
+ }
+
+ return dispatcher;
+ }
+ }
+
+ /* package */ EventDispatcher() {
+ mNativeQueue = GeckoThread.getNativeQueue();
+ mName = null;
+ }
+
+ /* package */ EventDispatcher(final String name) {
+ mNativeQueue = GeckoThread.getNativeQueue();
+ mName = name;
+ }
+
+ public EventDispatcher(final NativeQueue queue) {
+ mNativeQueue = queue;
+ mName = null;
+ }
+
+ private boolean isReadyForDispatchingToGecko() {
+ return mNativeQueue.isReady();
+ }
+
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ @WrapForJNI(stubName = "Shutdown")
+ protected native void shutdownNative();
+
+ @WrapForJNI private static final int DETACHED = 0;
+ @WrapForJNI private static final int ATTACHED = 1;
+ @WrapForJNI private static final int REATTACHING = 2;
+
+ @WrapForJNI(calledFrom = "gecko")
+ private synchronized void setAttachedToGecko(final int state) {
+ if (mAttachedToGecko && state == DETACHED) {
+ dispose(false);
+ }
+ mAttachedToGecko = (state == ATTACHED);
+ }
+
+ /**
+ * Shuts down this EventDispatcher and release resources.
+ *
+ * <p>Only named EventDispatcher can be shut down manually. A shut down EventDispatcher will not
+ * receive any further messages.
+ */
+ public void shutdown() {
+ if (mName == null) {
+ throw new RuntimeException("Only named EventDispatcher's can be shut down.");
+ }
+
+ mAttachedToGecko = false;
+ shutdownNative();
+ dispose(false);
+
+ synchronized (sDispatchers) {
+ sDispatchers.put(mName, null);
+ }
+ }
+
+ private void dispose(final boolean force) {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+ if (geckoHandler == null) {
+ return;
+ }
+
+ geckoHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (force || !mAttachedToGecko) {
+ disposeNative();
+ }
+ }
+ });
+ }
+
+ public void registerUiThreadListener(final BundleEventListener listener, final String... events) {
+ try {
+ synchronized (mListeners) {
+ for (final String event : events) {
+ if (!BuildConfig.RELEASE_OR_BETA && mListeners.containsEntry(event, listener)) {
+ throw new IllegalStateException("Already registered " + event);
+ }
+ mListeners.add(event, listener);
+ }
+ flush(events);
+ }
+ } catch (final Exception e) {
+ throw new IllegalArgumentException("Invalid new list type", e);
+ }
+ }
+
+ public void unregisterUiThreadListener(
+ final BundleEventListener listener, final String... events) {
+ synchronized (mListeners) {
+ for (final String event : events) {
+ if (!mListeners.remove(event, listener) && !BuildConfig.RELEASE_OR_BETA) {
+ throw new IllegalArgumentException(event + " was not registered");
+ }
+ }
+ }
+ }
+
+ @WrapForJNI
+ private native boolean hasGeckoListener(final String event);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void dispatchToGecko(
+ final String event, final GeckoBundle data, final EventCallback callback);
+
+ /**
+ * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * @param type Event type
+ * @param message Bundle message
+ */
+ public void dispatch(final String type, final GeckoBundle message) {
+ dispatch(type, message, /* callback */ null);
+ }
+
+ private abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
+ @Override
+ public void sendError(final Object response) {
+ completeExceptionally(new QueryException(response));
+ }
+ }
+
+ public class QueryException extends Exception {
+ public final Object data;
+
+ public QueryException(final Object data) {
+ this.data = data;
+ }
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes when the event handler returns.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<Void> queryVoid(final String type) {
+ return queryVoid(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes when the event handler returns.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<Void> queryVoid(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given boolean value returned by the handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<Boolean> queryBoolean(final String type) {
+ return queryBoolean(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given boolean value returned by the handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<Boolean> queryBoolean(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given String value returned by the handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<String> queryString(final String type) {
+ return queryString(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given String value returned by the handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<String> queryString(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the
+ * handler.
+ *
+ * @param type Event type
+ */
+ public GeckoResult<GeckoBundle> queryBundle(final String type) {
+ return queryBundle(type, null);
+ }
+
+ /**
+ * Query event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the
+ * handler.
+ *
+ * @param type Event type
+ * @param message GeckoBundle message
+ */
+ public GeckoResult<GeckoBundle> queryBundle(final String type, final GeckoBundle message) {
+ return query(type, message);
+ }
+
+ private <T> GeckoResult<T> query(final String type, final GeckoBundle message) {
+ final CallbackResult<T> result =
+ new CallbackResult<T>() {
+ @Override
+ @SuppressWarnings("unchecked") // Not a lot we can do about this :(
+ public void sendSuccess(final Object response) {
+ complete((T) response);
+ }
+ };
+
+ dispatch(type, message, result);
+ return result;
+ }
+
+ /**
+ * Flushes pending messages of given types.
+ *
+ * <p>All unhandled messages are put into a pending state by default for named EventDispatcher
+ * obtained from {@link #byName}.
+ *
+ * @param types Types of message to flush.
+ */
+ private void flush(final String[] types) {
+ final Set<String> typeSet = new HashSet<>(Arrays.asList(types));
+
+ final Deque<Message> pendingMessages;
+ synchronized (mPendingMessages) {
+ pendingMessages = mPendingMessages;
+ mPendingMessages = new ArrayDeque<>(pendingMessages.size());
+ }
+
+ Message message;
+ while (!pendingMessages.isEmpty()) {
+ message = pendingMessages.removeFirst();
+ if (typeSet.contains(message.type)) {
+ dispatchToThreads(message.type, message.bundle, message.callback);
+ } else {
+ synchronized (mPendingMessages) {
+ mPendingMessages.addLast(message);
+ }
+ }
+ }
+ }
+
+ /**
+ * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners).
+ *
+ * @param type Event type
+ * @param message Bundle message
+ * @param callback Optional object for callbacks from events.
+ */
+ @AnyThread
+ private void dispatch(
+ final String type, final GeckoBundle message, final EventCallback callback) {
+ final boolean isGeckoReady;
+ synchronized (this) {
+ isGeckoReady = isReadyForDispatchingToGecko();
+ if (isGeckoReady && mAttachedToGecko && hasGeckoListener(type)) {
+ dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback));
+ return;
+ }
+ }
+
+ dispatchToThreads(type, message, callback, isGeckoReady);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private boolean dispatchToThreads(
+ final String type, final GeckoBundle message, final EventCallback callback) {
+ return dispatchToThreads(type, message, callback, /* isGeckoReady */ true);
+ }
+
+ private boolean dispatchToThreads(
+ final String type,
+ final GeckoBundle message,
+ final EventCallback callback,
+ final boolean isGeckoReady) {
+ // We need to hold the lock throughout dispatching, to ensure the listeners list
+ // is consistent, while we iterate over it. We don't have to worry about listeners
+ // running for a long time while we have the lock, because the listeners will run
+ // on a separate thread.
+ synchronized (mListeners) {
+ if (mListeners.containsKey(type)) {
+ // Use a delegate to make sure callbacks happen on a specific thread.
+ final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback);
+
+ // Event listeners will call | callback.sendError | if applicable.
+ for (final BundleEventListener listener : mListeners.get(type)) {
+ ThreadUtils.getUiHandler()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ final Double startTime = GeckoJavaSampler.tryToGetProfilerTime();
+ listener.handleMessage(type, message, wrappedCallback);
+ GeckoJavaSampler.addMarker(
+ "EventDispatcher handleMessage", startTime, null, type);
+ }
+ });
+ }
+ return true;
+ }
+ }
+
+ if (!isGeckoReady) {
+ // Usually, we discard an event if there is no listeners for it by
+ // the time of the dispatch. However, if Gecko(View) is not ready and
+ // there is no listener for this event that's possibly headed to
+ // Gecko, we make a special exception to queue this event until
+ // Gecko(View) is ready. This way, Gecko can first register its
+ // listeners, and accept the event when it is ready.
+ mNativeQueue.queueUntilReady(
+ this,
+ "dispatchToGecko",
+ String.class,
+ type,
+ GeckoBundle.class,
+ message,
+ EventCallback.class,
+ JavaCallbackDelegate.wrap(callback));
+ return true;
+ }
+
+ // Named EventDispatchers use pending messages
+ if (mName != null) {
+ synchronized (mPendingMessages) {
+ mPendingMessages.addLast(new Message(type, message, callback));
+ }
+ return true;
+ }
+
+ final String error = "No listener for " + type;
+ if (callback != null) {
+ callback.sendError(error);
+ }
+
+ Log.w(LOGTAG, error);
+ return false;
+ }
+
+ @WrapForJNI
+ public boolean hasListener(final String event) {
+ synchronized (mListeners) {
+ return mListeners.containsKey(event);
+ }
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ dispose(true);
+ }
+
+ private static class NativeCallbackDelegate extends JNIObject implements EventCallback {
+ @WrapForJNI(calledFrom = "gecko")
+ private NativeCallbackDelegate() {}
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // We dispose in finalize().
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // EventCallback
+ public native void sendSuccess(Object response);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ @Override // EventCallback
+ public native void sendError(Object response);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ @Override // Object
+ protected native void finalize();
+ }
+
+ private static class JavaCallbackDelegate implements EventCallback {
+ private final Thread mOriginalThread = Thread.currentThread();
+ private final EventCallback mCallback;
+
+ public static EventCallback wrap(final EventCallback callback) {
+ if (callback == null) {
+ return null;
+ }
+ if (callback instanceof NativeCallbackDelegate) {
+ // NativeCallbackDelegate always posts to Gecko thread if needed.
+ return callback;
+ }
+ return new JavaCallbackDelegate(callback);
+ }
+
+ JavaCallbackDelegate(final EventCallback callback) {
+ mCallback = callback;
+ }
+
+ private void makeCallback(final boolean callSuccess, final Object rawResponse) {
+ final Object response;
+ if (rawResponse instanceof Number) {
+ // There is ambiguity because a number can be converted to either int or
+ // double, so e.g. the user can be expecting a double when we give it an
+ // int. To avoid these pitfalls, we disallow all numbers. The workaround
+ // is to wrap the number in a JS object / GeckoBundle, which supports
+ // type coersion for numbers.
+ throw new UnsupportedOperationException("Cannot use number as Java callback result");
+ } else if (rawResponse != null && rawResponse.getClass().isArray()) {
+ // Same with arrays.
+ throw new UnsupportedOperationException("Cannot use arrays as Java callback result");
+ } else if (rawResponse instanceof Character) {
+ response = rawResponse.toString();
+ } else {
+ response = rawResponse;
+ }
+
+ // Call back synchronously if we happen to be on the same thread as the thread
+ // making the original request.
+ if (ThreadUtils.isOnThread(mOriginalThread)) {
+ if (callSuccess) {
+ mCallback.sendSuccess(response);
+ } else {
+ mCallback.sendError(response);
+ }
+ return;
+ }
+
+ // Make callback on the thread of the original request, if the original thread
+ // is the UI or Gecko thread. Otherwise default to the background thread.
+ final Handler handler =
+ mOriginalThread == ThreadUtils.getUiThread()
+ ? ThreadUtils.getUiHandler()
+ : mOriginalThread == ThreadUtils.sGeckoThread
+ ? ThreadUtils.sGeckoHandler
+ : ThreadUtils.getBackgroundHandler();
+ final EventCallback callback = mCallback;
+
+ handler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (callSuccess) {
+ callback.sendSuccess(response);
+ } else {
+ callback.sendError(response);
+ }
+ }
+ });
+ }
+
+ @Override // EventCallback
+ public void sendSuccess(final Object response) {
+ makeCallback(/* success */ true, response);
+ }
+
+ @Override // EventCallback
+ public void sendError(final Object response) {
+ makeCallback(/* success */ false, response);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java
new file mode 100644
index 0000000000..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<? extends Service> handlerService) {
+ super(handlerService);
+ }
+
+ @Override
+ protected String getAppPackageName() {
+ final Context appContext = getAppContext();
+ if (appContext == null) {
+ return "<unknown>";
+ }
+ return appContext.getPackageName();
+ }
+
+ @Override
+ protected Context getAppContext() {
+ return getApplicationContext();
+ }
+
+ @Override
+ public boolean reportException(final Thread thread, final Throwable exc) {
+ try {
+ if (exc instanceof OutOfMemoryError) {
+ final SharedPreferences prefs =
+ getApplicationContext().getSharedPreferences(APP_PREFS_NAME, 0);
+ final SharedPreferences.Editor editor = prefs.edit();
+ editor.putBoolean(PREFS_OOM_EXCEPTION, true);
+
+ // Synchronously write to disk so we know it's done before we
+ // shutdown
+ editor.commit();
+ }
+
+ reportJavaCrash(exc, getExceptionStackTrace(exc));
+
+ } catch (final Throwable e) {
+ }
+
+ // reportJavaCrash should have caused us to hard crash. If we're still here,
+ // it probably means Gecko is not loaded, and we should do something else.
+ if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) {
+ // Only use Java crash reporter if enabled on official build.
+ return super.reportException(thread, exc);
+ }
+ return false;
+ }
+ }
+
+ private static String sAppNotes;
+ private static CrashHandler sCrashHandler;
+
+ public static synchronized CrashHandler ensureCrashHandling(
+ final Class<? extends Service> handler) {
+ if (sCrashHandler == null) {
+ sCrashHandler = new GeckoCrashHandler(handler);
+ }
+
+ return sCrashHandler;
+ }
+
+ private static Class<? extends Service> sCrashHandlerService;
+
+ public static synchronized void setCrashHandlerService(
+ final Class<? extends Service> handlerService) {
+ sCrashHandlerService = handlerService;
+ }
+
+ public static synchronized Class<? extends Service> getCrashHandlerService() {
+ return sCrashHandlerService;
+ }
+
+ @WrapForJNI(exceptionMode = "ignore")
+ /* package */ static synchronized String getAppNotes() {
+ return sAppNotes;
+ }
+
+ public static synchronized void appendAppNotesToCrashReport(final String notes) {
+ if (sAppNotes == null) {
+ sAppNotes = notes;
+ } else {
+ sAppNotes += '\n' + notes;
+ }
+ }
+
+ private static volatile boolean locationHighAccuracyEnabled;
+ private static volatile boolean locationListeningRequested = false;
+ private static volatile boolean locationPaused = false;
+
+ // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB.
+ private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768;
+
+ private static int sDensityDpi;
+ private static Float sDensity;
+ private static int sScreenDepth;
+ private static boolean sUseMaxScreenDepth;
+ private static Float sScreenRefreshRate;
+
+ /* Is the value in sVibrationEndTime valid? */
+ private static boolean sVibrationMaybePlaying;
+
+ /* Time (in System.nanoTime() units) when the currently-playing vibration
+ * is scheduled to end. This value is valid only when
+ * sVibrationMaybePlaying is true. */
+ private static long sVibrationEndTime;
+
+ private static Sensor gAccelerometerSensor;
+ private static Sensor gLinearAccelerometerSensor;
+ private static Sensor gGyroscopeSensor;
+ private static Sensor gOrientationSensor;
+ private static Sensor gLightSensor;
+ private static Sensor gRotationVectorSensor;
+ private static Sensor gGameRotationVectorSensor;
+
+ /*
+ * Keep in sync with constants found here:
+ * http://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl
+ */
+ public static final int WPL_STATE_START = 0x00000001;
+ public static final int WPL_STATE_STOP = 0x00000010;
+ public static final int WPL_STATE_IS_DOCUMENT = 0x00020000;
+ public static final int WPL_STATE_IS_NETWORK = 0x00040000;
+
+ /* Keep in sync with constants found here:
+ http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl
+ */
+ public static final int LINK_TYPE_UNKNOWN = 0;
+ public static final int LINK_TYPE_ETHERNET = 1;
+ public static final int LINK_TYPE_USB = 2;
+ public static final int LINK_TYPE_WIFI = 3;
+ public static final int LINK_TYPE_WIMAX = 4;
+ public static final int LINK_TYPE_MOBILE = 9;
+
+ public static final String PREFS_OOM_EXCEPTION = "OOMException";
+
+ /* The Android-side API: API methods that Android calls */
+
+ // helper methods
+ @WrapForJNI
+ /* package */ static native void reportJavaCrash(Throwable exc, String stackTrace);
+
+ private static Rect sScreenSizeOverride;
+
+ @WrapForJNI(stubName = "NotifyObservers", dispatchTo = "gecko")
+ private static native void nativeNotifyObservers(String topic, String data);
+
+ @WrapForJNI(stubName = "AppendAppNotesToCrashReport", dispatchTo = "gecko")
+ public static native void nativeAppendAppNotesToCrashReport(final String notes);
+
+ @RobocopTarget
+ public static void notifyObservers(final String topic, final String data) {
+ notifyObservers(topic, data, GeckoThread.State.RUNNING);
+ }
+
+ public static void notifyObservers(
+ final String topic, final String data, final GeckoThread.State state) {
+ if (GeckoThread.isStateAtLeast(state)) {
+ nativeNotifyObservers(topic, data);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ state,
+ GeckoAppShell.class,
+ "nativeNotifyObservers",
+ String.class,
+ topic,
+ String.class,
+ data);
+ }
+ }
+
+ /*
+ * The Gecko-side API: API methods that Gecko calls
+ */
+
+ @WrapForJNI(exceptionMode = "ignore")
+ private static String getExceptionStackTrace(final Throwable e) {
+ return CrashHandler.getExceptionStackTrace(CrashHandler.getRootException(e));
+ }
+
+ @WrapForJNI(exceptionMode = "ignore")
+ private static synchronized void handleUncaughtException(final Throwable e) {
+ if (sCrashHandler != null) {
+ sCrashHandler.uncaughtException(null, e);
+ }
+ }
+
+ private static float getLocationAccuracy(final Location location) {
+ final float radius = location.getAccuracy();
+ return (location.hasAccuracy() && radius > 0) ? radius : 1001;
+ }
+
+ private static Location determineReliableLocation(
+ @NotNull final Location locA, @NotNull final Location locB) {
+ // The 6 seconds were chosen arbitrarily
+ final long closeTime = 6000000000L;
+ final boolean isNearSameTime =
+ Math.abs((locA.getElapsedRealtimeNanos() - locB.getElapsedRealtimeNanos())) <= closeTime;
+ final boolean isAMoreAccurate = getLocationAccuracy(locA) < getLocationAccuracy(locB);
+ final boolean isAMoreRecent = locA.getElapsedRealtimeNanos() > locB.getElapsedRealtimeNanos();
+ if (isNearSameTime) {
+ return isAMoreAccurate ? locA : locB;
+ }
+ return isAMoreRecent ? locA : locB;
+ }
+
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static @Nullable Location getLastKnownLocation(final LocationManager lm) {
+ Location lastKnownLocation = null;
+ final List<String> providers = lm.getAllProviders();
+
+ for (final String provider : providers) {
+ final Location location = lm.getLastKnownLocation(provider);
+ if (location == null) {
+ continue;
+ }
+
+ if (lastKnownLocation == null) {
+ lastKnownLocation = location;
+ continue;
+ }
+ lastKnownLocation = determineReliableLocation(lastKnownLocation, location);
+ }
+ return lastKnownLocation;
+ }
+
+ // Toggles the location listeners on/off, which will then provide/stop location information
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized boolean enableLocationUpdates(final boolean enable) {
+ locationListeningRequested = enable;
+ final boolean canListen = updateLocationListeners();
+ if (!canListen && locationListeningRequested) {
+ // Didn't successfully start listener when requested
+ locationListeningRequested = false;
+ }
+ return canListen;
+ }
+
+ // Permissions are explicitly checked when requesting content permission.
+ @SuppressLint("MissingPermission")
+ private static synchronized boolean updateLocationListeners() {
+ final boolean shouldListen = locationListeningRequested && !locationPaused;
+ final LocationManager lm = getLocationManager(getApplicationContext());
+ if (lm == null) {
+ return false;
+ }
+
+ if (!shouldListen) {
+ // Could not complete request, because paused
+ if (locationListeningRequested) {
+ return false;
+ }
+ lm.removeUpdates(sAndroidListeners);
+ return true;
+ }
+
+ if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER)
+ && !lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ return false;
+ }
+
+ final Location lastKnownLocation = getLastKnownLocation(lm);
+ if (lastKnownLocation != null) {
+ sAndroidListeners.onLocationChanged(lastKnownLocation);
+ }
+
+ final Criteria criteria = new Criteria();
+ criteria.setSpeedRequired(false);
+ criteria.setBearingRequired(false);
+ criteria.setAltitudeRequired(false);
+ if (locationHighAccuracyEnabled) {
+ criteria.setAccuracy(Criteria.ACCURACY_FINE);
+ } else {
+ criteria.setAccuracy(Criteria.ACCURACY_COARSE);
+ }
+
+ final String provider = lm.getBestProvider(criteria, true);
+ if (provider == null) {
+ return false;
+ }
+
+ final Looper l = Looper.getMainLooper();
+ lm.requestLocationUpdates(provider, 100, 0.5f, sAndroidListeners, l);
+ return true;
+ }
+
+ public static void pauseLocation() {
+ locationPaused = true;
+ updateLocationListeners();
+ }
+
+ public static void resumeLocation() {
+ locationPaused = false;
+ updateLocationListeners();
+ }
+
+ private static LocationManager getLocationManager(final Context context) {
+ try {
+ return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
+ } catch (final NoSuchFieldError e) {
+ // Some Tegras throw exceptions about missing the CONTROL_LOCATION_UPDATES permission,
+ // which allows enabling/disabling location update notifications from the cell radio.
+ // CONTROL_LOCATION_UPDATES is not for use by normal applications, but we might be
+ // hitting this problem if the Tegras are confused about missing cell radios.
+ Log.e(LOGTAG, "LOCATION_SERVICE not found?!", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableLocationHighAccuracy(final boolean enable) {
+ locationHighAccuracyEnabled = enable;
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ /* package */ static native void onSensorChanged(
+ int halType, float x, float y, float z, float w, long time);
+
+ @WrapForJNI(calledFrom = "any", dispatchTo = "gecko")
+ /* package */ static native void onLocationChanged(
+ double latitude,
+ double longitude,
+ double altitude,
+ float accuracy,
+ float altitudeAccuracy,
+ float heading,
+ float speed);
+
+ private static class AndroidListeners implements SensorEventListener, LocationListener {
+ @Override
+ public void onAccuracyChanged(final Sensor sensor, final int accuracy) {}
+
+ @Override
+ public void onSensorChanged(final SensorEvent s) {
+ final int sensorType = s.sensor.getType();
+ int halType = 0;
+ float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f;
+ // SensorEvent timestamp is in nanoseconds, Gecko expects microseconds.
+ final long time = s.timestamp / 1000;
+
+ switch (sensorType) {
+ case Sensor.TYPE_ACCELEROMETER:
+ case Sensor.TYPE_LINEAR_ACCELERATION:
+ case Sensor.TYPE_ORIENTATION:
+ if (sensorType == Sensor.TYPE_ACCELEROMETER) {
+ halType = SENSOR_ACCELERATION;
+ } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION) {
+ halType = SENSOR_LINEAR_ACCELERATION;
+ } else {
+ halType = SENSOR_ORIENTATION;
+ }
+ x = s.values[0];
+ y = s.values[1];
+ z = s.values[2];
+ break;
+
+ case Sensor.TYPE_GYROSCOPE:
+ halType = SENSOR_GYROSCOPE;
+ x = (float) Math.toDegrees(s.values[0]);
+ y = (float) Math.toDegrees(s.values[1]);
+ z = (float) Math.toDegrees(s.values[2]);
+ break;
+
+ case Sensor.TYPE_LIGHT:
+ halType = SENSOR_LIGHT;
+ x = s.values[0];
+ break;
+
+ case Sensor.TYPE_ROTATION_VECTOR:
+ case Sensor.TYPE_GAME_ROTATION_VECTOR: // API >= 18
+ halType =
+ (sensorType == Sensor.TYPE_ROTATION_VECTOR
+ ? SENSOR_ROTATION_VECTOR
+ : SENSOR_GAME_ROTATION_VECTOR);
+ x = s.values[0];
+ y = s.values[1];
+ z = s.values[2];
+ if (s.values.length >= 4) {
+ w = s.values[3];
+ } else {
+ // s.values[3] was optional in API <= 18, so we need to compute it
+ // The values form a unit quaternion, so we can compute the angle of
+ // rotation purely based on the given 3 values.
+ w =
+ 1.0f
+ - s.values[0] * s.values[0]
+ - s.values[1] * s.values[1]
+ - s.values[2] * s.values[2];
+ w = (w > 0.0f) ? (float) Math.sqrt(w) : 0.0f;
+ }
+ break;
+ }
+
+ GeckoAppShell.onSensorChanged(halType, x, y, z, w, time);
+ }
+
+ // Geolocation.
+ @Override
+ public void onLocationChanged(final Location location) {
+ // No logging here: user-identifying information.
+
+ final double altitude = location.hasAltitude() ? location.getAltitude() : Double.NaN;
+
+ final float accuracy = location.hasAccuracy() ? location.getAccuracy() : Float.NaN;
+
+ final float altitudeAccuracy =
+ Build.VERSION.SDK_INT >= 26 && location.hasVerticalAccuracy()
+ ? location.getVerticalAccuracyMeters()
+ : Float.NaN;
+
+ final float speed = location.hasSpeed() ? location.getSpeed() : Float.NaN;
+
+ final float heading = location.hasBearing() ? location.getBearing() : Float.NaN;
+
+ // nsGeoPositionCoords will convert NaNs to null for optional
+ // properties of the JavaScript Coordinates object.
+ GeckoAppShell.onLocationChanged(
+ location.getLatitude(),
+ location.getLongitude(),
+ altitude,
+ accuracy,
+ altitudeAccuracy,
+ heading,
+ speed);
+ }
+
+ @Override
+ public void onProviderDisabled(final String provider) {}
+
+ @Override
+ public void onProviderEnabled(final String provider) {}
+
+ @Override
+ public void onStatusChanged(final String provider, final int status, final Bundle extras) {}
+ }
+
+ private static final AndroidListeners sAndroidListeners = new AndroidListeners();
+
+ private static SimpleArrayMap<String, PowerManager.WakeLock> sWakeLocks;
+
+ /** Wake-lock for the CPU. */
+ static final String WAKE_LOCK_CPU = "cpu";
+
+ /** Wake-lock for the screen. */
+ static final String WAKE_LOCK_SCREEN = "screen";
+
+ /** Wake-lock for the audio-playing, eqaul to LOCK_CPU. */
+ static final String WAKE_LOCK_AUDIO_PLAYING = "audio-playing";
+
+ /** Wake-lock for the video-playing, eqaul to LOCK_SCREEN.. */
+ static final String WAKE_LOCK_VIDEO_PLAYING = "video-playing";
+
+ static final int WAKE_LOCKS_COUNT = 2;
+
+ /** No one holds the wake-lock. */
+ static final int WAKE_LOCK_STATE_UNLOCKED = 0;
+
+ /** The wake-lock is held by a foreground window. */
+ static final int WAKE_LOCK_STATE_LOCKED_FOREGROUND = 1;
+
+ /** The wake-lock is held by a background window. */
+ static final int WAKE_LOCK_STATE_LOCKED_BACKGROUND = 2;
+
+ @SuppressLint("Wakelock") // We keep the wake lock independent from the function
+ // scope, so we need to suppress the linter warning.
+ private static void setWakeLockState(final String lock, final int state) {
+ if (sWakeLocks == null) {
+ sWakeLocks = new SimpleArrayMap<>(WAKE_LOCKS_COUNT);
+ }
+
+ PowerManager.WakeLock wl = sWakeLocks.get(lock);
+
+ // we should still hold the lock for background audio.
+ if (WAKE_LOCK_AUDIO_PLAYING.equals(lock) && state == WAKE_LOCK_STATE_LOCKED_BACKGROUND) {
+ return;
+ }
+
+ if (state == WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl == null) {
+ final PowerManager pm =
+ (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE);
+
+ if (WAKE_LOCK_CPU.equals(lock) || WAKE_LOCK_AUDIO_PLAYING.equals(lock)) {
+ wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, lock);
+ } else if (WAKE_LOCK_SCREEN.equals(lock) || WAKE_LOCK_VIDEO_PLAYING.equals(lock)) {
+ // ON_AFTER_RELEASE is set, the user activity timer will be reset when the
+ // WakeLock is released, causing the illumination to remain on a bit longer.
+ wl =
+ pm.newWakeLock(
+ PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, lock);
+ } else {
+ Log.w(LOGTAG, "Unsupported wake-lock: " + lock);
+ return;
+ }
+
+ wl.acquire();
+ sWakeLocks.put(lock, wl);
+ } else if (state != WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl != null) {
+ wl.release();
+ sWakeLocks.remove(lock);
+ }
+ }
+
+ @SuppressWarnings("fallthrough")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableSensor(final int aSensortype) {
+ final SensorManager sm =
+ (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+
+ switch (aSensortype) {
+ case SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor == null) {
+ gGameRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR);
+ }
+ if (gGameRotationVectorSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gGameRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gGameRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor == null) {
+ gRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
+ }
+ if (gRotationVectorSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ if (gRotationVectorSensor != null) {
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ORIENTATION:
+ if (gOrientationSensor == null) {
+ gOrientationSensor = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION);
+ }
+ if (gOrientationSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gOrientationSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_ACCELERATION:
+ if (gAccelerometerSensor == null) {
+ gAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
+ }
+ if (gAccelerometerSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_LIGHT:
+ if (gLightSensor == null) {
+ gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT);
+ }
+ if (gLightSensor != null) {
+ sm.registerListener(sAndroidListeners, gLightSensor, SensorManager.SENSOR_DELAY_NORMAL);
+ }
+ break;
+
+ case SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor == null) {
+ gLinearAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION);
+ }
+ if (gLinearAccelerometerSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gLinearAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ case SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor == null) {
+ gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
+ }
+ if (gGyroscopeSensor != null) {
+ sm.registerListener(
+ sAndroidListeners, gGyroscopeSensor, SensorManager.SENSOR_DELAY_FASTEST);
+ }
+ break;
+
+ default:
+ Log.w(LOGTAG, "Error! Can't enable unknown SENSOR type " + aSensortype);
+ }
+ }
+
+ @SuppressWarnings("fallthrough")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableSensor(final int aSensortype) {
+ final SensorManager sm =
+ (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE);
+
+ switch (aSensortype) {
+ case SENSOR_GAME_ROTATION_VECTOR:
+ if (gGameRotationVectorSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gGameRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ROTATION_VECTOR:
+ if (gRotationVectorSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gRotationVectorSensor);
+ break;
+ }
+ // Fallthrough
+
+ case SENSOR_ORIENTATION:
+ if (gOrientationSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gOrientationSensor);
+ }
+ break;
+
+ case SENSOR_ACCELERATION:
+ if (gAccelerometerSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gAccelerometerSensor);
+ }
+ break;
+
+ case SENSOR_LIGHT:
+ if (gLightSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gLightSensor);
+ }
+ break;
+
+ case SENSOR_LINEAR_ACCELERATION:
+ if (gLinearAccelerometerSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gLinearAccelerometerSensor);
+ }
+ break;
+
+ case SENSOR_GYROSCOPE:
+ if (gGyroscopeSensor != null) {
+ sm.unregisterListener(sAndroidListeners, gGyroscopeSensor);
+ }
+ break;
+ default:
+ Log.w(LOGTAG, "Error! Can't disable unknown SENSOR type " + aSensortype);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void moveTaskToBack() {
+ // This is a vestige, to be removed as full-screen support for GeckoView is implemented.
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Encoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(true /* aIsEncoder */);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean hasHWVP8Decoder() {
+ return HardwareCodecCapabilityUtils.hasHWVP8(false /* aIsEncoder */);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getExtensionFromMimeType(final String aMimeType) {
+ return MimeTypeMap.getSingleton().getExtensionFromMimeType(aMimeType);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String getMimeTypeFromExtensions(final String aFileExt) {
+ final StringTokenizer st = new StringTokenizer(aFileExt, ".,; ");
+ String type = null;
+ String subType = null;
+ while (st.hasMoreElements()) {
+ final String ext = st.nextToken();
+ final String mt = getMimeTypeFromExtension(ext);
+ if (mt == null) continue;
+ final int slash = mt.indexOf('/');
+ final String tmpType = mt.substring(0, slash);
+ if (!tmpType.equalsIgnoreCase(type)) type = type == null ? tmpType : "*";
+ final String tmpSubType = mt.substring(slash + 1);
+ if (!tmpSubType.equalsIgnoreCase(subType)) subType = subType == null ? tmpSubType : "*";
+ }
+ if (type == null) type = "*";
+ if (subType == null) subType = "*";
+ return type + "/" + subType;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void notifyAlertListener(String name, String topic, String cookie);
+
+ /**
+ * Called by the NotificationListener to notify Gecko that a previously shown notification has
+ * been closed.
+ */
+ public static void onNotificationClose(final String name, final String cookie) {
+ if (GeckoThread.isRunning()) {
+ notifyAlertListener(name, "alertfinished", cookie);
+ }
+ }
+
+ /**
+ * Called by the NotificationListener to notify Gecko that a previously shown notification has
+ * been clicked on.
+ */
+ public static void onNotificationClick(final String name, final String cookie) {
+ if (GeckoThread.isRunning()) {
+ notifyAlertListener(name, "alertclickcallback", cookie);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ GeckoAppShell.class,
+ "notifyAlertListener",
+ name,
+ "alertclickcallback",
+ cookie);
+ }
+ }
+
+ public static synchronized void setDisplayDpiOverride(@Nullable final Integer dpi) {
+ if (dpi == null) {
+ return;
+ }
+ if (sDensityDpi != 0) {
+ Log.e(LOGTAG, "Tried to override screen DPI after it's already been set");
+ return;
+ }
+ sDensityDpi = dpi;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized int getDpi() {
+ if (sDensityDpi == 0) {
+ sDensityDpi = getApplicationContext().getResources().getDisplayMetrics().densityDpi;
+ }
+ return sDensityDpi;
+ }
+
+ public static synchronized void setDisplayDensityOverride(@Nullable final Float density) {
+ if (density == null) {
+ return;
+ }
+ if (sDensity != null) {
+ Log.e(LOGTAG, "Tried to override screen density after it's already been set");
+ return;
+ }
+ sDensity = density;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized float getDensity() {
+ if (sDensity == null) {
+ sDensity = Float.valueOf(getApplicationContext().getResources().getDisplayMetrics().density);
+ }
+
+ return sDensity;
+ }
+
+ private static int sTotalRam;
+
+ private static int getTotalRam(final Context context) {
+ if (sTotalRam == 0) {
+ final ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo();
+ final ActivityManager am =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ am.getMemoryInfo(memInfo); // `getMemoryInfo()` returns a value in B. Convert to MB.
+ sTotalRam = (int) (memInfo.totalMem / (1024 * 1024));
+ Log.d(LOGTAG, "System memory: " + sTotalRam + "MB.");
+ }
+
+ return sTotalRam;
+ }
+
+ private static boolean isHighMemoryDevice(final Context context) {
+ return getTotalRam(context) > HIGH_MEMORY_DEVICE_THRESHOLD_MB;
+ }
+
+ public static synchronized void useMaxScreenDepth(final boolean enable) {
+ sUseMaxScreenDepth = enable;
+ }
+
+ /** Returns the colour depth of the default screen. This will either be 32, 24 or 16. */
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized int getScreenDepth() {
+ if (sScreenDepth == 0) {
+ sScreenDepth = 16;
+ final Context applicationContext = getApplicationContext();
+ final PixelFormat info = new PixelFormat();
+ final WindowManager wm =
+ (WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE);
+ PixelFormat.getPixelFormatInfo(wm.getDefaultDisplay().getPixelFormat(), info);
+ if (info.bitsPerPixel >= 24 && isHighMemoryDevice(applicationContext)) {
+ sScreenDepth = sUseMaxScreenDepth ? info.bitsPerPixel : 24;
+ }
+ }
+
+ return sScreenDepth;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static synchronized float getScreenRefreshRate() {
+ if (sScreenRefreshRate != null) {
+ return sScreenRefreshRate;
+ }
+
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final float refreshRate = wm.getDefaultDisplay().getRefreshRate();
+ // Android 11+ supports multiple refresh rate. So we have to get refresh rate per call.
+ // https://source.android.com/docs/core/graphics/multiple-refresh-rate
+ if (Build.VERSION.SDK_INT < 30) {
+ // Until Android 10, refresh rate is fixed, so we can cache it.
+ sScreenRefreshRate = Float.valueOf(refreshRate);
+ }
+ return refreshRate;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void performHapticFeedback(final boolean aIsLongPress) {
+ // Don't perform haptic feedback if a vibration is currently playing,
+ // because the haptic feedback will nuke the vibration.
+ if (!sVibrationMaybePlaying || System.nanoTime() >= sVibrationEndTime) {
+ final int[] pattern;
+ if (aIsLongPress) {
+ pattern = new int[] {0, 1, 20, 21};
+ } else {
+ pattern = new int[] {0, 10, 20, 30};
+ }
+ vibrateOnHapticFeedbackEnabled(pattern);
+ sVibrationMaybePlaying = false;
+ sVibrationEndTime = 0;
+ }
+ }
+
+ private static Vibrator vibrator() {
+ return (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE);
+ }
+
+ // Helper method to convert integer array to long array.
+ private static long[] convertIntToLongArray(final int[] input) {
+ final long[] output = new long[input.length];
+ for (int i = 0; i < input.length; i++) {
+ output[i] = input[i];
+ }
+ return output;
+ }
+
+ // Vibrate only if haptic feedback is enabled.
+ private static void vibrateOnHapticFeedbackEnabled(final int[] milliseconds) {
+ if (Settings.System.getInt(
+ getApplicationContext().getContentResolver(),
+ Settings.System.HAPTIC_FEEDBACK_ENABLED,
+ 0)
+ > 0) {
+ if (milliseconds.length == 1) {
+ vibrate(milliseconds[0]);
+ } else {
+ vibrate(convertIntToLongArray(milliseconds), -1);
+ }
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void vibrate(final long milliseconds) {
+ sVibrationEndTime = System.nanoTime() + milliseconds * 1000000;
+ sVibrationMaybePlaying = true;
+ try {
+ vibrator().vibrate(milliseconds);
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void vibrate(final long[] pattern, final int repeat) {
+ // If pattern.length is odd, the last element in the pattern is a
+ // meaningless delay, so don't include it in vibrationDuration.
+ long vibrationDuration = 0;
+ final int iterLen = pattern.length & ~1;
+ for (int i = 0; i < iterLen; i++) {
+ vibrationDuration += pattern[i];
+ }
+
+ sVibrationEndTime = System.nanoTime() + vibrationDuration * 1000000;
+ sVibrationMaybePlaying = true;
+ try {
+ vibrator().vibrate(pattern, repeat);
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ @SuppressLint("MissingPermission")
+ @WrapForJNI(calledFrom = "gecko")
+ private static void cancelVibrate() {
+ sVibrationMaybePlaying = false;
+ sVibrationEndTime = 0;
+ try {
+ vibrator().cancel();
+ } catch (final SecurityException ignore) {
+ Log.w(LOGTAG, "No VIBRATE permission");
+ }
+ }
+
+ private static ConnectivityManager sConnectivityManager;
+
+ private static void ensureConnectivityManager() {
+ if (sConnectivityManager == null) {
+ sConnectivityManager =
+ (ConnectivityManager)
+ getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean isNetworkLinkUp() {
+ ensureConnectivityManager();
+ try {
+ final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo();
+ if (info == null || !info.isConnected()) return false;
+ } catch (final SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean isNetworkLinkKnown() {
+ ensureConnectivityManager();
+ try {
+ if (sConnectivityManager.getActiveNetworkInfo() == null) return false;
+ } catch (final SecurityException se) {
+ return false;
+ }
+ return true;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getNetworkLinkType() {
+ ensureConnectivityManager();
+ final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo();
+ if (info == null) {
+ return LINK_TYPE_UNKNOWN;
+ }
+
+ switch (info.getType()) {
+ case ConnectivityManager.TYPE_ETHERNET:
+ return LINK_TYPE_ETHERNET;
+ case ConnectivityManager.TYPE_WIFI:
+ return LINK_TYPE_WIFI;
+ case ConnectivityManager.TYPE_WIMAX:
+ return LINK_TYPE_WIMAX;
+ case ConnectivityManager.TYPE_MOBILE:
+ return LINK_TYPE_MOBILE;
+ default:
+ Log.w(LOGTAG, "Ignoring the current network type.");
+ return LINK_TYPE_UNKNOWN;
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko", 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<ResolveInfo> list = pm.queryIntentActivities(intent, 0);
+ if (list.size() == 0) return null;
+
+ final ResolveInfo resolveInfo = list.get(0);
+
+ if (resolveInfo == null) return null;
+
+ final ActivityInfo activityInfo = resolveInfo.activityInfo;
+
+ return activityInfo.loadIcon(pm);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean getShowPasswordSetting() {
+ try {
+ final int showPassword =
+ Settings.System.getInt(
+ getApplicationContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1);
+ return (showPassword > 0);
+ } catch (final Exception e) {
+ return true;
+ }
+ }
+
+ private static Context sApplicationContext;
+ private static Boolean sIs24HourFormat = true;
+
+ @WrapForJNI
+ public static Context getApplicationContext() {
+ return sApplicationContext;
+ }
+
+ public static void setApplicationContext(final Context context) {
+ sApplicationContext = context;
+ }
+
+ /*
+ * Battery API related methods.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableBatteryNotifications() {
+ GeckoBatteryManager.enableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableBatteryNotifications() {
+ GeckoBatteryManager.disableNotifications();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static double[] getCurrentBatteryInformation() {
+ return GeckoBatteryManager.getCurrentInformation();
+ }
+
+ /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */
+ @WrapForJNI(calledFrom = "gecko")
+ @RobocopTarget
+ public static boolean isTablet() {
+ return HardwareUtils.isTablet(getApplicationContext());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static double[] getCurrentNetworkInformation() {
+ return GeckoNetworkManager.getInstance().getCurrentInformation();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void enableNetworkNotifications() {
+ ThreadUtils.runOnUiThread(() -> GeckoNetworkManager.getInstance().enableNotifications());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void disableNetworkNotifications() {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoNetworkManager.getInstance().disableNotifications();
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static short getScreenOrientation() {
+ return GeckoScreenOrientation.getInstance().getScreenOrientation().value;
+ }
+
+ /* package */ static int getRotation() {
+ return sScreenCompat.getRotation();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getScreenAngle() {
+ return GeckoScreenOrientation.getInstance().getAngle();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void notifyWakeLockChanged(final String topic, final String state) {
+ final int intState;
+ if ("unlocked".equals(state)) {
+ intState = WAKE_LOCK_STATE_UNLOCKED;
+ } else if ("locked-foreground".equals(state)) {
+ intState = WAKE_LOCK_STATE_LOCKED_FOREGROUND;
+ } else if ("locked-background".equals(state)) {
+ intState = WAKE_LOCK_STATE_LOCKED_BACKGROUND;
+ } else {
+ throw new IllegalArgumentException();
+ }
+ setWakeLockState(topic, intState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static String getProxyForURI(
+ final String spec, final String scheme, final String host, final int port) {
+ final ProxySelector ps = new ProxySelector();
+
+ final Proxy proxy = ps.select(scheme, host);
+ if (Proxy.NO_PROXY.equals(proxy)) {
+ return "DIRECT";
+ }
+
+ switch (proxy.type()) {
+ case HTTP:
+ return "PROXY " + proxy.address().toString();
+ case SOCKS:
+ return "SOCKS " + proxy.address().toString();
+ }
+
+ return "DIRECT";
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static int getMaxTouchPoints() {
+ final PackageManager pm = getApplicationContext().getPackageManager();
+ if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_JAZZHAND)) {
+ // at least, 5+ fingers.
+ return 5;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT)) {
+ // at least, 2+ fingers.
+ return 2;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH)) {
+ // 2 fingers
+ return 2;
+ } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) {
+ // 1 finger
+ return 1;
+ }
+ return 0;
+ }
+
+ /*
+ * Keep in sync with PointerCapabilities in ServoTypes.h
+ */
+ private static final int NO_POINTER = 0x00000000;
+ private static final int COARSE_POINTER = 0x00000001;
+ private static final int FINE_POINTER = 0x00000002;
+ private static final int HOVER_CAPABLE_POINTER = 0x00000004;
+
+ private static int getPointerCapabilities(final InputDevice inputDevice) {
+ int result = NO_POINTER;
+ final int sources = inputDevice.getSources();
+
+ // Blink checks fine pointer at first, then it check coarse pointer.
+ // So, we should use same order for compatibility.
+ // Also, if using Chrome OS, source may be SOURCE_MOUSE | SOURCE_TOUCHSCREEN | SOURCE_STYLUS
+ // even if no touch screen. So we shouldn't check TOUCHSCREEN at first.
+
+ if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_STYLUS)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)) {
+ result |= FINE_POINTER;
+ } else if (hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHSCREEN)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) {
+ result |= COARSE_POINTER;
+ }
+
+ if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)
+ || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) {
+ result |= HOVER_CAPABLE_POINTER;
+ }
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ // For any-pointer and any-hover media queries features.
+ private static int getAllPointerCapabilities() {
+ int result = NO_POINTER;
+
+ for (final int deviceId : InputDevice.getDeviceIds()) {
+ final InputDevice inputDevice = InputDevice.getDevice(deviceId);
+ if (inputDevice == null || !InputDeviceUtils.isPointerTypeDevice(inputDevice)) {
+ continue;
+ }
+
+ result |= getPointerCapabilities(inputDevice);
+ }
+
+ return result;
+ }
+
+ private static boolean hasInputDeviceSource(final int sources, final int inputDeviceSource) {
+ return (sources & inputDeviceSource) == inputDeviceSource;
+ }
+
+ public static synchronized void setScreenSizeOverride(final Rect size) {
+ sScreenSizeOverride = size;
+ }
+
+ static final ScreenCompat sScreenCompat;
+
+ private interface ScreenCompat {
+ Rect getScreenSize();
+
+ int getRotation();
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
+ private static class JellyBeanScreenCompat implements ScreenCompat {
+ public Rect getScreenSize() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Display disp = wm.getDefaultDisplay();
+ return new Rect(0, 0, disp.getWidth(), disp.getHeight());
+ }
+
+ public int getRotation() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ return wm.getDefaultDisplay().getRotation();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1)
+ private static class JellyBeanMR1ScreenCompat implements ScreenCompat {
+ public Rect getScreenSize() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ final Display disp = wm.getDefaultDisplay();
+ final Point size = new Point();
+ disp.getRealSize(size);
+ return new Rect(0, 0, size.x, size.y);
+ }
+
+ public int getRotation() {
+ final WindowManager wm =
+ (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
+ return wm.getDefaultDisplay().getRotation();
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.S)
+ private static class AndroidSScreenCompat implements ScreenCompat {
+ @SuppressLint("StaticFieldLeak")
+ private static Context sWindowContext;
+
+ private static Context getWindowContext() {
+ if (sWindowContext == null) {
+ final DisplayManager displayManager =
+ (DisplayManager) getApplicationContext().getSystemService(Context.DISPLAY_SERVICE);
+ final Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY);
+ sWindowContext =
+ getApplicationContext()
+ .createWindowContext(display, WindowManager.LayoutParams.TYPE_APPLICATION, null);
+ }
+ return sWindowContext;
+ }
+
+ public Rect getScreenSize() {
+ final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class);
+ return windowManager.getCurrentWindowMetrics().getBounds();
+ }
+
+ public int getRotation() {
+ final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class);
+ return windowManager.getDefaultDisplay().getRotation();
+ }
+ }
+
+ static {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ sScreenCompat = new AndroidSScreenCompat();
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ sScreenCompat = new JellyBeanMR1ScreenCompat();
+ } else {
+ sScreenCompat = new JellyBeanScreenCompat();
+ }
+ }
+
+ /* package */ static Rect getScreenSizeIgnoreOverride() {
+ return sScreenCompat.getScreenSize();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static synchronized Rect getScreenSize() {
+ if (sScreenSizeOverride != null) {
+ return sScreenSizeOverride;
+ }
+
+ return getScreenSizeIgnoreOverride();
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static int getAudioOutputFramesPerBuffer() {
+ final int DEFAULT = 512;
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return DEFAULT;
+ }
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return DEFAULT;
+ }
+ final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER);
+ if (prop == null) {
+ return DEFAULT;
+ }
+ return Integer.parseInt(prop);
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static int getAudioOutputSampleRate() {
+ final int DEFAULT = 44100;
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return DEFAULT;
+ }
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return DEFAULT;
+ }
+ final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE);
+ if (prop == null) {
+ return DEFAULT;
+ }
+ return Integer.parseInt(prop);
+ }
+
+ @WrapForJNI(calledFrom = "any")
+ public static void setCommunicationAudioModeOn(final boolean on) {
+ final AudioManager am =
+ (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
+ if (am == null) {
+ return;
+ }
+
+ try {
+ if (on) {
+ Log.e(LOGTAG, "Setting communication mode ON");
+ // This shouldn't throw, but does throw NullPointerException on a very
+ // small number of devices.
+ am.startBluetoothSco();
+ am.setBluetoothScoOn(true);
+ } else {
+ Log.e(LOGTAG, "Setting communication mode OFF");
+ am.stopBluetoothSco();
+ am.setBluetoothScoOn(false);
+ }
+ } catch (final SecurityException | NullPointerException e) {
+ Log.e(LOGTAG, "could not set communication mode", e);
+ }
+ }
+
+ private static String getLanguageTag(final Locale locale) {
+ final StringBuilder out = new StringBuilder(locale.getLanguage());
+ final String country = locale.getCountry();
+ final String variant = locale.getVariant();
+ if (!TextUtils.isEmpty(country)) {
+ out.append('-').append(country);
+ }
+ if (!TextUtils.isEmpty(variant)) {
+ out.append('-').append(variant);
+ }
+ // e.g. "en", "en-US", or "en-US-POSIX".
+ return out.toString();
+ }
+
+ @WrapForJNI
+ public static String[] getDefaultLocales() {
+ // XXX We may have to convert some language codes such as "id" vs "in".
+ if (Build.VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ final String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ final String[] locales = new String[1];
+ final Locale locale = Locale.getDefault();
+ if (Build.VERSION.SDK_INT >= 21) {
+ locales[0] = locale.toLanguageTag();
+ return locales;
+ }
+
+ locales[0] = getLanguageTag(locale);
+ return locales;
+ }
+
+ public static void setIs24HourFormat(final Boolean is24HourFormat) {
+ sIs24HourFormat = is24HourFormat;
+ }
+
+ @WrapForJNI
+ public static boolean getIs24HourFormat() {
+ return sIs24HourFormat;
+ }
+
+ @WrapForJNI
+ public static String getAppName() {
+ final Context context = getApplicationContext();
+ final ApplicationInfo info = context.getApplicationInfo();
+ final int id = info.labelRes;
+ return id == 0 ? info.nonLocalizedLabel.toString() : context.getString(id);
+ }
+
+ @WrapForJNI(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<Boolean> 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.
+ *
+ * <p>This class is thread safe because it uses synchronized on accesses to its mutable state. One
+ * exception is {@link #isProfilerActive()}: see the javadoc for details.
+ */
+public class GeckoJavaSampler {
+ private static final String LOGTAG = "GeckoJavaSampler";
+
+ /**
+ * The thread ID to use for the main thread instead of its true thread ID.
+ *
+ * <p>The main thread is sampled twice: once for native code and once on the JVM. The native
+ * version uses the thread's id so we replace it to avoid a collision. We use this thread ID
+ * because it's unlikely any other thread currently has it. We can't use 0 because 0 is considered
+ * "unspecified" in native code:
+ * https://searchfox.org/mozilla-central/rev/d4ebb53e719b913afdbcf7c00e162f0e96574701/mozglue/baseprofiler/public/BaseProfilerUtils.h#194
+ */
+ private static final long REPLACEMENT_MAIN_THREAD_ID = 1;
+
+ /**
+ * The thread name to use for the main thread instead of its true thread name. The name is "main",
+ * which is ambiguous with the JS main thread, so we rename it to match the C++ replacement. We
+ * expect our code to later add a suffix to avoid a collision with the C++ thread name. See {@link
+ * #REPLACEMENT_MAIN_THREAD_ID} for related details.
+ */
+ private static final String REPLACEMENT_MAIN_THREAD_NAME = "AndroidUI";
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private static SamplingRunnable sSamplingRunnable;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private static ScheduledExecutorService sSamplingScheduler;
+
+ // See isProfilerActive for details on the AtomicReference.
+ @GuardedBy("GeckoJavaSampler.class")
+ private static final AtomicReference<ScheduledFuture<?>> sSamplingFuture =
+ new AtomicReference<>();
+
+ private static final MarkerStorage sMarkerStorage = new MarkerStorage();
+
+ /**
+ * Returns true if profiler is running and unpaused at the moment which means it's allowed to add
+ * a marker.
+ *
+ * <p>Thread policy: we want this method to be inexpensive (i.e. non-blocking) because we want to
+ * be able to use it in performance-sensitive code. That's why we rely on an AtomicReference. If
+ * this requirement didn't exist, the AtomicReference could be removed because the class thread
+ * policy is to call synchronized on mutable state access.
+ */
+ public static boolean isProfilerActive() {
+ // This value will only be present if the profiler is started and not paused.
+ return sSamplingFuture.get() != null;
+ }
+
+ // Use the same timer primitive as the profiler
+ // to get a perfect sample syncing.
+ @WrapForJNI
+ private static native double getProfilerTime();
+
+ /** Try to get the profiler time. Returns null if profiler is not running. */
+ public static @Nullable Double tryToGetProfilerTime() {
+ if (!isProfilerActive()) {
+ // Android profiler hasn't started yet.
+ return null;
+ }
+ if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ // getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ return null;
+ }
+
+ return getProfilerTime();
+ }
+
+ /**
+ * A data container for a profiler sample. This class is effectively immutable (i.e. technically
+ * mutable but never mutated after construction) so is thread safe *if it is safely published*
+ * (see Java Concurrency in Practice, 2nd Ed., Section 3.5.3 for safe publication idioms).
+ */
+ private static class Sample {
+ public final long mThreadId;
+ public final Frame[] mFrames;
+ public final double mTime;
+ public final long mJavaTime; // non-zero if Android system time is used
+
+ public Sample(final long aThreadId, final StackTraceElement[] aStack) {
+ mThreadId = aThreadId;
+ mFrames = new Frame[aStack.length];
+ mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+
+ for (int i = 0; i < aStack.length; i++) {
+ mFrames[aStack.length - 1 - i] =
+ new Frame(aStack[i].getMethodName(), aStack[i].getClassName());
+ }
+ }
+ }
+
+ /**
+ * A container for the metadata around a call in a stack. This class is thread safe by being
+ * immutable.
+ */
+ private static class Frame {
+ public final String methodName;
+ public final String className;
+
+ private Frame(final String methodName, final String className) {
+ this.methodName = methodName;
+ this.className = className;
+ }
+ }
+
+ /** A data container for thread metadata. */
+ private static class ThreadInfo {
+ private final long mId;
+ private final String mName;
+
+ public ThreadInfo(final long mId, final String mName) {
+ this.mId = mId;
+ this.mName = mName;
+ }
+
+ @WrapForJNI
+ public long getId() {
+ return mId;
+ }
+
+ @WrapForJNI
+ public String getName() {
+ return mName;
+ }
+ }
+
+ /**
+ * A data container for metadata around a marker. This class is thread safe by being immutable.
+ */
+ private static class Marker extends JNIObject {
+ /** The id of the thread this marker was captured on. */
+ private final long mThreadId;
+
+ /** Name of the marker */
+ private final String mMarkerName;
+
+ /** Either start time for the duration markers or time for a point-in-time markers. */
+ private final double mTime;
+
+ /**
+ * A fallback field of {@link #mTime} but it only exists when {@link #getProfilerTime()} is
+ * failed. It is non-zero if Android time is used.
+ */
+ private final long mJavaTime;
+
+ /** End time for the duration markers. It's zero for point-in-time markers. */
+ private final double mEndTime;
+
+ /**
+ * A fallback field of {@link #mEndTime} but it only exists when {@link #getProfilerTime()} is
+ * failed. It is non-zero if Android time is used.
+ */
+ private final long mEndJavaTime;
+
+ /** A nullable additional information field for the marker. */
+ private @Nullable final String mText;
+
+ /**
+ * Constructor for the Marker class. It initializes different kinds of markers depending on the
+ * parameters. Here are some combinations to create different kinds of markers:
+ *
+ * <p>If you want to create a marker that points a single point in time: <code>
+ * new Marker("name", null, null, null)</code> to implicitly get the time when this marker is
+ * added, or <code>new Marker("name", null, endTime, null)</code> to use an explicit time as an
+ * end time retrieved from {@link #tryToGetProfilerTime()}.
+ *
+ * <p>If you want to create a marker that has a start and end time: <code>
+ * new Marker("name", startTime, null, null)</code> to implicitly get the end time when this
+ * marker is added, or <code>new Marker("name", startTime, endTime, null)</code> to explicitly
+ * give the marker start and end time retrieved from {@link #tryToGetProfilerTime()}.
+ *
+ * <p>Last parameter is optional and can be given with any combination. This gives users the
+ * ability to add more context into a marker.
+ *
+ * @param aThreadId The id of the thread this marker was captured on.
+ * @param aMarkerName Identifier of the marker as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public Marker(
+ final long aThreadId,
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ mThreadId = getAdjustedThreadId(aThreadId);
+ mMarkerName = aMarkerName;
+ mText = aText;
+
+ if (aStartTime != null) {
+ // Start time is provided. This is an interval marker.
+ mTime = aStartTime;
+ mJavaTime = 0;
+ if (aEndTime != null) {
+ // End time is also provided.
+ mEndTime = aEndTime;
+ mEndJavaTime = 0;
+ } else {
+ // End time is not provided. Get the profiler time now and use it.
+ mEndTime =
+ GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mEndTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mEndJavaTime = mEndTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+ }
+
+ } else {
+ // Start time is not provided. This is point-in-time marker.
+ mEndTime = 0;
+ mEndJavaTime = 0;
+
+ if (aEndTime != null) {
+ // End time is also provided. Use that to point the time.
+ mTime = aEndTime;
+ mJavaTime = 0;
+ } else {
+ mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0;
+
+ // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded,
+ // or profiling hasn't started on the Gecko side yet
+ mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0;
+ }
+ }
+ }
+
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ @WrapForJNI
+ public double getStartTime() {
+ if (mJavaTime != 0) {
+ return (mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return mTime;
+ }
+
+ @WrapForJNI
+ public double getEndTime() {
+ if (mEndJavaTime != 0) {
+ return (mEndJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return mEndTime;
+ }
+
+ @WrapForJNI
+ public long getThreadId() {
+ return mThreadId;
+ }
+
+ @WrapForJNI
+ public @NonNull String getMarkerName() {
+ return mMarkerName;
+ }
+
+ @WrapForJNI
+ public @Nullable String getMarkerText() {
+ return mText;
+ }
+ }
+
+ /**
+ * Public method to add a new marker to Gecko profiler. This can be used to add a marker *inside*
+ * the geckoview code, but ideally ProfilerController methods should be used instead.
+ *
+ * @see Marker#Marker(long, String, Double, Double, String) for information about the parameter
+ * options.
+ */
+ public static void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ sMarkerStorage.addMarker(aMarkerName, aStartTime, aEndTime, aText);
+ }
+
+ /**
+ * A routine to store profiler samples. This class is thread safe because it synchronizes access
+ * to its mutable state.
+ */
+ private static class SamplingRunnable implements Runnable {
+ private final long mMainThreadId = Looper.getMainLooper().getThread().getId();
+
+ // Sampling interval that is used by start and unpause
+ public final int mInterval;
+ private final int mSampleCount;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private boolean mBufferOverflowed = false;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private @NonNull final List<Thread> mThreadsToProfile;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private final Sample[] mSamples;
+
+ @GuardedBy("GeckoJavaSampler.class")
+ private int mSamplePos;
+
+ public SamplingRunnable(
+ @NonNull final List<Thread> aThreadsToProfile,
+ final int aInterval,
+ final int aSampleCount) {
+ mThreadsToProfile = aThreadsToProfile;
+ // Sanity check of sampling interval.
+ mInterval = Math.max(1, aInterval);
+ mSampleCount = aSampleCount;
+ mSamples = new Sample[mSampleCount];
+ mSamplePos = 0;
+ }
+
+ @Override
+ public void run() {
+ synchronized (GeckoJavaSampler.class) {
+ // To minimize allocation in the critical section, we use a traditional for loop instead of
+ // a for each (i.e. `elem : coll`) loop because that allocates an iterator.
+ //
+ // We won't capture threads that are started during profiling because we iterate through an
+ // unchanging list of threads (bug 1759550).
+ for (int i = 0; i < mThreadsToProfile.size(); i++) {
+ final Thread thread = mThreadsToProfile.get(i);
+
+ // getStackTrace will return an empty trace if the thread is not alive: we call continue
+ // to avoid wasting space in the buffer for an empty sample.
+ final StackTraceElement[] stackTrace = thread.getStackTrace();
+ if (stackTrace.length == 0) {
+ continue;
+ }
+
+ mSamples[mSamplePos] = new Sample(thread.getId(), stackTrace);
+ mSamplePos += 1;
+ if (mSamplePos == mSampleCount) {
+ // Sample array is full now, go back to start of
+ // the array and override old samples
+ mSamplePos = 0;
+ mBufferOverflowed = true;
+ }
+ }
+ }
+ }
+
+ private Sample getSample(final int aSampleId) {
+ synchronized (GeckoJavaSampler.class) {
+ if (aSampleId >= mSampleCount) {
+ // Return early because there is no more sample left.
+ return null;
+ }
+
+ int samplePos = aSampleId;
+ if (mBufferOverflowed) {
+ // This is a circular buffer and the buffer is overflowed. Start
+ // of the buffer is mSamplePos now. Calculate the real index.
+ samplePos = (samplePos + mSamplePos) % mSampleCount;
+ }
+
+ // Since the array elements are initialized to null, it will return
+ // null whenever we access to an element that's not been written yet.
+ // We want it to return null in that case, so it's okay.
+ return mSamples[samplePos];
+ }
+ }
+ }
+
+ /**
+ * Returns the sample with the given sample ID.
+ *
+ * <p>Thread safety code smell: this method call is synchronized but this class returns a
+ * reference to an effectively immutable object so that the reference is accessible after
+ * synchronization ends. It's unclear if this is thread safe. However, this is safe with the
+ * current callers (because they are all synchronized and don't leak the Sample) so we don't
+ * investigate it further.
+ */
+ private static synchronized Sample getSample(final int aSampleId) {
+ return sSamplingRunnable.getSample(aSampleId);
+ }
+
+ @WrapForJNI
+ public static Marker pollNextMarker() {
+ return sMarkerStorage.pollNextMarker();
+ }
+
+ @WrapForJNI
+ public static synchronized int getRegisteredThreadCount() {
+ return sSamplingRunnable.mThreadsToProfile.size();
+ }
+
+ @WrapForJNI
+ public static synchronized ThreadInfo getRegisteredThreadInfo(final int aIndex) {
+ final Thread thread = sSamplingRunnable.mThreadsToProfile.get(aIndex);
+
+ // See REPLACEMENT_MAIN_THREAD_NAME for why we do this.
+ String adjustedThreadName =
+ thread.getId() == sSamplingRunnable.mMainThreadId
+ ? REPLACEMENT_MAIN_THREAD_NAME
+ : thread.getName();
+
+ // To distinguish JVM threads from native threads, we append a JVM-specific suffix.
+ adjustedThreadName += " (JVM)";
+ return new ThreadInfo(getAdjustedThreadId(thread.getId()), adjustedThreadName);
+ }
+
+ @WrapForJNI
+ public static synchronized long getThreadId(final int aSampleId) {
+ final Sample sample = getSample(aSampleId);
+ return getAdjustedThreadId(sample != null ? sample.mThreadId : 0);
+ }
+
+ private static synchronized long getAdjustedThreadId(final long threadId) {
+ // See REPLACEMENT_MAIN_THREAD_ID for why we do this.
+ return threadId == sSamplingRunnable.mMainThreadId ? REPLACEMENT_MAIN_THREAD_ID : threadId;
+ }
+
+ @WrapForJNI
+ public static synchronized double getSampleTime(final int aSampleId) {
+ final Sample sample = getSample(aSampleId);
+ if (sample != null) {
+ if (sample.mJavaTime != 0) {
+ return (sample.mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime();
+ }
+ return sample.mTime;
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public static synchronized String getFrameName(final int aSampleId, final int aFrameId) {
+ final Sample sample = getSample(aSampleId);
+ if (sample != null && aFrameId < sample.mFrames.length) {
+ final Frame frame = sample.mFrames[aFrameId];
+ if (frame == null) {
+ return null;
+ }
+ return frame.className + "." + frame.methodName + "()";
+ }
+ return null;
+ }
+
+ /**
+ * A start/stop-aware container for storing profiler markers.
+ *
+ * <p>This class is thread safe: see {@link #mMarkers} and other member variables for the
+ * threading policy. Start/stop are guaranteed to execute in the order they are called but other
+ * methods do not have such ordering guarantees.
+ */
+ private static class MarkerStorage {
+ /**
+ * The underlying storage for the markers. This field maintains thread safety without using
+ * synchronized everywhere by:
+ * <li>- using volatile to allow non-blocking reads
+ * <li>- leveraging a thread safe collection when accessing the underlying data
+ * <li>- looping until success for compound read-write operations
+ */
+ private volatile Queue<Marker> mMarkers;
+
+ /**
+ * The thread ids of the threads we're profiling. This field maintains thread safety by writing
+ * a read-only value to this volatile field before concurrency begins and only reading it during
+ * concurrent sections.
+ */
+ private volatile Set<Long> mProfiledThreadIds = Collections.emptySet();
+
+ MarkerStorage() {}
+
+ public synchronized void start(final int aMarkerCount, final List<Thread> aProfiledThreads) {
+ if (this.mMarkers != null) {
+ return;
+ }
+ this.mMarkers = new LinkedBlockingQueue<>(aMarkerCount);
+
+ final Set<Long> profiledThreadIds = new HashSet<>(aProfiledThreads.size());
+ for (final Thread thread : aProfiledThreads) {
+ profiledThreadIds.add(thread.getId());
+ }
+
+ // We use a temporary collection, rather than mutating the collection within the member
+ // variable, to ensure the collection is fully written before the state is made available to
+ // all threads via the volatile write into the member variable. This collection must be
+ // read-only for it to remain thread safe.
+ mProfiledThreadIds = Collections.unmodifiableSet(profiledThreadIds);
+ }
+
+ public synchronized void stop() {
+ if (this.mMarkers == null) {
+ return;
+ }
+ this.mMarkers = null;
+ mProfiledThreadIds = Collections.emptySet();
+ }
+
+ private void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ final Queue<Marker> markersQueue = this.mMarkers;
+ if (markersQueue == null) {
+ // Profiler is not active.
+ return;
+ }
+
+ final long threadId = Thread.currentThread().getId();
+ if (!mProfiledThreadIds.contains(threadId)) {
+ return;
+ }
+
+ final Marker newMarker = new Marker(threadId, aMarkerName, aStartTime, aEndTime, aText);
+ boolean successful = markersQueue.offer(newMarker);
+ while (!successful) {
+ // Marker storage is full, remove the head and add again.
+ markersQueue.poll();
+ successful = markersQueue.offer(newMarker);
+ }
+ }
+
+ private Marker pollNextMarker() {
+ final Queue<Marker> markersQueue = this.mMarkers;
+ if (markersQueue == null) {
+ // Profiler is not active.
+ return null;
+ }
+ // Retrieve and return the head of this queue.
+ // Returns null if the queue is empty.
+ return markersQueue.poll();
+ }
+ }
+
+ @WrapForJNI
+ public static void start(
+ @NonNull final Object[] aFilters, final int aInterval, final int aEntryCount) {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable != null) {
+ return;
+ }
+
+ final ScheduledFuture<?> future = sSamplingFuture.get();
+ if (future != null && !future.isDone()) {
+ return;
+ }
+
+ Log.i(LOGTAG, "Profiler starting. Calling thread: " + Thread.currentThread().getName());
+
+ // Setting a limit of 120000 (2 mins with 1ms interval) for samples and markers for now
+ // to make sure we are not allocating too much.
+ final int limitedEntryCount = Math.min(aEntryCount, 120000);
+
+ final List<Thread> threadsToProfile = getThreadsToProfile(aFilters);
+ if (threadsToProfile.size() < 1) {
+ throw new IllegalStateException("Expected >= 1 thread to profile (main thread).");
+ }
+ Log.i(LOGTAG, "Number of threads to profile: " + threadsToProfile.size());
+
+ sSamplingRunnable = new SamplingRunnable(threadsToProfile, aInterval, limitedEntryCount);
+ sMarkerStorage.start(limitedEntryCount, threadsToProfile);
+ sSamplingScheduler = Executors.newSingleThreadScheduledExecutor();
+ sSamplingFuture.set(
+ sSamplingScheduler.scheduleAtFixedRate(
+ sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS));
+ }
+ }
+
+ private static @NonNull List<Thread> getThreadsToProfile(final Object[] aFilters) {
+ // Clean up filters.
+ final List<String> cleanedFilters = new ArrayList<>();
+ for (final Object rawFilter : aFilters) {
+ // aFilters is a String[] but jni can only accept Object[] so we're forced to cast.
+ //
+ // We could pass the lowercased filters from native code but it may not handle lowercasing the
+ // same way Java does so we lower case here so it's consistent later when we lower case the
+ // thread name and compare against it.
+ final String filter = ((String) rawFilter).trim().toLowerCase(Locale.US);
+
+ // If the filter is empty, it's not meaningful: skip.
+ if (filter.isEmpty()) {
+ continue;
+ }
+
+ cleanedFilters.add(filter);
+ }
+
+ final ThreadGroup rootThreadGroup = getRootThreadGroup();
+ final Thread[] activeThreads = getActiveThreads(rootThreadGroup);
+ final Thread mainThread = Looper.getMainLooper().getThread();
+
+ // We model these catch-all filters after the C++ code (which we should eventually deduplicate):
+ // https://searchfox.org/mozilla-central/rev/b0779bcc485dc1c04334dfb9ea024cbfff7b961a/tools/profiler/core/platform.cpp#778-801
+ if (cleanedFilters.contains("*") || doAnyFiltersMatchPid(cleanedFilters, Process.myPid())) {
+ final List<Thread> activeThreadList = new ArrayList<>();
+ Collections.addAll(activeThreadList, activeThreads);
+ if (!activeThreadList.contains(mainThread)) {
+ activeThreadList.add(mainThread); // see below for why this is necessary.
+ }
+ return activeThreadList;
+ }
+
+ // We always want to profile the main thread. We're not certain getActiveThreads returns
+ // all active threads since we've observed that getActiveThreads doesn't include the main thread
+ // during xpcshell tests even though it's alive (bug 1760716). We intentionally don't rely on
+ // that method to add the main thread here.
+ final List<Thread> threadsToProfile = new ArrayList<>();
+ threadsToProfile.add(mainThread);
+
+ for (final Thread thread : activeThreads) {
+ if (shouldProfileThread(thread, cleanedFilters, mainThread)) {
+ threadsToProfile.add(thread);
+ }
+ }
+ return threadsToProfile;
+ }
+
+ private static boolean shouldProfileThread(
+ final Thread aThread, final List<String> aFilters, final Thread aMainThread) {
+ final String threadName = aThread.getName().trim().toLowerCase(Locale.US);
+ if (threadName.isEmpty()) {
+ return false; // We can't match against a thread with no name: skip.
+ }
+
+ if (aThread.equals(aMainThread)) {
+ return false; // We've already added the main thread outside of this method.
+ }
+
+ for (final String filter : aFilters) {
+ // In order to generically support thread pools with thread names like "arch_disk_io_0" (the
+ // kotlin IO dispatcher), we check if the filter is inside the thread name (e.g. a filter of
+ // "io" will match all of the threads in that pool) rather than an equality check.
+ if (threadName.contains(filter)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static boolean doAnyFiltersMatchPid(
+ @NonNull final List<String> aFilters, final long aPid) {
+ final String prefix = "pid:";
+ for (final String filter : aFilters) {
+ if (!filter.startsWith(prefix)) {
+ continue;
+ }
+
+ try {
+ final long filterPid = Long.parseLong(filter.substring(prefix.length()));
+ if (filterPid == aPid) {
+ return true;
+ }
+ } catch (final NumberFormatException e) {
+ /* do nothing. */
+ }
+ }
+
+ return false;
+ }
+
+ private static @NonNull Thread[] getActiveThreads(final @NonNull ThreadGroup rootThreadGroup) {
+ // We need the root thread group to get all of the active threads because of how
+ // ThreadGroup.enumerate works.
+ //
+ // ThreadGroup.enumerate is inherently racey so we loop until we capture all of the active
+ // threads. We can only detect if we didn't capture all of the threads if the number of threads
+ // found (the value returned by enumerate) is smaller than the array we're capturing them in.
+ // Therefore, we make the array slightly larger than the known number of threads.
+ Thread[] allThreads;
+ int threadsFound;
+ do {
+ allThreads = new Thread[rootThreadGroup.activeCount() + 15];
+ threadsFound = rootThreadGroup.enumerate(allThreads, /* recurse */ true);
+ } while (threadsFound >= allThreads.length);
+
+ // There will be more indices in the array than threads and these will be set to null. We remove
+ // the null values to minimize bugs.
+ return Arrays.copyOfRange(allThreads, 0, threadsFound);
+ }
+
+ private static @NonNull ThreadGroup getRootThreadGroup() {
+ // Assert non-null: getThreadGroup only returns null for dead threads but the current thread
+ // can't be dead.
+ ThreadGroup parentGroup = Objects.requireNonNull(Thread.currentThread().getThreadGroup());
+
+ ThreadGroup group = null;
+ while (parentGroup != null) {
+ group = parentGroup;
+ parentGroup = group.getParent();
+ }
+ return group;
+ }
+
+ @WrapForJNI
+ public static void pauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ final ScheduledFuture<?> future = sSamplingFuture.getAndSet(null);
+ future.cancel(false /* mayInterruptIfRunning */);
+ }
+ }
+
+ @WrapForJNI
+ public static void unpauseSampling() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingFuture.get() != null) {
+ return;
+ }
+ sSamplingFuture.set(
+ sSamplingScheduler.scheduleAtFixedRate(
+ sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS));
+ }
+ }
+
+ @WrapForJNI
+ public static void stop() {
+ synchronized (GeckoJavaSampler.class) {
+ if (sSamplingRunnable == null) {
+ return;
+ }
+
+ Log.i(
+ LOGTAG,
+ "Profiler stopping. Sample array position: "
+ + sSamplingRunnable.mSamplePos
+ + ". Overflowed? "
+ + sSamplingRunnable.mBufferOverflowed);
+
+ try {
+ sSamplingScheduler.shutdown();
+ // 1s is enough to wait shutdown.
+ sSamplingScheduler.awaitTermination(1000, TimeUnit.MILLISECONDS);
+ } catch (final InterruptedException e) {
+ Log.e(LOGTAG, "Sampling scheduler isn't terminated. Last sampling data might be broken.");
+ sSamplingScheduler.shutdownNow();
+ }
+ sSamplingScheduler = null;
+ sSamplingRunnable = null;
+ sSamplingFuture.set(null);
+ sMarkerStorage.stop();
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "StartProfiler")
+ private static native void startProfilerNative(String[] aFilters, String[] aFeaturesArr);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "StopProfiler")
+ private static native void stopProfilerNative(GeckoResult<byte[]> aResult);
+
+ public static void startProfiler(final String[] aFilters, final String[] aFeaturesArr) {
+ startProfilerNative(aFilters, aFeaturesArr);
+ }
+
+ public static GeckoResult<byte[]> stopProfiler() {
+ final GeckoResult<byte[]> result = new GeckoResult<byte[]>();
+ stopProfilerNative(result);
+ return result;
+ }
+
+ /** Returns the device brand and model as a string. */
+ @WrapForJNI
+ public static String getDeviceInformation() {
+ final StringBuilder sb = new StringBuilder(Build.BRAND);
+ sb.append(" ");
+ sb.append(Build.MODEL);
+ return sb.toString();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java
new file mode 100644
index 0000000000..02ed848f6b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java
@@ -0,0 +1,413 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.DhcpInfo;
+import android.net.wifi.WifiManager;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.NetworkUtils;
+import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType;
+import org.mozilla.gecko.util.NetworkUtils.ConnectionType;
+import org.mozilla.gecko.util.NetworkUtils.NetworkStatus;
+
+/**
+ * Provides connection type, subtype and general network status (up/down).
+ *
+ * <p>According to spec of Network Information API version 3, connection types include: bluetooth,
+ * cellular, ethernet, none, wifi and other. The objective of providing such general connection is
+ * due to some security concerns. In short, we don't want to expose exact network type, especially
+ * the cellular network type.
+ *
+ * <p>Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets.
+ *
+ * <p>Logic is implemented as a state machine, so see the transition matrix to figure out what
+ * happens when. This class depends on access to the context, so only use after GeckoAppShell has
+ * been initialized.
+ */
+public class GeckoNetworkManager extends BroadcastReceiver {
+ private static final String LOGTAG = "GeckoNetworkManager";
+
+ // If network configuration and/or status changed, we send details of what changed.
+ // If we received a "check out new network state!" intent from the OS but nothing in it looks
+ // different, we ignore it. See Bug 1330836 for some relevant details.
+ private static final String LINK_DATA_CHANGED = "changed";
+
+ private static GeckoNetworkManager instance;
+
+ // We hackishly (yet harmlessly, in this case) keep a Context reference passed in via the start
+ // method.
+ // See context handling notes in handleManagerEvent, and Bug 1277333.
+ private Context mContext;
+
+ public static void destroy() {
+ if (instance != null) {
+ instance.onDestroy();
+ instance = null;
+ }
+ }
+
+ public enum ManagerState {
+ OffNoListeners,
+ OffWithListeners,
+ OnNoListeners,
+ OnWithListeners
+ }
+
+ public enum ManagerEvent {
+ start,
+ stop,
+ enableNotifications,
+ disableNotifications,
+ receivedUpdate
+ }
+
+ private ManagerState mCurrentState = ManagerState.OffNoListeners;
+ private ConnectionType mCurrentConnectionType = ConnectionType.NONE;
+ private ConnectionType mPreviousConnectionType = ConnectionType.NONE;
+ private ConnectionSubType mCurrentConnectionSubtype = ConnectionSubType.UNKNOWN;
+ private ConnectionSubType mPreviousConnectionSubtype = ConnectionSubType.UNKNOWN;
+ private NetworkStatus mCurrentNetworkStatus = NetworkStatus.UNKNOWN;
+ private NetworkStatus mPreviousNetworkStatus = NetworkStatus.UNKNOWN;
+
+ private GeckoNetworkManager() {}
+
+ private void onDestroy() {
+ handleManagerEvent(ManagerEvent.stop);
+ }
+
+ public static GeckoNetworkManager getInstance() {
+ if (instance == null) {
+ instance = new GeckoNetworkManager();
+ }
+
+ return instance;
+ }
+
+ public double[] getCurrentInformation() {
+ final Context applicationContext = GeckoAppShell.getApplicationContext();
+ final ConnectionType connectionType = mCurrentConnectionType;
+ return new double[] {
+ connectionType.value,
+ connectionType == ConnectionType.WIFI ? 1.0 : 0.0,
+ connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0
+ };
+ }
+
+ @Override
+ public void onReceive(final Context aContext, final Intent aIntent) {
+ handleManagerEvent(ManagerEvent.receivedUpdate);
+ }
+
+ public void start(final Context context) {
+ mContext = context;
+ handleManagerEvent(ManagerEvent.start);
+ }
+
+ public void stop() {
+ handleManagerEvent(ManagerEvent.stop);
+ }
+
+ public void enableNotifications() {
+ handleManagerEvent(ManagerEvent.enableNotifications);
+ }
+
+ public void disableNotifications() {
+ handleManagerEvent(ManagerEvent.disableNotifications);
+ }
+
+ /**
+ * For a given event, figure out the next state, run any transition by-product actions, and switch
+ * current state to the next state. If event is invalid for the current state, this is a no-op.
+ *
+ * @param event Incoming event
+ * @return Boolean indicating if transition was performed.
+ */
+ private synchronized boolean handleManagerEvent(final ManagerEvent event) {
+ final ManagerState nextState = getNextState(mCurrentState, event);
+
+ Log.d(LOGTAG, "Incoming event " + event + " for state " + mCurrentState + " -> " + nextState);
+ if (nextState == null) {
+ Log.w(LOGTAG, "Invalid event " + event + " for state " + mCurrentState);
+ return false;
+ }
+
+ // We're being deliberately careful about handling context here; it's possible that in some
+ // rare cases and possibly related to timing of when this is called (seems to be early in the
+ // startup phase),
+ // GeckoAppShell.getApplicationContext() will be null, and .start() wasn't called yet,
+ // so we don't have a local Context reference either. If both of these are true, we have to drop
+ // the event.
+ // NB: this is hacky (and these checks attempt to isolate the hackiness), and root cause
+ // seems to be how this class fits into the larger ecosystem and general flow of events.
+ // See Bug 1277333.
+ final Context contextForAction;
+ if (mContext != null) {
+ contextForAction = mContext;
+ } else {
+ contextForAction = GeckoAppShell.getApplicationContext();
+ }
+
+ if (contextForAction == null) {
+ Log.w(
+ LOGTAG,
+ "Context is not available while processing event "
+ + event
+ + " for state "
+ + mCurrentState);
+ return false;
+ }
+
+ performActionsForStateEvent(contextForAction, mCurrentState, event);
+ mCurrentState = nextState;
+
+ return true;
+ }
+
+ /**
+ * Defines a transition matrix for our state machine. For a given state/event pair, returns
+ * nextState.
+ *
+ * @param currentState Current state against which we have an incoming event
+ * @param event Incoming event for which we'd like to figure out the next state
+ * @return State into which we should transition as result of given event
+ */
+ @Nullable
+ public static ManagerState getNextState(
+ final @NonNull ManagerState currentState, final @NonNull ManagerEvent event) {
+ switch (currentState) {
+ case OffNoListeners:
+ switch (event) {
+ case start:
+ return ManagerState.OnNoListeners;
+ case enableNotifications:
+ return ManagerState.OffWithListeners;
+ default:
+ return null;
+ }
+ case OnNoListeners:
+ switch (event) {
+ case stop:
+ return ManagerState.OffNoListeners;
+ case enableNotifications:
+ return ManagerState.OnWithListeners;
+ case receivedUpdate:
+ return ManagerState.OnNoListeners;
+ default:
+ return null;
+ }
+ case OnWithListeners:
+ switch (event) {
+ case stop:
+ return ManagerState.OffWithListeners;
+ case disableNotifications:
+ return ManagerState.OnNoListeners;
+ case receivedUpdate:
+ return ManagerState.OnWithListeners;
+ default:
+ return null;
+ }
+ case OffWithListeners:
+ switch (event) {
+ case start:
+ return ManagerState.OnWithListeners;
+ case disableNotifications:
+ return ManagerState.OffNoListeners;
+ default:
+ return null;
+ }
+ default:
+ throw new IllegalStateException("Unknown current state: " + currentState.name());
+ }
+ }
+
+ /**
+ * For a given state/event combination, run any actions which are by-products of leaving the state
+ * because of a given event. Since this is a deterministic state machine, we can easily do that
+ * without any additional information.
+ *
+ * @param currentState State which we are leaving
+ * @param event Event which is causing us to leave the state
+ */
+ private void performActionsForStateEvent(
+ final Context context, final ManagerState currentState, final ManagerEvent event) {
+ // NB: network state might be queried via getCurrentInformation at any time; pre-rewrite
+ // behaviour was
+ // that network state was updated whenever enableNotifications was called. To avoid deviating
+ // from previous behaviour and causing weird side-effects, we call
+ // updateNetworkStateAndConnectionType
+ // whenever notifications are enabled.
+ switch (currentState) {
+ case OffNoListeners:
+ if (event == ManagerEvent.start) {
+ updateNetworkStateAndConnectionType(context);
+ registerBroadcastReceiver(context, this);
+ }
+ if (event == ManagerEvent.enableNotifications) {
+ updateNetworkStateAndConnectionType(context);
+ }
+ break;
+ case OnNoListeners:
+ if (event == ManagerEvent.receivedUpdate) {
+ updateNetworkStateAndConnectionType(context);
+ sendNetworkStateToListeners(context);
+ }
+ if (event == ManagerEvent.enableNotifications) {
+ updateNetworkStateAndConnectionType(context);
+ registerBroadcastReceiver(context, this);
+ }
+ if (event == ManagerEvent.stop) {
+ unregisterBroadcastReceiver(context, this);
+ }
+ break;
+ case OnWithListeners:
+ if (event == ManagerEvent.receivedUpdate) {
+ updateNetworkStateAndConnectionType(context);
+ sendNetworkStateToListeners(context);
+ }
+ if (event == ManagerEvent.stop) {
+ unregisterBroadcastReceiver(context, this);
+ }
+ /* no-op event: ManagerEvent.disableNotifications */
+ break;
+ case OffWithListeners:
+ if (event == ManagerEvent.start) {
+ registerBroadcastReceiver(context, this);
+ }
+ /* no-op event: ManagerEvent.disableNotifications */
+ break;
+ default:
+ throw new IllegalStateException("Unknown current state: " + currentState.name());
+ }
+ }
+
+ /** Update current network state and connection types. */
+ private void updateNetworkStateAndConnectionType(final Context context) {
+ final ConnectivityManager connectivityManager =
+ (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ // Type/status getters below all have a defined behaviour for when connectivityManager == null
+ if (connectivityManager == null) {
+ Log.e(LOGTAG, "ConnectivityManager does not exist.");
+ }
+ mCurrentConnectionType = NetworkUtils.getConnectionType(connectivityManager);
+ mCurrentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager);
+ mCurrentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager);
+ Log.d(
+ LOGTAG,
+ "New network state: "
+ + mCurrentNetworkStatus
+ + ", "
+ + mCurrentConnectionType
+ + ", "
+ + mCurrentConnectionSubtype);
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void onConnectionChanged(
+ int type, String subType, boolean isWifi, int dhcpGateway);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void onStatusChanged(String status);
+
+ /** Send current network state and connection type to whomever is listening. */
+ private void sendNetworkStateToListeners(final Context context) {
+ final boolean connectionTypeOrSubtypeChanged =
+ mCurrentConnectionType != mPreviousConnectionType
+ || mCurrentConnectionSubtype != mPreviousConnectionSubtype;
+ if (connectionTypeOrSubtypeChanged) {
+ mPreviousConnectionType = mCurrentConnectionType;
+ mPreviousConnectionSubtype = mCurrentConnectionSubtype;
+
+ final boolean isWifi = mCurrentConnectionType == ConnectionType.WIFI;
+ final int gateway = !isWifi ? 0 : wifiDhcpGatewayAddress(context);
+
+ if (GeckoThread.isRunning()) {
+ onConnectionChanged(
+ mCurrentConnectionType.value, mCurrentConnectionSubtype.value, isWifi, gateway);
+ } else {
+ GeckoThread.queueNativeCall(
+ GeckoNetworkManager.class,
+ "onConnectionChanged",
+ mCurrentConnectionType.value,
+ String.class,
+ mCurrentConnectionSubtype.value,
+ isWifi,
+ gateway);
+ }
+ }
+
+ // If neither network status nor network configuration changed, do nothing.
+ if (mCurrentNetworkStatus == mPreviousNetworkStatus && !connectionTypeOrSubtypeChanged) {
+ return;
+ }
+
+ // If network status remains the same, send "changed". Otherwise, send new network status.
+ // See Bug 1330836 for relevant details.
+ final String status;
+ if (mCurrentNetworkStatus == mPreviousNetworkStatus) {
+ status = LINK_DATA_CHANGED;
+ } else {
+ mPreviousNetworkStatus = mCurrentNetworkStatus;
+ status = mCurrentNetworkStatus.value;
+ }
+
+ if (GeckoThread.isRunning()) {
+ onStatusChanged(status);
+ } else {
+ GeckoThread.queueNativeCall(
+ GeckoNetworkManager.class, "onStatusChanged", String.class, status);
+ }
+ }
+
+ /** Stop listening for network state updates. */
+ private static void unregisterBroadcastReceiver(
+ final Context context, final BroadcastReceiver receiver) {
+ context.unregisterReceiver(receiver);
+ }
+
+ /** Start listening for network state updates. */
+ private static void registerBroadcastReceiver(
+ final Context context, final BroadcastReceiver receiver) {
+ final IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
+ context.registerReceiver(receiver, filter);
+ }
+
+ private static int wifiDhcpGatewayAddress(final Context context) {
+ if (context == null) {
+ return 0;
+ }
+
+ try {
+ final WifiManager mgr =
+ (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ if (mgr == null) {
+ return 0;
+ }
+
+ @SuppressLint("MissingPermission")
+ final DhcpInfo d = mgr.getDhcpInfo();
+ if (d == null) {
+ return 0;
+ }
+
+ return d.gateway;
+
+ } catch (final Exception ex) {
+ // getDhcpInfo() is not documented to require any permissions, but on some devices
+ // requires android.permission.ACCESS_WIFI_STATE. Just catch the generic exception
+ // here and returning 0. Not logging because this could be noisy.
+ return 0;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java
new file mode 100644
index 0000000000..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<OrientationChangeListener> mListeners;
+
+ public static GeckoScreenOrientation getInstance() {
+ if (sInstance == null) {
+ sInstance = new GeckoScreenOrientation();
+ }
+ return sInstance;
+ }
+
+ private GeckoScreenOrientation() {
+ mListeners = new ArrayList<>();
+ update();
+ }
+
+ /** Add a listener that will be notified when the screen orientation has changed. */
+ public void addListener(final OrientationChangeListener aListener) {
+ ThreadUtils.assertOnUiThread();
+ mListeners.add(aListener);
+ }
+
+ /** Remove a OrientationChangeListener again. */
+ public void removeListener(final OrientationChangeListener aListener) {
+ ThreadUtils.assertOnUiThread();
+ mListeners.remove(aListener);
+ }
+
+ /*
+ * Update screen orientation.
+ * Retrieve orientation and rotation via GeckoAppShell.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update() {
+ // Check whether we have the application context for fenix/a-c unit test.
+ final Context appContext = GeckoAppShell.getApplicationContext();
+ if (appContext == null) {
+ return false;
+ }
+ final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride();
+ final int orientation =
+ rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT;
+ return update(getScreenOrientation(orientation, getRotation()));
+ }
+
+ /*
+ * Update screen orientation.
+ * Retrieve orientation and rotation via Display.
+ *
+ * @param aDisplay The Display that has screen orientation information
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update(final Display aDisplay) {
+ return update(getScreenOrientation(aDisplay));
+ }
+
+ /*
+ * Update screen orientation given the android orientation.
+ * Retrieve rotation via GeckoAppShell.
+ *
+ * @param aAndroidOrientation
+ * Android screen orientation from Configuration.orientation.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public boolean update(final int aAndroidOrientation) {
+ return update(getScreenOrientation(aAndroidOrientation, getRotation()));
+ }
+
+ /*
+ * Update screen orientation given the screen orientation.
+ *
+ * @param aScreenOrientation
+ * Gecko screen orientation based on android orientation and rotation.
+ *
+ * @return Whether the screen orientation has changed.
+ */
+ public synchronized boolean update(final ScreenOrientation aScreenOrientation) {
+ // Gecko expects a definite screen orientation, so we default to the
+ // primary orientations.
+ final ScreenOrientation screenOrientation;
+ if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_PRIMARY.value) != 0) {
+ screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_SECONDARY.value) != 0) {
+ screenOrientation = ScreenOrientation.PORTRAIT_SECONDARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_PRIMARY.value) != 0) {
+ screenOrientation = ScreenOrientation.LANDSCAPE_PRIMARY;
+ } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_SECONDARY.value) != 0) {
+ screenOrientation = ScreenOrientation.LANDSCAPE_SECONDARY;
+ } else {
+ screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY;
+ }
+ if (mScreenOrientation == screenOrientation) {
+ return false;
+ }
+ mScreenOrientation = screenOrientation;
+ Log.d(LOGTAG, "updating to new orientation " + mScreenOrientation);
+ notifyListeners(mScreenOrientation);
+ ScreenManagerHelper.refreshScreenInfo();
+ return true;
+ }
+
+ private void notifyListeners(final ScreenOrientation newOrientation) {
+ final Runnable notifier =
+ new Runnable() {
+ @Override
+ public void run() {
+ for (final OrientationChangeListener listener : mListeners) {
+ listener.onScreenOrientationChanged(newOrientation);
+ }
+ }
+ };
+
+ if (ThreadUtils.isOnUiThread()) {
+ notifier.run();
+ } else {
+ ThreadUtils.runOnUiThread(notifier);
+ }
+ }
+
+ /*
+ * @return The Gecko screen orientation derived from Android orientation and
+ * rotation.
+ */
+ public ScreenOrientation getScreenOrientation() {
+ return mScreenOrientation;
+ }
+
+ /*
+ * Combine the Android orientation and rotation to the Gecko orientation.
+ *
+ * @param aAndroidOrientation
+ * Android orientation from Configuration.orientation.
+ * @param aRotation
+ * Device rotation from Display.getRotation().
+ *
+ * @return Gecko screen orientation.
+ */
+ private ScreenOrientation getScreenOrientation(
+ final int aAndroidOrientation, final int aRotation) {
+ final boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90;
+ if (aAndroidOrientation == ORIENTATION_PORTRAIT) {
+ if (isPrimary) {
+ // Non-rotated portrait device or landscape device rotated
+ // to primary portrait mode counter-clockwise.
+ return ScreenOrientation.PORTRAIT_PRIMARY;
+ }
+ return ScreenOrientation.PORTRAIT_SECONDARY;
+ }
+ if (aAndroidOrientation == ORIENTATION_LANDSCAPE) {
+ if (isPrimary) {
+ // Non-rotated landscape device or portrait device rotated
+ // to primary landscape mode counter-clockwise.
+ return ScreenOrientation.LANDSCAPE_PRIMARY;
+ }
+ return ScreenOrientation.LANDSCAPE_SECONDARY;
+ }
+ return ScreenOrientation.NONE;
+ }
+
+ /*
+ * Get the Gecko orientation from Display.
+ *
+ * @param aDisplay The display that has orientation information.
+ *
+ * @return Gecko screen orientation.
+ */
+ private ScreenOrientation getScreenOrientation(final Display aDisplay) {
+ final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride();
+ final int orientation =
+ rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT;
+ return getScreenOrientation(orientation, aDisplay.getRotation());
+ }
+
+ /*
+ * @return Device rotation converted to an angle.
+ */
+ public short getAngle() {
+ switch (getRotation()) {
+ case Surface.ROTATION_0:
+ return 0;
+ case Surface.ROTATION_90:
+ return 90;
+ case Surface.ROTATION_180:
+ return 180;
+ case Surface.ROTATION_270:
+ return 270;
+ default:
+ Log.w(LOGTAG, "getAngle: unexpected rotation value");
+ return 0;
+ }
+ }
+
+ /*
+ * @return Device rotation.
+ */
+ private int getRotation() {
+ return GeckoAppShell.getRotation();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java
new file mode 100644
index 0000000000..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.
+ *
+ * <p>Uses `Settings.Global` which was introduced in API version 17.
+ */
+ private static boolean prefersReducedMotion() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ return false;
+ }
+
+ final ContentResolver contentResolver = sApplicationContext.getContentResolver();
+
+ return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1)
+ == 0.0f;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
+ @WrapForJNI(calledFrom = "gecko")
+ /**
+ * For inverted-colors queries feature.
+ *
+ * <p>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<StateGeckoResult> sStateListeners = new LinkedList<>();
+
+ // Main process parameters
+ public static final int FLAG_DEBUGGING = 1 << 0; // Debugging mode.
+ public static final int FLAG_PRELOAD_CHILD = 1 << 1; // Preload child during main thread start.
+ public static final int FLAG_ENABLE_NATIVE_CRASHREPORTER =
+ 1 << 2; // Enable native crash reporting.
+
+ /* package */ static final String EXTRA_ARGS = "args";
+
+ private boolean mInitialized;
+ private InitInfo mInitInfo;
+
+ public static final class ParcelFileDescriptors {
+ public final @Nullable ParcelFileDescriptor prefs;
+ public final @Nullable ParcelFileDescriptor prefMap;
+ public final @NonNull ParcelFileDescriptor ipc;
+ public final @Nullable ParcelFileDescriptor crashReporter;
+ public final @Nullable ParcelFileDescriptor crashAnnotation;
+
+ private ParcelFileDescriptors(final Builder builder) {
+ prefs = builder.prefs;
+ prefMap = builder.prefMap;
+ ipc = builder.ipc;
+ crashReporter = builder.crashReporter;
+ crashAnnotation = builder.crashAnnotation;
+ }
+
+ public FileDescriptors detach() {
+ return FileDescriptors.builder()
+ .prefs(detach(prefs))
+ .prefMap(detach(prefMap))
+ .ipc(detach(ipc))
+ .crashReporter(detach(crashReporter))
+ .crashAnnotation(detach(crashAnnotation))
+ .build();
+ }
+
+ private static int detach(final ParcelFileDescriptor pfd) {
+ if (pfd == null) {
+ return INVALID_FD;
+ }
+ return pfd.detachFd();
+ }
+
+ public void close() {
+ close(prefs, prefMap, ipc, crashReporter, crashAnnotation);
+ }
+
+ private static void close(final ParcelFileDescriptor... pfds) {
+ for (final ParcelFileDescriptor pfd : pfds) {
+ if (pfd != null) {
+ try {
+ pfd.close();
+ } catch (final IOException ex) {
+ // Nothing we can do about this really.
+ Log.w(LOGTAG, "Failed to close File Descriptors.", ex);
+ }
+ }
+ }
+ }
+
+ public static ParcelFileDescriptors from(final FileDescriptors fds) {
+ return ParcelFileDescriptors.builder()
+ .prefs(from(fds.prefs))
+ .prefMap(from(fds.prefMap))
+ .ipc(from(fds.ipc))
+ .crashReporter(from(fds.crashReporter))
+ .crashAnnotation(from(fds.crashAnnotation))
+ .build();
+ }
+
+ private static ParcelFileDescriptor from(final int fd) {
+ if (fd == INVALID_FD) {
+ return null;
+ }
+ try {
+ return ParcelFileDescriptor.fromFd(fd);
+ } catch (final IOException ex) {
+ throw new RuntimeException(ex);
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ ParcelFileDescriptor prefs;
+ ParcelFileDescriptor prefMap;
+ ParcelFileDescriptor ipc;
+ ParcelFileDescriptor crashReporter;
+ ParcelFileDescriptor crashAnnotation;
+
+ private Builder() {}
+
+ public ParcelFileDescriptors build() {
+ return new ParcelFileDescriptors(this);
+ }
+
+ public Builder prefs(final ParcelFileDescriptor prefs) {
+ this.prefs = prefs;
+ return this;
+ }
+
+ public Builder prefMap(final ParcelFileDescriptor prefMap) {
+ this.prefMap = prefMap;
+ return this;
+ }
+
+ public Builder ipc(final ParcelFileDescriptor ipc) {
+ this.ipc = ipc;
+ return this;
+ }
+
+ public Builder crashReporter(final ParcelFileDescriptor crashReporter) {
+ this.crashReporter = crashReporter;
+ return this;
+ }
+
+ public Builder crashAnnotation(final ParcelFileDescriptor crashAnnotation) {
+ this.crashAnnotation = crashAnnotation;
+ return this;
+ }
+ }
+ }
+
+ public static final class FileDescriptors {
+ final int prefs;
+ final int prefMap;
+ final int ipc;
+ final int crashReporter;
+ final int crashAnnotation;
+
+ private FileDescriptors(final Builder builder) {
+ prefs = builder.prefs;
+ prefMap = builder.prefMap;
+ ipc = builder.ipc;
+ crashReporter = builder.crashReporter;
+ crashAnnotation = builder.crashAnnotation;
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ int prefs = INVALID_FD;
+ int prefMap = INVALID_FD;
+ int ipc = INVALID_FD;
+ int crashReporter = INVALID_FD;
+ int crashAnnotation = INVALID_FD;
+
+ private Builder() {}
+
+ public FileDescriptors build() {
+ return new FileDescriptors(this);
+ }
+
+ public Builder prefs(final int prefs) {
+ this.prefs = prefs;
+ return this;
+ }
+
+ public Builder prefMap(final int prefMap) {
+ this.prefMap = prefMap;
+ return this;
+ }
+
+ public Builder ipc(final int ipc) {
+ this.ipc = ipc;
+ return this;
+ }
+
+ public Builder crashReporter(final int crashReporter) {
+ this.crashReporter = crashReporter;
+ return this;
+ }
+
+ public Builder crashAnnotation(final int crashAnnotation) {
+ this.crashAnnotation = crashAnnotation;
+ return this;
+ }
+ }
+ }
+
+ public static class InitInfo {
+ public final String[] args;
+ public final Bundle extras;
+ public final int flags;
+ public final Map<String, Object> prefs;
+ public final String userSerialNumber;
+
+ public final boolean xpcshell;
+ public final String outFilePath;
+
+ public final FileDescriptors fds;
+
+ private InitInfo(final Builder builder) {
+ final List<String> result = new ArrayList<>(builder.mArgs.length);
+
+ boolean xpcshell = false;
+ for (final String argument : builder.mArgs) {
+ if ("-xpcshell".equals(argument)) {
+ xpcshell = true;
+ } else {
+ result.add(argument);
+ }
+ }
+ this.xpcshell = xpcshell;
+
+ args = result.toArray(new String[0]);
+
+ extras = builder.mExtras != null ? new Bundle(builder.mExtras) : new Bundle(3);
+ flags = builder.mFlags;
+ prefs = builder.mPrefs;
+ userSerialNumber = builder.mUserSerialNumber;
+
+ outFilePath = xpcshell ? builder.mOutFilePath : null;
+
+ if (builder.mFds != null) {
+ fds = builder.mFds;
+ } else {
+ fds = FileDescriptors.builder().build();
+ }
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String[] mArgs;
+ private Bundle mExtras;
+ private int mFlags;
+ private Map<String, Object> mPrefs;
+ private String mUserSerialNumber;
+
+ private String mOutFilePath;
+
+ private FileDescriptors mFds;
+
+ // Prevent direct instantiation
+ private Builder() {}
+
+ public InitInfo build() {
+ return new InitInfo(this);
+ }
+
+ public Builder args(final String[] args) {
+ mArgs = args;
+ return this;
+ }
+
+ public Builder extras(final Bundle extras) {
+ mExtras = extras;
+ return this;
+ }
+
+ public Builder flags(final int flags) {
+ mFlags = flags;
+ return this;
+ }
+
+ public Builder prefs(final Map<String, Object> prefs) {
+ mPrefs = prefs;
+ return this;
+ }
+
+ public Builder userSerialNumber(final String userSerialNumber) {
+ mUserSerialNumber = userSerialNumber;
+ return this;
+ }
+
+ public Builder outFilePath(final String outFilePath) {
+ mOutFilePath = outFilePath;
+ return this;
+ }
+
+ public Builder fds(final FileDescriptors fds) {
+ mFds = fds;
+ return this;
+ }
+ }
+ }
+
+ private static class StateGeckoResult extends GeckoResult<Void> {
+ final State state;
+
+ public StateGeckoResult(final State state) {
+ this.state = state;
+ }
+ }
+
+ GeckoThread() {
+ // Request more (virtual) stack space to avoid overflows in the CSS frame
+ // constructor. 8 MB matches desktop.
+ super(null, null, "Gecko", 8 * 1024 * 1024);
+ }
+
+ @WrapForJNI
+ private static boolean isChildProcess() {
+ final InitInfo info = INSTANCE.mInitInfo;
+ return info != null && info.fds.ipc != INVALID_FD;
+ }
+
+ public static boolean init(final InitInfo info) {
+ return INSTANCE.initInternal(info);
+ }
+
+ private synchronized boolean initInternal(final InitInfo info) {
+ ThreadUtils.assertOnUiThread();
+ uiThreadId = Process.myTid();
+
+ if (mInitialized) {
+ return false;
+ }
+
+ sInitTimer = new TelemetryUtils.UptimeTimer("GV_STARTUP_RUNTIME_MS");
+
+ mInitInfo = info;
+ mInitialized = true;
+ notifyAll();
+ return true;
+ }
+
+ public static boolean launch() {
+ ThreadUtils.assertOnUiThread();
+
+ if (checkAndSetState(State.INITIAL, State.LAUNCHED)) {
+ INSTANCE.start();
+ return true;
+ }
+ return false;
+ }
+
+ public static boolean isLaunched() {
+ return !isState(State.INITIAL);
+ }
+
+ @RobocopTarget
+ public static boolean isRunning() {
+ return isState(State.RUNNING);
+ }
+
+ private static void loadGeckoLibs(final Context context) {
+ GeckoLoader.loadSQLiteLibs(context);
+ GeckoLoader.loadNSSLibs(context);
+ GeckoLoader.loadGeckoLibs(context);
+ setState(State.LIBS_READY);
+ }
+
+ private static void initGeckoEnvironment() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Locale locale = Locale.getDefault();
+ final Resources res = context.getResources();
+ if (locale.toString().equalsIgnoreCase("zh_hk")) {
+ final Locale mappedLocale = Locale.TRADITIONAL_CHINESE;
+ Locale.setDefault(mappedLocale);
+ final Configuration config = res.getConfiguration();
+ config.locale = mappedLocale;
+ res.updateConfiguration(config, null);
+ }
+
+ if (!isChildProcess()) {
+ GeckoSystemStateListener.getInstance().initialize(context);
+ }
+
+ loadGeckoLibs(context);
+ }
+
+ private String[] getMainProcessArgs() {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final ArrayList<String> args = new ArrayList<>();
+
+ // argv[0] is the program name, which for us is the package name.
+ args.add(context.getPackageName());
+
+ if (!mInitInfo.xpcshell) {
+ args.add("-greomni");
+ args.add(context.getPackageResourcePath());
+ }
+
+ if (mInitInfo.args != null) {
+ args.addAll(Arrays.asList(mInitInfo.args));
+ }
+
+ // Legacy "args" parameter
+ final String extraArgs = mInitInfo.extras.getString(EXTRA_ARGS, null);
+ if (extraArgs != null) {
+ final StringTokenizer st = new StringTokenizer(extraArgs);
+ while (st.hasMoreTokens()) {
+ args.add(st.nextToken());
+ }
+ }
+
+ // "argX" parameters
+ for (int i = 0; mInitInfo.extras.containsKey("arg" + i); i++) {
+ final String arg = mInitInfo.extras.getString("arg" + i);
+ args.add(arg);
+ }
+
+ return args.toArray(new String[0]);
+ }
+
+ public static @Nullable Bundle getActiveExtras() {
+ synchronized (INSTANCE) {
+ if (!INSTANCE.mInitialized) {
+ return null;
+ }
+ return new Bundle(INSTANCE.mInitInfo.extras);
+ }
+ }
+
+ public static int getActiveFlags() {
+ synchronized (INSTANCE) {
+ if (!INSTANCE.mInitialized) {
+ return 0;
+ }
+
+ return INSTANCE.mInitInfo.flags;
+ }
+ }
+
+ private static ArrayList<String> getEnvFromExtras(final Bundle extras) {
+ if (extras == null) {
+ return new ArrayList<>();
+ }
+
+ final ArrayList<String> result = new ArrayList<>();
+ if (extras != null) {
+ String env = extras.getString("env0");
+ for (int c = 1; env != null; c++) {
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(LOGTAG, "env var: " + env);
+ }
+ result.add(env);
+ env = extras.getString("env" + c);
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ public void run() {
+ Log.i(LOGTAG, "preparing to run Gecko");
+
+ Looper.prepare();
+ GeckoThread.msgQueue = Looper.myQueue();
+ ThreadUtils.sGeckoThread = this;
+ ThreadUtils.sGeckoHandler = new Handler();
+
+ // Preparation for pumpMessageLoop()
+ final MessageQueue.IdleHandler idleHandler =
+ new MessageQueue.IdleHandler() {
+ @Override
+ public boolean queueIdle() {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+ final Message idleMsg = Message.obtain(geckoHandler);
+ // Use |Message.obj == GeckoHandler| to identify our "queue is empty" message
+ idleMsg.obj = geckoHandler;
+ geckoHandler.sendMessageAtFrontOfQueue(idleMsg);
+ // Keep this IdleHandler
+ return true;
+ }
+ };
+ Looper.myQueue().addIdleHandler(idleHandler);
+
+ // Wait until initialization before preparing environment.
+ synchronized (this) {
+ while (!mInitialized) {
+ try {
+ wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ final List<String> env = getEnvFromExtras(mInitInfo.extras);
+
+ // In Gecko, the native crash reporter is enabled by default in opt builds, and
+ // disabled by default in debug builds.
+ if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) == 0 && !BuildConfig.DEBUG_BUILD) {
+ env.add(0, "MOZ_CRASHREPORTER_DISABLE=1");
+ } else if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) != 0
+ && BuildConfig.DEBUG_BUILD) {
+ env.add(0, "MOZ_CRASHREPORTER=1");
+ }
+
+ if (mInitInfo.userSerialNumber != null) {
+ env.add(0, "MOZ_ANDROID_USER_SERIAL_NUMBER=" + mInitInfo.userSerialNumber);
+ }
+
+ // Start the profiler before even loading mozglue, so we can capture more
+ // things that are happening on the JVM side.
+ maybeStartGeckoProfiler(env);
+
+ GeckoLoader.loadMozGlue(context);
+ setState(State.MOZGLUE_READY);
+
+ final boolean isChildProcess = isChildProcess();
+
+ GeckoLoader.setupGeckoEnvironment(
+ context,
+ isChildProcess,
+ context.getFilesDir().getPath(),
+ env,
+ mInitInfo.prefs,
+ mInitInfo.xpcshell);
+
+ initGeckoEnvironment();
+
+ if ((mInitInfo.flags & FLAG_PRELOAD_CHILD) != 0) {
+ // Preload the content ("tab") child process.
+ GeckoProcessManager.getInstance().preload(GeckoProcessType.CONTENT);
+ }
+
+ if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) {
+ try {
+ Thread.sleep(5 * 1000 /* 5 seconds */);
+ } catch (final InterruptedException e) {
+ }
+ }
+
+ Log.w(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - runGecko");
+
+ final String[] args = isChildProcess ? mInitInfo.args : getMainProcessArgs();
+
+ if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) {
+ Log.i(LOGTAG, "RunGecko - args = " + TextUtils.join(" ", args));
+ }
+
+ // And go.
+ GeckoLoader.nativeRun(
+ args,
+ mInitInfo.fds.prefs,
+ mInitInfo.fds.prefMap,
+ mInitInfo.fds.ipc,
+ mInitInfo.fds.crashReporter,
+ mInitInfo.fds.crashAnnotation,
+ isChildProcess ? false : mInitInfo.xpcshell,
+ isChildProcess ? null : mInitInfo.outFilePath);
+
+ // And... we're done.
+ final boolean restarting = isState(State.RESTARTING);
+ setState(State.EXITED);
+
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putBoolean("restart", restarting);
+ EventDispatcher.getInstance().dispatch("Gecko:Exited", data);
+
+ // Remove pumpMessageLoop() idle handler
+ Looper.myQueue().removeIdleHandler(idleHandler);
+
+ if (isChildProcess) {
+ // The child process is completely controlled by Gecko so we don't really need to keep
+ // it alive after Gecko exits.
+ System.exit(0);
+ }
+ }
+
+ // This may start the gecko profiler early by looking at the environment variables.
+ // Refer to the platform side for more information about the environment variables:
+ // https://searchfox.org/mozilla-central/rev/2f9eacd9d3d995c937b4251a5557d95d494c9be1/tools/profiler/core/platform.cpp#2969-3072
+ private static void maybeStartGeckoProfiler(final @NonNull List<String> env) {
+ final String startupEnv = "MOZ_PROFILER_STARTUP=";
+ final String intervalEnv = "MOZ_PROFILER_STARTUP_INTERVAL=";
+ final String capacityEnv = "MOZ_PROFILER_STARTUP_ENTRIES=";
+ final String filtersEnv = "MOZ_PROFILER_STARTUP_FILTERS=";
+ boolean isStartupProfiling = false;
+ // Putting default values for now, but they can be overwritten.
+ // Keep these values in sync with profiler defaults.
+ int interval = 1;
+ // 8M entries. Keep this in sync with `PROFILER_DEFAULT_STARTUP_ENTRIES`.
+ int capacity = 8 * 1024 * 1024;
+ // We have a default 8M of entries but user can actually put less entries
+ // with environment variables. But even though user can put anything, we
+ // have a hard cap on the minimum value count, because if it's lower than
+ // this value, profiler could not capture anything meaningful.
+ // This value is kept in `scMinimumBufferEntries` variable in the cpp side:
+ // https://searchfox.org/mozilla-central/rev/fa7f47027917a186fb2052dee104cd06c21dd76f/tools/profiler/core/platform.cpp#749
+ // This number is not clear in the cpp code at first, so lets calculate:
+ // scMinimumBufferEntries = scMinimumBufferSize / scBytesPerEntry
+ // expands into
+ // scMinimumNumberOfChunks * 2 * scExpectedMaximumStackSize / scBytesPerEntry
+ // and this is: 4 * 2 * 64 * 1024 / 8 = 65536 (~512 kb)
+ final int minCapacity = 65536;
+
+ // Set the default value of no filters - an empty array - which is safer than using null.
+ // If we find a user provided value, this will be overwritten.
+ String[] filters = new String[0];
+
+ // Looping the environment variable list to check known variable names.
+ for (final String envItem : env) {
+ if (envItem == null) {
+ continue;
+ }
+
+ if (envItem.startsWith(startupEnv)) {
+ // Check the environment variable value to see if it's positive.
+ final String value = envItem.substring(startupEnv.length());
+ if (value.isEmpty() || value.equals("0") || value.equals("n") || value.equals("N")) {
+ // ''/'0'/'n'/'N' values mean do not start the startup profiler.
+ // There's no need to inspect other environment variables,
+ // so let's break out of the loop
+ break;
+ }
+
+ isStartupProfiling = true;
+ } else if (envItem.startsWith(intervalEnv)) {
+ // Parse the interval environment variable if present
+ final String value = envItem.substring(intervalEnv.length());
+
+ try {
+ final int intValue = Integer.parseInt(value);
+ interval = Math.max(intValue, interval);
+ } catch (final NumberFormatException err) {
+ // Failed to parse. Do nothing and just use the default value.
+ }
+ } else if (envItem.startsWith(capacityEnv)) {
+ // Parse the capacity environment variable if present
+ final String value = envItem.substring(capacityEnv.length());
+
+ try {
+ final int intValue = Integer.parseInt(value);
+ // See `scMinimumBufferEntries` variable for this value on the platform side.
+ capacity = Math.max(intValue, minCapacity);
+ } catch (final NumberFormatException err) {
+ // Failed to parse. Do nothing and just use the default value.
+ }
+ } else if (envItem.startsWith(filtersEnv)) {
+ filters = envItem.substring(filtersEnv.length()).split(",");
+ }
+ }
+
+ if (isStartupProfiling) {
+ GeckoJavaSampler.start(filters, interval, capacity);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean pumpMessageLoop(final Message msg) {
+ final Handler geckoHandler = ThreadUtils.sGeckoHandler;
+
+ if (msg.obj == geckoHandler && msg.getTarget() == geckoHandler) {
+ // Our "queue is empty" message; see runGecko()
+ return false;
+ }
+
+ if (msg.getTarget() == null) {
+ Looper.myLooper().quit();
+ } else {
+ msg.getTarget().dispatchMessage(msg);
+ }
+
+ return true;
+ }
+
+ /**
+ * Check that the current Gecko thread state matches the given state.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isState(final State state) {
+ return sNativeQueue.getState().is(state);
+ }
+
+ /**
+ * Check that the current Gecko thread state is at the given state or further along, according to
+ * the order defined in the State enum.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateAtLeast(final State state) {
+ return sNativeQueue.getState().isAtLeast(state);
+ }
+
+ /**
+ * Check that the current Gecko thread state is at the given state or prior, according to the
+ * order defined in the State enum.
+ *
+ * @param state State to check
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateAtMost(final State state) {
+ return state.isAtLeast(sNativeQueue.getState());
+ }
+
+ /**
+ * Check that the current Gecko thread state falls into an inclusive range of states, according to
+ * the order defined in the State enum.
+ *
+ * @param minState Lower range of allowable states
+ * @param maxState Upper range of allowable states
+ * @return True if the current Gecko thread state matches
+ */
+ public static boolean isStateBetween(final State minState, final State maxState) {
+ return isStateAtLeast(minState) && isStateAtMost(maxState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void setState(final State newState) {
+ checkAndSetState(null, newState);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static boolean checkAndSetState(final State expectedState, final State newState) {
+ final boolean result = sNativeQueue.checkAndSetState(expectedState, newState);
+ if (result) {
+ Log.d(LOGTAG, "State changed to " + newState);
+
+ if (sInitTimer != null && isRunning()) {
+ sInitTimer.stop();
+ sInitTimer = null;
+ }
+
+ notifyStateListeners();
+ }
+ return result;
+ }
+
+ @WrapForJNI(stubName = "SpeculativeConnect")
+ private static native void speculativeConnectNative(String uri);
+
+ public static void speculativeConnect(final String uri) {
+ // This is almost always called before Gecko loads, so we don't
+ // bother checking here if Gecko is actually loaded or not.
+ // Speculative connection depends on proxy settings,
+ // so the earliest it can happen is after profile is ready.
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "speculativeConnectNative", uri);
+ }
+
+ @UiThread
+ public static GeckoResult<Void> waitForState(final State state) {
+ final StateGeckoResult result = new StateGeckoResult(state);
+ if (isStateAtLeast(state)) {
+ result.complete(null);
+ return result;
+ }
+
+ synchronized (sStateListeners) {
+ sStateListeners.add(result);
+ }
+ return result;
+ }
+
+ private static void notifyStateListeners() {
+ synchronized (sStateListeners) {
+ final LinkedList<StateGeckoResult> newListeners = new LinkedList<>();
+ for (final StateGeckoResult result : sStateListeners) {
+ if (!isStateAtLeast(result.state)) {
+ newListeners.add(result);
+ continue;
+ }
+
+ result.complete(null);
+ }
+
+ sStateListeners = newListeners;
+ }
+ }
+
+ @WrapForJNI(stubName = "OnPause", dispatchTo = "gecko")
+ private static native void nativeOnPause();
+
+ public static void onPause() {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeOnPause();
+ } else {
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnPause");
+ }
+ }
+
+ @WrapForJNI(stubName = "OnResume", dispatchTo = "gecko")
+ private static native void nativeOnResume();
+
+ public static void onResume() {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeOnResume();
+ } else {
+ queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeOnResume");
+ }
+ }
+
+ @WrapForJNI(stubName = "CreateServices", dispatchTo = "gecko")
+ private static native void nativeCreateServices(String category, String data);
+
+ public static void createServices(final String category, final String data) {
+ if (isStateAtLeast(State.PROFILE_READY)) {
+ nativeCreateServices(category, data);
+ } else {
+ queueNativeCallUntil(
+ State.PROFILE_READY,
+ GeckoThread.class,
+ "nativeCreateServices",
+ String.class,
+ category,
+ String.class,
+ data);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ /* package */ static native long runUiThreadCallback();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public static native void forceQuit();
+
+ @WrapForJNI(dispatchTo = "gecko")
+ public static native void crash();
+
+ @WrapForJNI
+ private static void requestUiThreadCallback(final long delay) {
+ ThreadUtils.getUiHandler().postDelayed(UI_THREAD_CALLBACK, delay);
+ }
+
+ /** Queue a call to the given static method until Gecko is in the RUNNING state. */
+ public static void queueNativeCall(
+ final Class<?> cls, final String methodName, final Object... args) {
+ sNativeQueue.queueUntilReady(cls, methodName, args);
+ }
+
+ /** Queue a call to the given instance method until Gecko is in the RUNNING state. */
+ public static void queueNativeCall(
+ final Object obj, final String methodName, final Object... args) {
+ sNativeQueue.queueUntilReady(obj, methodName, args);
+ }
+
+ /** Queue a call to the given instance method until Gecko is in the RUNNING state. */
+ public static void queueNativeCallUntil(
+ final State state, final Object obj, final String methodName, final Object... args) {
+ sNativeQueue.queueUntil(state, obj, methodName, args);
+ }
+
+ /** Queue a call to the given static method until Gecko is in the RUNNING state. */
+ public static void queueNativeCallUntil(
+ final State state, final Class<?> cls, final String methodName, final Object... args) {
+ sNativeQueue.queueUntil(state, cls, methodName, args);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java
new file mode 100644
index 0000000000..5689944717
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java
@@ -0,0 +1,106 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.os.Build;
+import android.provider.Settings.Secure;
+import android.view.View;
+import android.view.inputmethod.InputMethodInfo;
+import android.view.inputmethod.InputMethodManager;
+import java.util.Collection;
+
+public final class InputMethods {
+ public static final String METHOD_ANDROID_LATINIME = "com.android.inputmethod.latin/.LatinIME";
+ // ATOK has a lot of package names since they release custom versions.
+ public static final String METHOD_ATOK_PREFIX = "com.justsystems.atokmobile";
+ public static final String METHOD_ATOK_OEM_PREFIX = "com.atok.mobile.";
+ public static final String METHOD_GOOGLE_JAPANESE_INPUT =
+ "com.google.android.inputmethod.japanese/.MozcService";
+ public static final String METHOD_ATOK_OEM_SOFTBANK =
+ "com.mobiroo.n.justsystems.atok/.AtokInputMethodService";
+ public static final String METHOD_GOOGLE_LATINIME =
+ "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME";
+ public static final String METHOD_HTC_TOUCH_INPUT = "com.htc.android.htcime/.HTCIMEService";
+ public static final String METHOD_IWNN =
+ "jp.co.omronsoft.iwnnime.ml/.standardcommon.IWnnLanguageSwitcher";
+ public static final String METHOD_OPENWNN_PLUS = "com.owplus.ime.openwnnplus/.OpenWnnJAJP";
+ public static final String METHOD_SAMSUNG = "com.sec.android.inputmethod/.SamsungKeypad";
+ public static final String METHOD_SIMEJI = "com.adamrocker.android.input.simeji/.OpenWnnSimeji";
+ public static final String METHOD_SONY =
+ "com.sonyericsson.textinput.uxp/.glue.InputMethodServiceGlue";
+ public static final String METHOD_SWIFTKEY =
+ "com.touchtype.swiftkey/com.touchtype.KeyboardService";
+ public static final String METHOD_SWYPE = "com.swype.android.inputmethod/.SwypeInputMethod";
+ public static final String METHOD_SWYPE_BETA = "com.nuance.swype.input/.IME";
+ public static final String METHOD_TOUCHPAL_KEYBOARD =
+ "com.cootek.smartinputv5/com.cootek.smartinput5.TouchPalIME";
+
+ private InputMethods() {}
+
+ public static String getCurrentInputMethod(final Context context) {
+ final String inputMethod =
+ Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD);
+ return (inputMethod != null ? inputMethod : "");
+ }
+
+ public static InputMethodInfo getInputMethodInfo(
+ final Context context, final String inputMethod) {
+ final InputMethodManager imm = getInputMethodManager(context);
+ final Collection<InputMethodInfo> infos = imm.getEnabledInputMethodList();
+ for (final InputMethodInfo info : infos) {
+ if (info.getId().equals(inputMethod)) {
+ return info;
+ }
+ }
+ return null;
+ }
+
+ public static InputMethodManager getInputMethodManager(final Context context) {
+ return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ public static void restartInput(final Context context, final View view) {
+ final InputMethodManager imm = getInputMethodManager(context);
+ if (imm != null) {
+ imm.restartInput(view);
+ }
+ }
+
+ public static boolean needsSoftResetWorkaround(final String inputMethod) {
+ // Stock latin IME on Android 4.2 and above
+ return Build.VERSION.SDK_INT >= 17
+ && (METHOD_ANDROID_LATINIME.equals(inputMethod)
+ || METHOD_GOOGLE_LATINIME.equals(inputMethod));
+ }
+
+ /**
+ * Check input method if we require a workaround to remove composition in {@link
+ * android.view.inputmethod.InputMethodManager.updateSelection}.
+ *
+ * @param inputMethod The input method name by {@link #getCurrentInputMethod}.
+ * @return true if {@link android.view.inputmethod.InputMethodManager.updateSelection} doesn't
+ * remove the composition, use {@link
+ * android.view.inputmethod.InputMehtodManager.restartInput} to remove it in this case.
+ */
+ public static boolean needsRestartInput(final String inputMethod) {
+ return inputMethod.startsWith(METHOD_ATOK_PREFIX)
+ || inputMethod.startsWith(METHOD_ATOK_OEM_PREFIX)
+ || METHOD_ATOK_OEM_SOFTBANK.equals(inputMethod);
+ }
+
+ public static boolean shouldCommitCharAsKey(final String inputMethod) {
+ return METHOD_HTC_TOUCH_INPUT.equals(inputMethod);
+ }
+
+ public static boolean needsRestartOnReplaceRemove(final Context context) {
+ final String inputMethod = getCurrentInputMethod(context);
+ return METHOD_SONY.equals(inputMethod);
+ }
+
+ // TODO: Replace usages by definition in EditorInfoCompat once available (bug 1385726).
+ public static final int IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java
new file mode 100644
index 0000000000..2003abcc6f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java
@@ -0,0 +1,137 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+/**
+ * A {@link android.view.SurfaceView} which allows a {@link android.widget.Magnifier} widget to
+ * magnify a custom {@link android.view.Surface} rather than the SurfaceView's default Surface.
+ */
+public class MagnifiableSurfaceView extends SurfaceView {
+ private static final String LOGTAG = "MagnifiableSurfaceView";
+
+ private SurfaceHolderWrapper mHolder;
+
+ public MagnifiableSurfaceView(final Context context) {
+ super(context);
+ }
+
+ @Override
+ public SurfaceHolder getHolder() {
+ if (mHolder != null) {
+ // Only return our custom holder if we are being called from the Magnifier class.
+ // Throwable.getStackTrace() is faster than Thread.getStackTrace(), but still has a cost,
+ // hence why we only check the caller if we have set an override Surface.
+ final StackTraceElement[] stackTrace = new Throwable().getStackTrace();
+ if (stackTrace.length >= 2
+ && stackTrace[1].getClassName().equals("android.widget.Magnifier")) {
+ return mHolder;
+ }
+ }
+ return super.getHolder();
+ }
+
+ /**
+ * Sets the Surface that should be magnified by a Magnifier widget.
+ *
+ * <p>This should be set immediately before calling {@link android.widget.Magnifier#show()} or
+ * {@link android.widget.Magnifier#update()}, and unset immediately afterwards.
+ *
+ * @param surface The Surface to be magnified. If null, the SurfaceView's default Surface will be
+ * used.
+ */
+ public void setMagnifierSurface(final Surface surface) {
+ if (surface != null) {
+ mHolder = new SurfaceHolderWrapper(getHolder(), surface);
+ } else {
+ mHolder = null;
+ }
+ }
+
+ /**
+ * A {@link android.view.SurfaceHolder} implementation that simply forwards all methods to a
+ * provided SurfaceHolder instance, except for getSurface() which returns a custom Surface.
+ */
+ private class SurfaceHolderWrapper implements SurfaceHolder {
+ private final SurfaceHolder mHolder;
+ private final Surface mSurface;
+
+ public SurfaceHolderWrapper(final SurfaceHolder holder, final Surface surface) {
+ mHolder = holder;
+ mSurface = surface;
+ }
+
+ @Override
+ public void addCallback(final Callback callback) {
+ mHolder.addCallback(callback);
+ }
+
+ @Override
+ public void removeCallback(final Callback callback) {
+ mHolder.removeCallback(callback);
+ }
+
+ @Override
+ public boolean isCreating() {
+ return mHolder.isCreating();
+ }
+
+ @Override
+ public void setType(final int type) {
+ mHolder.setType(type);
+ }
+
+ @Override
+ public void setFixedSize(final int width, final int height) {
+ mHolder.setFixedSize(width, height);
+ }
+
+ @Override
+ public void setSizeFromLayout() {
+ mHolder.setSizeFromLayout();
+ }
+
+ @Override
+ public void setFormat(final int format) {
+ mHolder.setFormat(format);
+ }
+
+ @Override
+ public void setKeepScreenOn(final boolean screenOn) {
+ mHolder.setKeepScreenOn(screenOn);
+ }
+
+ @Override
+ public Canvas lockCanvas() {
+ return mHolder.lockCanvas();
+ }
+
+ @Override
+ public Canvas lockCanvas(final Rect dirty) {
+ return mHolder.lockCanvas(dirty);
+ }
+
+ @Override
+ public void unlockCanvasAndPost(final Canvas canvas) {
+ mHolder.unlockCanvasAndPost(canvas);
+ }
+
+ @Override
+ public Rect getSurfaceFrame() {
+ return mHolder.getSurfaceFrame();
+ }
+
+ @Override
+ public Surface getSurface() {
+ return mSurface;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
new file mode 100644
index 0000000000..ff26d99dea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java
@@ -0,0 +1,186 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Defines a map that holds a collection of values against each key.
+ *
+ * @param <K> Key type
+ * @param <T> Value type
+ */
+public class MultiMap<K, T> {
+ private HashMap<K, List<T>> mMap;
+ private final List<T> mEmptyList = Collections.unmodifiableList(new ArrayList<>());
+
+ /**
+ * Creates a MultiMap with specified initial capacity.
+ *
+ * @param count Initial capacity
+ */
+ public MultiMap(final int count) {
+ mMap = new HashMap<>(count);
+ }
+
+ /** Creates a MultiMap with default initial capacity. */
+ public MultiMap() {
+ mMap = new HashMap<>();
+ }
+
+ private void ensure(final K key) {
+ if (!mMap.containsKey(key)) {
+ mMap.put(key, new ArrayList<>());
+ }
+ }
+
+ /**
+ * @return A map of key to the list of values associated to it
+ */
+ public Map<K, List<T>> asMap() {
+ return mMap;
+ }
+
+ /**
+ * @return The number of keys present in this map
+ */
+ public int size() {
+ return mMap.size();
+ }
+
+ /**
+ * @return whether this map is empty or not
+ */
+ public boolean isEmpty() {
+ return mMap.isEmpty();
+ }
+
+ /**
+ * Checks if a key is present in this map.
+ *
+ * @param key the key to check
+ * @return True if the map contains this key, false otherwise.
+ */
+ public boolean containsKey(final @Nullable K key) {
+ return mMap.containsKey(key);
+ }
+
+ /**
+ * Checks if a (key, value) pair is present in this map.
+ *
+ * @param key the key to check
+ * @param value the value to check
+ * @return true if there is a value associated to the given key, false otherwise
+ */
+ public boolean containsEntry(final @Nullable K key, final @Nullable T value) {
+ if (!mMap.containsKey(key)) {
+ return false;
+ }
+
+ return mMap.get(key).contains(value);
+ }
+
+ /**
+ * Gets the values associated with the given key.
+ *
+ * @param key the key to check
+ * @return the list of values associated with keys, an empty list if no values are associated with
+ * key.
+ */
+ @NonNull
+ public List<T> get(final @Nullable K key) {
+ if (!mMap.containsKey(key)) {
+ return mEmptyList;
+ }
+
+ return mMap.get(key);
+ }
+
+ /**
+ * Add a (key, value) mapping to this map.
+ *
+ * @param key the key to add
+ * @param value the value to add
+ */
+ @Nullable
+ public void add(final @NonNull K key, final @NonNull T value) {
+ ensure(key);
+ mMap.get(key).add(value);
+ }
+
+ /**
+ * Add a list of values to the given key.
+ *
+ * @param key the key to add
+ * @param values the list of values to add
+ * @return the final list of values or null if no value was added
+ */
+ @Nullable
+ public List<T> addAll(final @NonNull K key, final @NonNull List<T> values) {
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+
+ ensure(key);
+
+ final List<T> result = mMap.get(key);
+ result.addAll(values);
+ return result;
+ }
+
+ /**
+ * Remove all mappings for the given key.
+ *
+ * @param key the key
+ * @return values associated with the key or null if no values was present.
+ */
+ @Nullable
+ public List<T> remove(final @Nullable K key) {
+ return mMap.remove(key);
+ }
+
+ /**
+ * Remove a (key, value) mapping from this map
+ *
+ * @param key the key to remove
+ * @param value the value to remove
+ * @return true if the (key, value) mapping was present, false otherwise
+ */
+ @Nullable
+ public boolean remove(final @Nullable K key, final @Nullable T value) {
+ if (!mMap.containsKey(key)) {
+ return false;
+ }
+
+ final List<T> values = mMap.get(key);
+ final boolean wasPresent = values.remove(value);
+
+ if (values.isEmpty()) {
+ mMap.remove(key);
+ }
+
+ return wasPresent;
+ }
+
+ /** Remove all mappings from this map. */
+ public void clear() {
+ mMap.clear();
+ }
+
+ /**
+ * @return a set with all the keys for this map.
+ */
+ @NonNull
+ public Set<K> keySet() {
+ return mMap.keySet();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java
new file mode 100644
index 0000000000..7932e6c839
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java
@@ -0,0 +1,225 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+
+public class NativeQueue {
+ private static final String LOGTAG = "GeckoNativeQueue";
+
+ public interface State {
+ boolean is(final State other);
+
+ boolean isAtLeast(final State other);
+ }
+
+ private volatile State mState;
+ private final State mReadyState;
+
+ public NativeQueue(final State initial, final State ready) {
+ mState = initial;
+ mReadyState = ready;
+ }
+
+ public boolean isReady() {
+ return getState().isAtLeast(mReadyState);
+ }
+
+ public State getState() {
+ return mState;
+ }
+
+ public boolean setState(final State newState) {
+ return checkAndSetState(null, newState);
+ }
+
+ public synchronized boolean checkAndSetState(final State expectedState, final State newState) {
+ if (expectedState != null && !mState.is(expectedState)) {
+ return false;
+ }
+ flushQueuedLocked(newState);
+ mState = newState;
+ return true;
+ }
+
+ private static class QueuedCall {
+ public Method method;
+ public Object target;
+ public Object[] args;
+ public State state;
+
+ public QueuedCall(
+ final Method method, final Object target, final Object[] args, final State state) {
+ this.method = method;
+ this.target = target;
+ this.args = args;
+ this.state = state;
+ }
+ }
+
+ private static final int QUEUED_CALLS_COUNT = 16;
+ /* package */ final ArrayList<QueuedCall> mQueue = new ArrayList<>(QUEUED_CALLS_COUNT);
+
+ // Invoke the given Method and handle checked Exceptions.
+ private static void invokeMethod(final Method method, final Object obj, final Object[] args) {
+ try {
+ method.setAccessible(true);
+ method.invoke(obj, args);
+ } catch (final IllegalAccessException e) {
+ throw new IllegalStateException("Unexpected exception", e);
+ } catch (final InvocationTargetException e) {
+ throw new UnsupportedOperationException("Cannot make call", e.getCause());
+ }
+ }
+
+ // Queue a call to the given method.
+ private void queueNativeCallLocked(
+ final Class<?> cls,
+ final String methodName,
+ final Object obj,
+ final Object[] args,
+ final State state) {
+ final ArrayList<Class<?>> argTypes = new ArrayList<>(args.length);
+ final ArrayList<Object> argValues = new ArrayList<>(args.length);
+
+ for (int i = 0; i < args.length; i++) {
+ if (args[i] instanceof Class) {
+ argTypes.add((Class<?>) args[i]);
+ argValues.add(args[++i]);
+ continue;
+ }
+ Class<?> argType = args[i].getClass();
+ if (argType == Boolean.class) argType = Boolean.TYPE;
+ else if (argType == Byte.class) argType = Byte.TYPE;
+ else if (argType == Character.class) argType = Character.TYPE;
+ else if (argType == Double.class) argType = Double.TYPE;
+ else if (argType == Float.class) argType = Float.TYPE;
+ else if (argType == Integer.class) argType = Integer.TYPE;
+ else if (argType == Long.class) argType = Long.TYPE;
+ else if (argType == Short.class) argType = Short.TYPE;
+ argTypes.add(argType);
+ argValues.add(args[i]);
+ }
+ final Method method;
+ try {
+ method = cls.getDeclaredMethod(methodName, argTypes.toArray(new Class<?>[argTypes.size()]));
+ } catch (final NoSuchMethodException e) {
+ throw new IllegalArgumentException("Cannot find method", e);
+ }
+
+ if (!Modifier.isNative(method.getModifiers())) {
+ // As a precaution, we disallow queuing non-native methods. Queuing non-native
+ // methods is dangerous because the method could end up being called on either
+ // the original thread or the Gecko thread depending on timing. Native methods
+ // usually handle this by posting an event to the Gecko thread automatically,
+ // but there is no automatic mechanism for non-native methods.
+ throw new UnsupportedOperationException("Not allowed to queue non-native methods");
+ }
+
+ if (getState().isAtLeast(state)) {
+ invokeMethod(method, obj, argValues.toArray());
+ return;
+ }
+
+ mQueue.add(new QueuedCall(method, obj, argValues.toArray(), state));
+ }
+
+ /**
+ * Queue a call to the given instance method if the given current state does not satisfy the
+ * isReady condition.
+ *
+ * @param obj Object that declares the instance method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntilReady(
+ final Object obj, final String methodName, final Object... args) {
+ queueNativeCallLocked(obj.getClass(), methodName, obj, args, mReadyState);
+ }
+
+ /**
+ * Queue a call to the given static method if the given current state does not satisfy the isReady
+ * condition.
+ *
+ * @param cls Class that declares the static method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntilReady(
+ final Class<?> cls, final String methodName, final Object... args) {
+ queueNativeCallLocked(cls, methodName, null, args, mReadyState);
+ }
+
+ /**
+ * Queue a call to the given instance method if the given current state does not satisfy the given
+ * state.
+ *
+ * @param state The state in which the native call could be executed.
+ * @param obj Object that declares the instance method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntil(
+ final State state, final Object obj, final String methodName, final Object... args) {
+ queueNativeCallLocked(obj.getClass(), methodName, obj, args, state);
+ }
+
+ /**
+ * Queue a call to the given static method if the given current state does not satisfy the given
+ * state.
+ *
+ * @param state The state in which the native call could be executed.
+ * @param cls Class that declares the static method.
+ * @param methodName Name of the instance method.
+ * @param args Args to call the instance method with; to specify a parameter type, pass in a Class
+ * instance first, followed by the value.
+ */
+ public synchronized void queueUntil(
+ final State state, final Class<?> cls, final String methodName, final Object... args) {
+ queueNativeCallLocked(cls, methodName, null, args, state);
+ }
+
+ // Run all queued methods
+ private void flushQueuedLocked(final State state) {
+ int lastSkipped = -1;
+ for (int i = 0; i < mQueue.size(); i++) {
+ final QueuedCall call = mQueue.get(i);
+ if (call == null) {
+ // We already handled the call.
+ continue;
+ }
+ if (!state.isAtLeast(call.state)) {
+ // The call is not ready yet; skip it.
+ lastSkipped = i;
+ continue;
+ }
+ // Mark as handled.
+ mQueue.set(i, null);
+
+ invokeMethod(call.method, call.target, call.args);
+ }
+ if (lastSkipped < 0) {
+ // We're done here; release the memory
+ mQueue.clear();
+ } else if (lastSkipped < mQueue.size() - 1) {
+ // We skipped some; free up null entries at the end,
+ // but keep all the previous entries for later.
+ mQueue.subList(lastSkipped + 1, mQueue.size()).clear();
+ }
+ }
+
+ public synchronized void reset(final State initial) {
+ mQueue.clear();
+ mState = initial;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java
new file mode 100644
index 0000000000..edd6c7418a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+class ScreenManagerHelper {
+
+ /** Trigger a refresh of the cached screen information held by Gecko. */
+ public static void refreshScreenInfo() {
+ // Screen data is initialised automatically on startup, so no need to queue the call if
+ // Gecko isn't running yet.
+ if (GeckoThread.isRunning()) {
+ nativeRefreshScreenInfo();
+ }
+ }
+
+ @WrapForJNI(stubName = "RefreshScreenInfo", dispatchTo = "gecko")
+ private static native void nativeRefreshScreenInfo();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java
new file mode 100644
index 0000000000..7c6f572edc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java
@@ -0,0 +1,230 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil -*- */
+/* vim: set ts=20 sts=4 et sw=4: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import android.content.Context;
+import android.os.Build;
+import android.speech.tts.TextToSpeech;
+import android.speech.tts.UtteranceProgressListener;
+import android.util.Log;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class SpeechSynthesisService {
+ private static final String LOGTAG = "GeckoSpeechSynthesis";
+ // Object type is used to make it easier to remove android.speech dependencies using Proguard.
+ private static Object sTTS;
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void initSynth() {
+ initSynthInternal();
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void initSynthInternal() {
+ if (sTTS != null) {
+ return;
+ }
+
+ final Context ctx = GeckoAppShell.getApplicationContext();
+
+ sTTS =
+ new TextToSpeech(
+ ctx,
+ new TextToSpeech.OnInitListener() {
+ @Override
+ public void onInit(final int status) {
+ if (status != TextToSpeech.SUCCESS) {
+ Log.w(LOGTAG, "Failed to initialize TextToSpeech");
+ return;
+ }
+
+ setUtteranceListener();
+ registerVoicesByLocale();
+ }
+ });
+ }
+
+ private static TextToSpeech getTTS() {
+ return (TextToSpeech) sTTS;
+ }
+
+ private static void registerVoicesByLocale() {
+ ThreadUtils.postToBackgroundThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ final TextToSpeech tss = getTTS();
+ if (tss == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+ final Locale defaultLocale =
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2
+ ? tss.getDefaultLanguage()
+ : tss.getLanguage();
+ for (final Locale locale : getAvailableLanguages()) {
+ final Set<String> features = tss.getFeatures(locale);
+ final boolean isLocal =
+ features != null
+ && features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS);
+ final String localeStr = locale.toString();
+ registerVoice(
+ "moz-tts:android:" + localeStr,
+ locale.getDisplayName(),
+ localeStr.replace("_", "-"),
+ !isLocal,
+ defaultLocale == locale);
+ }
+ doneRegisteringVoices();
+ }
+ });
+ }
+
+ private static Set<Locale> getAvailableLanguages() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ // While this method was introduced in 21, it seems that it
+ // has not been implemented in the speech service side until 23.
+ final Set<Locale> availableLanguages = getTTS().getAvailableLanguages();
+ if (availableLanguages != null) {
+ return availableLanguages;
+ }
+ }
+ final Set<Locale> locales = new HashSet<Locale>();
+ for (final Locale locale : Locale.getAvailableLocales()) {
+ if (locale.getVariant().isEmpty() && getTTS().isLanguageAvailable(locale) > 0) {
+ locales.add(locale);
+ }
+ }
+
+ return locales;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void registerVoice(
+ String uri, String name, String locale, boolean isNetwork, boolean isDefault);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void doneRegisteringVoices();
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static String speak(
+ final String uri,
+ final String text,
+ final float rate,
+ final float pitch,
+ final float volume) {
+ final AtomicBoolean result = new AtomicBoolean(false);
+ final String utteranceId = UUID.randomUUID().toString();
+ speakInternal(uri, text, rate, pitch, volume, utteranceId, result);
+ return result.get() ? utteranceId : null;
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void speakInternal(
+ final String uri,
+ final String text,
+ final float rate,
+ final float pitch,
+ final float volume,
+ final String utteranceId,
+ final AtomicBoolean result) {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ final HashMap<String, String> params = new HashMap<String, String>();
+ params.put(TextToSpeech.Engine.KEY_PARAM_VOLUME, Float.toString(volume));
+ params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId);
+ final TextToSpeech tss = (TextToSpeech) sTTS;
+ tss.setLanguage(new Locale(uri.substring("moz-tts:android:".length())));
+ tss.setSpeechRate(rate);
+ tss.setPitch(pitch);
+ final int speakRes = tss.speak(text, TextToSpeech.QUEUE_FLUSH, params);
+ result.set(speakRes == TextToSpeech.SUCCESS);
+ }
+
+ private static void setUtteranceListener() {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ getTTS()
+ .setOnUtteranceProgressListener(
+ new UtteranceProgressListener() {
+ @Override
+ public void onDone(final String utteranceId) {
+ dispatchEnd(utteranceId);
+ }
+
+ @Override
+ public void onError(final String utteranceId) {
+ dispatchError(utteranceId);
+ }
+
+ @Override
+ public void onStart(final String utteranceId) {
+ dispatchStart(utteranceId);
+ }
+
+ @Override
+ public void onStop(final String utteranceId, final boolean interrupted) {
+ if (interrupted) {
+ dispatchEnd(utteranceId);
+ } else {
+ // utterance isn't started yet.
+ dispatchError(utteranceId);
+ }
+ }
+
+ public void onRangeStart(
+ final String utteranceId, final int start, final int end, final int frame) {
+ dispatchBoundary(utteranceId, start, end);
+ }
+ });
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchStart(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchEnd(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchError(String utteranceId);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void dispatchBoundary(String utteranceId, int start, int end);
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static void stop() {
+ stopInternal();
+ }
+
+ // Extra internal method to make it easier to remove android.speech dependencies using Proguard.
+ private static void stopInternal() {
+ if (sTTS == null) {
+ Log.w(LOGTAG, "TextToSpeech is not initialized");
+ return;
+ }
+
+ getTTS().stop();
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ // Android M has onStop method. If Android L or above, dispatch
+ // event
+ dispatchEnd(null);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java
new file mode 100644
index 0000000000..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:
+ *
+ * <p>* Real time since the device was booted, including deep sleep. Use this as a substitute for
+ * wall clock. * Uptime since the device was booted, excluding deep sleep. Use this to avoid timing
+ * a user activity when their phone is in their pocket!
+ *
+ * <p>The majority of methods in this class are defined in terms of real time.
+ */
+public class TelemetryUtils {
+ private static final String LOGTAG = "TelemetryUtils";
+
+ @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko")
+ private static native void nativeAddHistogram(String name, int value);
+
+ public static long uptime() {
+ return SystemClock.uptimeMillis();
+ }
+
+ public static long realtime() {
+ return SystemClock.elapsedRealtime();
+ }
+
+ // Define new histograms in:
+ // toolkit/components/telemetry/Histograms.json
+ public static void addToHistogram(final String name, final int value) {
+ if (GeckoThread.isRunning()) {
+ nativeAddHistogram(name, value);
+ } else {
+ GeckoThread.queueNativeCall(
+ TelemetryUtils.class, "nativeAddHistogram", String.class, name, value);
+ }
+ }
+
+ public abstract static class Timer {
+ private final long mStartTime;
+ private final String mName;
+
+ private volatile boolean mHasFinished;
+ private volatile long mElapsed = -1;
+
+ protected abstract long now();
+
+ public Timer(final String name) {
+ mName = name;
+ mStartTime = now();
+ }
+
+ public void cancel() {
+ mHasFinished = true;
+ }
+
+ public long getElapsed() {
+ return mElapsed;
+ }
+
+ public void stop() {
+ // Only the first stop counts.
+ if (mHasFinished) {
+ return;
+ }
+
+ mHasFinished = true;
+
+ final long elapsed = now() - mStartTime;
+ if (elapsed < 0) {
+ Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?");
+ return;
+ }
+
+ mElapsed = elapsed;
+ if (elapsed > Integer.MAX_VALUE) {
+ Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram.");
+ return;
+ }
+
+ addToHistogram(mName, (int) (elapsed));
+ }
+ }
+
+ public static class UptimeTimer extends Timer {
+ public UptimeTimer(final String name) {
+ super(name);
+ }
+
+ @Override
+ protected long now() {
+ return TelemetryUtils.uptime();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java
new file mode 100644
index 0000000000..805e0a3f79
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation is used to tag classes that are conditionally built behind build flags. Any
+ * generated JNI bindings will incorporate the specified build flags.
+ */
+@Target({ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface BuildFlag {
+ /**
+ * Preprocessor macro for conditionally building the generated bindings. "MOZ_FOO" wraps generated
+ * bindings in "#ifdef MOZ_FOO / #endif" "!MOZ_FOO" wraps generated bindings in "#ifndef MOZ_FOO /
+ * #endif"
+ */
+ String value() default "";
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java
new file mode 100644
index 0000000000..d6140a1ffb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface JNITarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java
new file mode 100644
index 0000000000..e873ebeb96
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java
@@ -0,0 +1,18 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/*
+ * Used to indicate to ProGuard that this definition is accessed
+ * via reflection and should not be stripped from the source.
+ */
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface ReflectionTarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java
new file mode 100644
index 0000000000..e15875dc8b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface RobocopTarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java
new file mode 100644
index 0000000000..f58dea1487
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD})
+@Retention(RetentionPolicy.CLASS)
+public @interface WebRTCJNITarget {}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java
new file mode 100644
index 0000000000..6a3fcfcb1c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * This annotation is used to tag methods that are to have wrapper methods generated. Such methods
+ * will be protected from destruction by ProGuard, and allow us to avoid writing by hand large
+ * amounts of boring boilerplate.
+ */
+@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface WrapForJNI {
+ /** Skip this member when generating wrappers for a whole class. */
+ boolean skip() default false;
+
+ /**
+ * Optional parameter specifying the name of the generated method stub. If omitted, the
+ * capitalized name of the Java method will be used.
+ */
+ String stubName() default "";
+
+ /**
+ * Action to take if member access returns an exception. - "abort" will cause a crash if there is
+ * a pending exception. - "ignore" will not handle any pending exceptions; it is then the caller's
+ * responsibility to handle exceptions. - "nsresult" will clear any pending exceptions and return
+ * an error code; not supported for native methods.
+ */
+ String exceptionMode() default "abort";
+
+ /**
+ * The thread that the method will be called from. One of "any", "gecko", or "ui". Not supported
+ * for fields.
+ */
+ String calledFrom() default "any";
+
+ /**
+ * The thread that the method call will be dispatched to. - "current" indicates no dispatching;
+ * only supported value for fields, constructors, non-native methods, and non-void native methods.
+ * - "gecko" indicates dispatching to the Gecko XPCOM (nsThread) event queue. - "gecko_priority"
+ * indicates dispatching to the Gecko widget (nsAppShell) event queue; in most cases, events in
+ * the widget event queue (aka native event queue) are favored over events in the XPCOM event
+ * queue. - "proxy" indicates dispatching to a proxy function as a function object; see
+ * widget/jni/Natives.h.
+ */
+ String dispatchTo() default "current";
+
+ /** Generate a getter instead of a literal. Only supported for static final fields. */
+ boolean noLiteral() default false;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java
new file mode 100644
index 0000000000..c87bf466d0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java
@@ -0,0 +1,72 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.view.Choreographer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/** This class receives HW vsync events through a {@link Choreographer}. */
+@WrapForJNI
+/* package */ final class AndroidVsync extends JNIObject implements Choreographer.FrameCallback {
+ @WrapForJNI
+ @Override // JNIObject
+ protected native void disposeNative();
+
+ private static final String LOGTAG = "AndroidVsync";
+
+ /* package */ Choreographer mChoreographer;
+ private volatile boolean mObservingVsync;
+
+ public AndroidVsync() {
+ final Handler mainHandler = new Handler(Looper.getMainLooper());
+ mainHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ mChoreographer = Choreographer.getInstance();
+ if (mObservingVsync) {
+ mChoreographer.postFrameCallback(AndroidVsync.this);
+ }
+ }
+ });
+ }
+
+ @WrapForJNI(stubName = "NotifyVsync")
+ private native void nativeNotifyVsync(final long frameTimeNanos);
+
+ // Choreographer callback implementation.
+ public void doFrame(final long frameTimeNanos) {
+ if (mObservingVsync) {
+ mChoreographer.postFrameCallback(this);
+ nativeNotifyVsync(frameTimeNanos);
+ }
+ }
+
+ /**
+ * Start/stop observing Vsync event.
+ *
+ * @param enable true to start observing; false to stop.
+ * @return true if observing and false if not.
+ */
+ @WrapForJNI
+ public synchronized boolean observeVsync(final boolean enable) {
+ if (mObservingVsync != enable) {
+ mObservingVsync = enable;
+
+ if (mChoreographer != null) {
+ if (enable) {
+ mChoreographer.postFrameCallback(this);
+ } else {
+ mChoreographer.removeFrameCallback(this);
+ }
+ }
+ }
+ return mObservingVsync;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java
new file mode 100644
index 0000000000..1378a284b7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java
@@ -0,0 +1,26 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gfx;
+
+import android.os.RemoteException;
+import android.view.Surface;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class CompositorSurfaceManager {
+ private static final String LOGTAG = "CompSurfManager";
+
+ private ICompositorSurfaceManager mManager;
+
+ public CompositorSurfaceManager(final ICompositorSurfaceManager aManager) {
+ mManager = aManager;
+ }
+
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void onSurfaceChanged(final int widgetId, final Surface surface)
+ throws RemoteException {
+ mManager.onSurfaceChanged(widgetId, surface);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java
new file mode 100644
index 0000000000..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<GeckoSurface> CREATOR =
+ new Parcelable.Creator<GeckoSurface>() {
+ 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<GeckoSurfaceTexture> sSurfaceTextures =
+ new LongSparseArray<GeckoSurfaceTexture>();
+
+ private static LongSparseArray<LinkedList<GeckoSurfaceTexture>> sUnusedTextures =
+ new LongSparseArray<LinkedList<GeckoSurfaceTexture>>();
+
+ private long mHandle;
+ private boolean mIsSingleBuffer;
+
+ private long mAttachedContext;
+ private int mTexName;
+
+ private GeckoSurfaceTexture.Callbacks mListener;
+ private AtomicInteger mUseCount;
+ private boolean mFinalized;
+
+ private long mUpstream;
+ private NativeGLBlitHelper mBlitter;
+
+ private GeckoSurfaceTexture(final long handle) {
+ super(0);
+ init(handle, false);
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.KITKAT)
+ private GeckoSurfaceTexture(final long handle, final boolean singleBufferMode) {
+ super(0, singleBufferMode);
+ init(handle, singleBufferMode);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ // We only want finalize() to be called once
+ if (mFinalized) {
+ return;
+ }
+
+ mFinalized = true;
+ super.finalize();
+ }
+
+ private void init(final long handle, final boolean singleBufferMode) {
+ mHandle = handle;
+ mIsSingleBuffer = singleBufferMode;
+ mUseCount = new AtomicInteger(1);
+
+ // Start off detached
+ detachFromGLContext();
+ }
+
+ @WrapForJNI
+ public long getHandle() {
+ return mHandle;
+ }
+
+ @WrapForJNI
+ public int getTexName() {
+ return mTexName;
+ }
+
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void attachToGLContext(final long context, final int texName) {
+ if (context == mAttachedContext && texName == mTexName) {
+ return;
+ }
+
+ attachToGLContext(texName);
+
+ mAttachedContext = context;
+ mTexName = texName;
+ }
+
+ @Override
+ @WrapForJNI(exceptionMode = "nsresult")
+ public synchronized void detachFromGLContext() {
+ super.detachFromGLContext();
+
+ mAttachedContext = mTexName = 0;
+ }
+
+ @WrapForJNI
+ public synchronized boolean isAttachedToGLContext(final long context) {
+ return mAttachedContext == context;
+ }
+
+ @WrapForJNI
+ public boolean isSingleBuffer() {
+ return mIsSingleBuffer;
+ }
+
+ @Override
+ @WrapForJNI
+ public synchronized void updateTexImage() {
+ try {
+ if (mUpstream != 0) {
+ SurfaceAllocator.sync(mUpstream);
+ }
+ super.updateTexImage();
+ if (mListener != null) {
+ mListener.onUpdateTexImage();
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "updateTexImage() failed", e);
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ mUpstream = 0;
+ if (mBlitter != null) {
+ mBlitter.close();
+ }
+ try {
+ super.release();
+ synchronized (sSurfaceTextures) {
+ sSurfaceTextures.remove(mHandle);
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "release() failed", e);
+ }
+ }
+
+ @Override
+ @WrapForJNI
+ public synchronized void releaseTexImage() {
+ if (!mIsSingleBuffer) {
+ return;
+ }
+
+ try {
+ super.releaseTexImage();
+ if (mListener != null) {
+ mListener.onReleaseTexImage();
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "releaseTexImage() failed", e);
+ }
+ }
+
+ public synchronized void setListener(final GeckoSurfaceTexture.Callbacks listener) {
+ mListener = listener;
+ }
+
+ @WrapForJNI
+ public static boolean isSingleBufferSupported() {
+ return Build.VERSION.SDK_INT >= 19;
+ }
+
+ @WrapForJNI
+ public synchronized void incrementUse() {
+ mUseCount.incrementAndGet();
+ }
+
+ @WrapForJNI
+ public synchronized void decrementUse() {
+ final int useCount = mUseCount.decrementAndGet();
+
+ if (useCount == 0) {
+ setListener(null);
+
+ if (mAttachedContext == 0) {
+ release();
+ synchronized (sUnusedTextures) {
+ sSurfaceTextures.remove(mHandle);
+ }
+ return;
+ }
+
+ synchronized (sUnusedTextures) {
+ LinkedList<GeckoSurfaceTexture> list = sUnusedTextures.get(mAttachedContext);
+ if (list == null) {
+ list = new LinkedList<GeckoSurfaceTexture>();
+ sUnusedTextures.put(mAttachedContext, list);
+ }
+ list.addFirst(this);
+ }
+ }
+ }
+
+ @WrapForJNI
+ public static void destroyUnused(final long context) {
+ final LinkedList<GeckoSurfaceTexture> list;
+ synchronized (sUnusedTextures) {
+ list = sUnusedTextures.get(context);
+ sUnusedTextures.delete(context);
+ }
+
+ if (list == null) {
+ return;
+ }
+
+ for (final GeckoSurfaceTexture tex : list) {
+ try {
+ if (tex.isSingleBuffer()) {
+ tex.releaseTexImage();
+ }
+
+ tex.detachFromGLContext();
+ tex.release();
+
+ // We need to manually call finalize here, otherwise we can run out
+ // of file descriptors if the GC doesn't kick in soon enough. Bug 1416015.
+ try {
+ tex.finalize();
+ } catch (final Throwable t) {
+ Log.e(LOGTAG, "Failed to finalize SurfaceTexture", t);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to destroy SurfaceTexture", e);
+ }
+ }
+ }
+
+ public static GeckoSurfaceTexture acquire(final boolean singleBufferMode, final long handle) {
+ if (singleBufferMode && !isSingleBufferSupported()) {
+ throw new IllegalArgumentException("single buffer mode not supported on API version < 19");
+ }
+
+ // 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<Long> mFrameTimes;
+ private static long mFrameStartTime;
+
+ private static void initialiseRecordingArrays() {
+ if (mFrameTimes == null) {
+ mFrameTimes = new ArrayList<Long>(EXPECTED_FRAME_COUNT);
+ } else {
+ mFrameTimes.clear();
+ }
+ }
+
+ @RobocopTarget
+ public static void startFrameTimeRecording() {
+ if (mRecordingFrames) {
+ Log.e(LOGTAG, "Error: startFrameTimeRecording() called while already recording!");
+ return;
+ }
+ mRecordingFrames = true;
+ initialiseRecordingArrays();
+ mFrameStartTime = SystemClock.uptimeMillis();
+ }
+
+ @RobocopTarget
+ public static List<Long> stopFrameTimeRecording() {
+ if (!mRecordingFrames) {
+ Log.e(LOGTAG, "Error: stopFrameTimeRecording() called when not recording!");
+ return null;
+ }
+ mRecordingFrames = false;
+ return mFrameTimes;
+ }
+
+ public static void recordFrameTime() {
+ // this will be called often, so try to make it as quick as possible
+ if (mRecordingFrames) {
+ mFrameTimes.add(SystemClock.uptimeMillis() - mFrameStartTime);
+ }
+ }
+
+ @RobocopTarget
+ public static void startCheckerboardRecording() {
+ throw new UnsupportedOperationException();
+ }
+
+ @RobocopTarget
+ public static List<Float> stopCheckerboardRecording() {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java
new file mode 100644
index 0000000000..3244519da1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java
@@ -0,0 +1,77 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.gfx;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class RemoteSurfaceAllocator extends ISurfaceAllocator.Stub {
+ private static final String LOGTAG = "RemoteSurfaceAllocator";
+
+ private static RemoteSurfaceAllocator mInstance;
+
+ private final int mAllocatorId;
+ /// Monotonically increasing counter used to generate unique handles
+ /// for each SurfaceTexture by combining with mAllocatorId.
+ private static AtomicInteger sNextHandle = new AtomicInteger(1);
+
+ /**
+ * Retrieves the singleton allocator instance for this process.
+ *
+ * @param allocatorId A unique ID identifying the process this instance belongs to, which must be
+ * 0 for the parent process instance.
+ */
+ public static synchronized RemoteSurfaceAllocator getInstance(final int allocatorId) {
+ if (mInstance == null) {
+ mInstance = new RemoteSurfaceAllocator(allocatorId);
+ }
+ return mInstance;
+ }
+
+ private RemoteSurfaceAllocator(final int allocatorId) {
+ mAllocatorId = allocatorId;
+ }
+
+ @Override
+ public GeckoSurface acquireSurface(
+ final int width, final int height, final boolean singleBufferMode) {
+ final long handle = ((long) mAllocatorId << 32) | sNextHandle.getAndIncrement();
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.acquire(singleBufferMode, handle);
+
+ if (gst == null) {
+ return null;
+ }
+
+ if (width > 0 && height > 0) {
+ gst.setDefaultBufferSize(width, height);
+ }
+
+ return new GeckoSurface(gst);
+ }
+
+ @Override
+ public void releaseSurface(final long handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.decrementUse();
+ }
+ }
+
+ @Override
+ public void configureSync(final SyncConfig config) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(config.sourceTextureHandle);
+ if (gst != null) {
+ gst.configureSnapshot(config.targetSurface, config.width, config.height);
+ }
+ }
+
+ @Override
+ public void sync(final long handle) {
+ final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle);
+ if (gst != null) {
+ gst.takeSnapshot();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java
new file mode 100644
index 0000000000..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<GeckoSurface> sSurfaces =
+ new LongSparseArray<GeckoSurface>();
+
+ private static synchronized void ensureConnection() {
+ if (sAllocator != null) {
+ return;
+ }
+
+ try {
+ if (GeckoAppShell.isParentProcess()) {
+ sAllocator = GeckoProcessManager.getInstance().getSurfaceAllocator();
+ } else {
+ sAllocator = GeckoServiceChildProcess.getSurfaceAllocator();
+ }
+
+ if (sAllocator == null) {
+ Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator");
+ return;
+ }
+ sAllocator
+ .asBinder()
+ .linkToDeath(
+ new IBinder.DeathRecipient() {
+ @Override
+ public void binderDied() {
+ Log.w(LOGTAG, "RemoteSurfaceAllocator died");
+ synchronized (SurfaceAllocator.class) {
+ // Our connection to the remote allocator has died, so all our surfaces are
+ // invalid. Release them all now. When their owners attempt to render in to
+ // them they can detect they have been released and allocate new ones instead.
+ for (int i = 0; i < sSurfaces.size(); i++) {
+ sSurfaces.valueAt(i).release();
+ }
+ sSurfaces.clear();
+ sAllocator = null;
+ }
+ }
+ },
+ 0);
+ } catch (final RemoteException e) {
+ Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator", e);
+ sAllocator = null;
+ }
+ }
+
+ @WrapForJNI
+ public static synchronized GeckoSurface acquireSurface(
+ final int width, final int height, final boolean singleBufferMode) {
+ try {
+ ensureConnection();
+
+ if (sAllocator == null) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface: not connected");
+ return null;
+ }
+
+ if (singleBufferMode && !GeckoSurfaceTexture.isSingleBufferSupported()) {
+ return null;
+ }
+
+ final GeckoSurface surface = sAllocator.acquireSurface(width, height, singleBufferMode);
+ if (surface == null) {
+ Log.w(LOGTAG, "Failed to acquire GeckoSurface: RemoteSurfaceAllocator returned null");
+ return null;
+ }
+ sSurfaces.put(surface.getHandle(), surface);
+
+ if (!surface.inProcess()) {
+ 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<SurfaceControl, SurfaceControl> mChildSurfaceControls = new WeakHashMap<>();
+
+ @WrapForJNI
+ public static SurfaceControlManager getInstance() {
+ return sInstance;
+ }
+
+ // Returns a Surface of the requested size that will be composited in to the specified
+ // SurfaceControl.
+ @RequiresApi(api = Build.VERSION_CODES.Q)
+ @WrapForJNI(exceptionMode = "abort")
+ public synchronized Surface getChildSurface(
+ final SurfaceControl parent, final int width, final int height) {
+ SurfaceControl child = mChildSurfaceControls.get(parent);
+ if (child == null) {
+ // We must periodically check if any of the SurfaceControls we are managing have been
+ // destroyed, as we are unable to directly listen to their SurfaceViews' surfaceDestroyed
+ // callbacks, and they may not be attached to any compositor when they are destroyed meaning
+ // we cannot perform cleanup in response to the compositor being paused.
+ // Doing so here, when we encounter a new SurfaceControl instance, is a reasonable guess as to
+ // when a previous instance may have been released.
+ final Iterator<Map.Entry<SurfaceControl, SurfaceControl>> it =
+ mChildSurfaceControls.entrySet().iterator();
+ while (it.hasNext()) {
+ final Map.Entry<SurfaceControl, SurfaceControl> entry = it.next();
+ if (!entry.getKey().isValid()) {
+ it.remove();
+ }
+ }
+
+ child = new SurfaceControl.Builder().setParent(parent).setName("GeckoSurface").build();
+ mChildSurfaceControls.put(parent, child);
+ }
+
+ new SurfaceControl.Transaction()
+ .setVisibility(child, true)
+ .setBufferSize(child, width, height)
+ .apply();
+
+ return new Surface(child);
+ }
+
+ // 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<SyncConfig> CREATOR =
+ new Creator<SyncConfig>() {
+ @Override
+ public SyncConfig createFromParcel(final Parcel parcel) {
+ return new SyncConfig(parcel);
+ }
+
+ @Override
+ public SyncConfig[] newArray(final int i) {
+ return new SyncConfig[i];
+ }
+ };
+
+ private SyncConfig(final Parcel parcel) {
+ sourceTextureHandle = parcel.readLong();
+ targetSurface = GeckoSurface.CREATOR.createFromParcel(parcel);
+ width = parcel.readInt();
+ height = parcel.readInt();
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel parcel, final int flags) {
+ parcel.writeLong(sourceTextureHandle);
+ targetSurface.writeToParcel(parcel, flags);
+ parcel.writeInt(width);
+ parcel.writeInt(height);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java
new file mode 100644
index 0000000000..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<GeckoHLSSample> getSamples(TrackType trackType, int number);
+
+ public long getBufferedPosition();
+
+ public int getNumberOfTracks(TrackType trackType);
+
+ public GeckoVideoInfo getVideoInfo(int index);
+
+ public GeckoAudioInfo getAudioInfo(int index);
+
+ public boolean seek(long positionUs);
+
+ public long getNextKeyFrameTime();
+
+ public void suspend();
+
+ public void resume();
+
+ public void play();
+
+ public void pause();
+
+ public void release();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java
new file mode 100644
index 0000000000..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<Integer> mAvailableInputBuffers = new LinkedList<>();
+ private Queue<Sample> mDequeuedSamples = new LinkedList<>();
+ private Queue<Input> mInputSamples = new LinkedList<>();
+ private boolean mStopped;
+
+ private synchronized Sample onAllocate(final int size) {
+ final Sample sample = mSamplePool.obtainInput(size);
+ sample.session = mSession;
+ mDequeuedSamples.add(sample);
+ return sample;
+ }
+
+ private synchronized void onSample(final Sample sample) {
+ if (sample == null) {
+ // Ignore empty input.
+ mSamplePool.recycleInput(mDequeuedSamples.remove());
+ Log.w(LOGTAG, "WARN: empty input sample");
+ return;
+ }
+
+ if (sample.isEOS()) {
+ queueSample(sample);
+ return;
+ }
+
+ if (sample.session >= mSession) {
+ final Sample dequeued = mDequeuedSamples.remove();
+ dequeued.setBufferInfo(sample.info);
+ dequeued.setCryptoInfo(sample.cryptoInfo);
+ queueSample(dequeued);
+ }
+
+ sample.dispose();
+ }
+
+ private void queueSample(final Sample sample) {
+ if (!mInputSamples.offer(new Input(sample))) {
+ reportError(Error.FATAL, new Exception("FAIL: input sample queue is full"));
+ return;
+ }
+
+ try {
+ feedSampleToBuffer();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private synchronized void onBuffer(final int index) {
+ if (mStopped || !isValidBuffer(index)) {
+ return;
+ }
+
+ if (!mHasInputCapacitySet) {
+ final int capacity = mCodec.getInputBuffer(index).capacity();
+ if (capacity > 0) {
+ mSamplePool.setInputBufferSize(capacity);
+ mHasInputCapacitySet = true;
+ }
+ }
+
+ if (mAvailableInputBuffers.offer(index)) {
+ feedSampleToBuffer();
+ } else {
+ reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full"));
+ }
+ }
+
+ private boolean isValidBuffer(final int index) {
+ try {
+ return mCodec.getInputBuffer(index) != null;
+ } catch (final IllegalStateException e) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "invalid input buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private void feedSampleToBuffer() {
+ while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) {
+ final int index = mAvailableInputBuffers.poll();
+ if (!isValidBuffer(index)) {
+ continue;
+ }
+ int len = 0;
+ final Sample sample = mInputSamples.poll().sample;
+ final long pts = sample.info.presentationTimeUs;
+ final int flags = sample.info.flags;
+ final MediaCodec.CryptoInfo cryptoInfo = sample.cryptoInfo;
+ if (!sample.isEOS() && sample.bufferId != Sample.NO_BUFFER) {
+ len = sample.info.size;
+ final ByteBuffer buf = mCodec.getInputBuffer(index);
+ try {
+ mSamplePool
+ .getInputBuffer(sample.bufferId)
+ .writeToByteBuffer(buf, sample.info.offset, len);
+ } catch (final IOException e) {
+ e.printStackTrace();
+ len = 0;
+ }
+ mSamplePool.recycleInput(sample);
+ }
+
+ try {
+ if (cryptoInfo != null && len > 0) {
+ mCodec.queueSecureInputBuffer(index, 0, cryptoInfo, pts, flags);
+ } else {
+ mCodec.queueInputBuffer(index, 0, len, pts, flags);
+ }
+ mCallbacks.onInputQueued(pts);
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ return;
+ }
+ }
+ reportPendingInputs();
+ }
+
+ private void reportPendingInputs() {
+ try {
+ for (final Input i : mInputSamples) {
+ if (!i.reported) {
+ i.reported = true;
+ mCallbacks.onInputPending(i.sample.info.presentationTimeUs);
+ }
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ }
+
+ private synchronized void reset() {
+ for (final Input i : mInputSamples) {
+ if (!i.sample.isEOS()) {
+ mSamplePool.recycleInput(i.sample);
+ }
+ }
+ mInputSamples.clear();
+
+ for (final Sample s : mDequeuedSamples) {
+ mSamplePool.recycleInput(s);
+ }
+ mDequeuedSamples.clear();
+
+ mAvailableInputBuffers.clear();
+ }
+
+ private synchronized void start() {
+ if (!mStopped) {
+ return;
+ }
+ mStopped = false;
+ }
+
+ private synchronized void stop() {
+ if (mStopped) {
+ return;
+ }
+ mStopped = true;
+ reset();
+ }
+ }
+
+ private static final class Output {
+ public final Sample sample;
+ public final int index;
+
+ public Output(final Sample sample, final int index) {
+ this.sample = sample;
+ this.index = index;
+ }
+ }
+
+ private class OutputProcessor {
+ private final boolean mRenderToSurface;
+ private boolean mHasOutputCapacitySet;
+ private Queue<Output> mSentOutputs = new LinkedList<>();
+ private boolean mStopped;
+
+ private OutputProcessor(final boolean renderToSurface) {
+ mRenderToSurface = renderToSurface;
+ }
+
+ private synchronized void onBuffer(final int index, final MediaCodec.BufferInfo info) {
+ if (mStopped || !isValidBuffer(index)) {
+ return;
+ }
+
+ try {
+ final Sample output = obtainOutputSample(index, info);
+ mSentOutputs.add(new Output(output, index));
+ output.session = mSession;
+ mCallbacks.onOutput(output);
+ } catch (final Exception e) {
+ e.printStackTrace();
+ mCodec.releaseOutputBuffer(index, false);
+ }
+
+ final boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ if (DEBUG && eos) {
+ Log.d(LOGTAG, "output EOS");
+ }
+ }
+
+ private boolean isValidBuffer(final int index) {
+ try {
+ return (mCodec.getOutputBuffer(index) != null) || mRenderToSurface;
+ } catch (final IllegalStateException e) {
+ if (DEBUG) {
+ Log.e(LOGTAG, "invalid buffer#" + index, e);
+ }
+ return false;
+ }
+ }
+
+ private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) {
+ final Sample sample = mSamplePool.obtainOutput(info);
+
+ if (mRenderToSurface) {
+ return sample;
+ }
+
+ final ByteBuffer output = mCodec.getOutputBuffer(index);
+ if (!mHasOutputCapacitySet) {
+ final int capacity = output.capacity();
+ if (capacity > 0) {
+ mSamplePool.setOutputBufferSize(capacity);
+ mHasOutputCapacitySet = true;
+ }
+ }
+
+ if (info.size > 0) {
+ try {
+ mSamplePool
+ .getOutputBuffer(sample.bufferId)
+ .readFromByteBuffer(output, info.offset, info.size);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage());
+ }
+ }
+
+ return sample;
+ }
+
+ private synchronized void onRelease(final Sample sample, final boolean render) {
+ final Output output = mSentOutputs.poll();
+ if (output != null) {
+ mCodec.releaseOutputBuffer(output.index, render);
+ mSamplePool.recycleOutput(output.sample);
+ } else if (DEBUG) {
+ Log.d(LOGTAG, sample + " already released");
+ }
+
+ sample.dispose();
+ }
+
+ private synchronized void onFormatChanged(final MediaFormat format) {
+ if (mStopped) {
+ return;
+ }
+ try {
+ mCallbacks.onOutputFormatChanged(new FormatParam(format));
+ } catch (final RemoteException re) {
+ // Dead recipient.
+ re.printStackTrace();
+ }
+ }
+
+ private synchronized void reset() {
+ for (final Output o : mSentOutputs) {
+ mCodec.releaseOutputBuffer(o.index, false);
+ mSamplePool.recycleOutput(o.sample);
+ }
+ mSentOutputs.clear();
+ }
+
+ private synchronized void start() {
+ if (!mStopped) {
+ return;
+ }
+ mStopped = false;
+ }
+
+ private synchronized void stop() {
+ if (mStopped) {
+ return;
+ }
+ mStopped = true;
+ reset();
+ }
+ }
+
+ private volatile ICodecCallbacks mCallbacks;
+ private GeckoSurface mSurface;
+ private AsyncCodec mCodec;
+ private InputProcessor mInputProcessor;
+ private OutputProcessor mOutputProcessor;
+ private long mSession;
+ private SamplePool mSamplePool;
+ // Values will be updated after configure called.
+ private volatile boolean mIsAdaptivePlaybackSupported = false;
+ private volatile boolean mIsHardwareAccelerated = false;
+ private boolean mIsTunneledPlaybackSupported = false;
+
+ public synchronized void setCallbacks(final ICodecCallbacks callbacks) throws RemoteException {
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Callbacks is dead");
+ try {
+ release();
+ } catch (final RemoteException e) {
+ // Nowhere to report the error.
+ }
+ }
+
+ @Override
+ public synchronized boolean configure(
+ final FormatParam format, final GeckoSurface surface, final int flags, final String drmStubId)
+ throws RemoteException {
+ if (mCallbacks == null) {
+ Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()");
+ return false;
+ }
+
+ if (mCodec != null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "release existing codec: " + mCodec);
+ }
+ mCodec.release();
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "configure " + this);
+ }
+
+ final MediaFormat fmt = format.asFormat();
+ final String mime = fmt.getString(MediaFormat.KEY_MIME);
+ if (mime == null || mime.isEmpty()) {
+ Log.e(LOGTAG, "invalid MIME type: " + mime);
+ return false;
+ }
+
+ final List<String> found =
+ findMatchingCodecNames(fmt, flags == MediaCodec.CONFIGURE_FLAG_ENCODE);
+ for (final String name : found) {
+ final AsyncCodec codec =
+ configureCodec(
+ name, fmt, surface != 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<String> findMatchingCodecNames(final MediaFormat format, final boolean isEncoder) {
+ final String mimeType = format.getString(MediaFormat.KEY_MIME);
+ // Missing width and height value in format means audio;
+ // Video format should never has 0 width or height.
+ final int width =
+ format.containsKey(MediaFormat.KEY_WIDTH) ? format.getInteger(MediaFormat.KEY_WIDTH) : 0;
+ final int height =
+ format.containsKey(MediaFormat.KEY_HEIGHT) ? format.getInteger(MediaFormat.KEY_HEIGHT) : 0;
+
+ final int numCodecs = MediaCodecList.getCodecCount();
+ final List<String> found = new ArrayList<>();
+ for (int i = 0; i < numCodecs; i++) {
+ final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i);
+ if (info.isEncoder() == !isEncoder) {
+ continue;
+ }
+
+ final String[] types = info.getSupportedTypes();
+ for (final String t : types) {
+ if (!t.equalsIgnoreCase(mimeType)) {
+ continue;
+ }
+ final String name = info.getName();
+ // API 21+ provide a method to query whether supplied size is supported. For
+ // older version, just avoid software video encoders.
+ if (isEncoder && width > 0 && height > 0) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ final VideoCapabilities c =
+ info.getCapabilitiesForType(mimeType).getVideoCapabilities();
+ if (c != null && !c.isSizeSupported(width, height)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, name + ": " + width + "x" + height + " not supported");
+ }
+ continue;
+ }
+ } else if (name.startsWith(SW_CODEC_PREFIX)) {
+ continue;
+ }
+ }
+
+ found.add(name);
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "found " + (isEncoder ? "encoder:" : "decoder:") + name + " for mime:" + mimeType);
+ }
+ }
+ }
+ return found;
+ }
+
+ private AsyncCodec configureCodec(
+ final String name,
+ final MediaFormat format,
+ final Surface surface,
+ final int flags,
+ final String drmStubId) {
+ try {
+ final AsyncCodec codec = AsyncCodecFactory.create(name);
+ codec.setCallbacks(new Callbacks(), null);
+
+ final MediaCrypto crypto = RemoteMediaDrmBridgeStub.getMediaCrypto(drmStubId);
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "configure mediacodec with crypto(" + (crypto != null) + ") / Id :" + drmStubId);
+ }
+
+ if (surface != null) {
+ setupAdaptivePlayback(codec, format);
+ }
+
+ codec.configure(format, surface, crypto, flags);
+ return codec;
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "codec creation error", e);
+ return null;
+ }
+ }
+
+ private void setupAdaptivePlayback(final AsyncCodec codec, final MediaFormat format) {
+ // Video decoder should config with adaptive playback capability.
+ mIsAdaptivePlaybackSupported =
+ codec.isAdaptivePlaybackSupported(format.getString(MediaFormat.KEY_MIME));
+ if (mIsAdaptivePlaybackSupported) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "codec supports adaptive playback = " + mIsAdaptivePlaybackSupported);
+ }
+ // TODO: may need to find a way to not use hard code to decide the max w/h.
+ format.setInteger(MediaFormat.KEY_MAX_WIDTH, 1920);
+ format.setInteger(MediaFormat.KEY_MAX_HEIGHT, 1080);
+ }
+ }
+
+ @Override
+ public synchronized boolean isAdaptivePlaybackSupported() {
+ return mIsAdaptivePlaybackSupported;
+ }
+
+ @Override
+ public synchronized boolean isHardwareAccelerated() {
+ return mIsHardwareAccelerated;
+ }
+
+ @Override
+ public synchronized boolean isTunneledPlaybackSupported() {
+ return mIsTunneledPlaybackSupported;
+ }
+
+ @Override
+ public synchronized void start() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "start " + this);
+ }
+ mInputProcessor.start();
+ mOutputProcessor.start();
+ try {
+ mCodec.start();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ private void reportError(final Error error, final Exception e) {
+ if (e != null) {
+ e.printStackTrace();
+ }
+ try {
+ mCallbacks.onError(error == Error.FATAL);
+ } catch (final NullPointerException ne) {
+ // mCallbacks has been disposed by release().
+ } catch (final RemoteException re) {
+ re.printStackTrace();
+ }
+ }
+
+ @Override
+ public synchronized void stop() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop " + this);
+ }
+ try {
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.stop();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void flush() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "flush " + this);
+ }
+ try {
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.flush();
+ if (DEBUG) {
+ Log.d(LOGTAG, "flushed " + this);
+ }
+ mInputProcessor.start();
+ mOutputProcessor.start();
+ mCodec.resumeReceivingInputs();
+ mSession++;
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized Sample dequeueInput(final int size) throws RemoteException {
+ try {
+ return mInputProcessor.onAllocate(size);
+ } catch (final Exception e) {
+ // Translate allocation error to remote exception.
+ throw new RemoteException(e.getMessage());
+ }
+ }
+
+ @Override
+ public synchronized SampleBuffer getInputBuffer(final int id) {
+ if (mSamplePool == null) {
+ return null;
+ }
+ return mSamplePool.getInputBuffer(id);
+ }
+
+ @Override
+ public synchronized SampleBuffer getOutputBuffer(final int id) {
+ if (mSamplePool == null) {
+ return null;
+ }
+ return mSamplePool.getOutputBuffer(id);
+ }
+
+ @Override
+ public synchronized void queueInput(final Sample sample) throws RemoteException {
+ try {
+ mInputProcessor.onSample(sample);
+ } catch (final Exception e) {
+ throw new RemoteException(e.getMessage());
+ }
+ }
+
+ @Override
+ public synchronized void setBitrate(final int bps) {
+ try {
+ mCodec.setBitrate(bps);
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void releaseOutput(final Sample sample, final boolean render) {
+ try {
+ mOutputProcessor.onRelease(sample, render);
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ }
+
+ @Override
+ public synchronized void release() throws RemoteException {
+ if (DEBUG) {
+ Log.d(LOGTAG, "release " + this);
+ }
+ try {
+ // In case Codec.stop() is not called yet.
+ mInputProcessor.stop();
+ mOutputProcessor.stop();
+
+ mCodec.release();
+ } catch (final Exception e) {
+ reportError(Error.FATAL, e);
+ }
+ mCodec = null;
+ mSamplePool.reset();
+ mSamplePool = null;
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ if (mSurface != null) {
+ mSurface.release();
+ mSurface = null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java
new file mode 100644
index 0000000000..e31ea4b132
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java
@@ -0,0 +1,508 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.DeadObjectException;
+import android.os.RemoteException;
+import android.util.Log;
+import android.util.SparseArray;
+import androidx.annotation.RequiresApi;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.GeckoSurface;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+// Proxy class of ICodec binder.
+public final class CodecProxy {
+ private static final String LOGTAG = "GeckoRemoteCodecProxy";
+ private static final boolean DEBUG = false;
+ @WrapForJNI private static final long INVALID_SESSION = -1;
+
+ private ICodec mRemote;
+ private long mSession;
+ private boolean mIsEncoder;
+ private FormatParam mFormat;
+ private GeckoSurface mOutputSurface;
+ private CallbacksForwarder mCallbacks;
+ private String mRemoteDrmStubId;
+ private Queue<Sample> mSurfaceOutputs = new ConcurrentLinkedQueue<>();
+ private boolean mFlushed = true;
+
+ private SparseArray<SampleBuffer> mInputBuffers = new SparseArray<>();
+ private SparseArray<SampleBuffer> mOutputBuffers = new SparseArray<>();
+
+ public interface Callbacks {
+ void onInputStatus(long timestamp, boolean processed);
+
+ void onOutputFormatChanged(MediaFormat format);
+
+ void onOutput(Sample output, SampleBuffer buffer);
+
+ void onError(boolean fatal);
+ }
+
+ @WrapForJNI
+ public static class NativeCallbacks extends JNIObject implements Callbacks {
+ public native void onInputStatus(long timestamp, boolean processed);
+
+ public native void onOutputFormatChanged(MediaFormat format);
+
+ public native void onOutput(Sample output, SampleBuffer buffer);
+
+ public native void onError(boolean fatal);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private class CallbacksForwarder extends ICodecCallbacks.Stub {
+ private final Callbacks mCallbacks;
+ private boolean mCodecProxyReleased;
+
+ CallbacksForwarder(final Callbacks callbacks) {
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public synchronized void onInputQueued(final long timestamp) throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onInputStatus(timestamp, true /* processed */);
+ }
+ }
+
+ @Override
+ public synchronized void onInputPending(final long timestamp) throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onInputStatus(timestamp, false /* processed */);
+ }
+ }
+
+ @Override
+ public synchronized void onOutputFormatChanged(final FormatParam format)
+ throws RemoteException {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onOutputFormatChanged(format.asFormat());
+ }
+ }
+
+ @Override
+ public synchronized void onOutput(final Sample sample) throws RemoteException {
+ if (mCodecProxyReleased) {
+ sample.dispose();
+ return;
+ }
+
+ final SampleBuffer buffer = CodecProxy.this.getOutputBuffer(sample.bufferId);
+ if (mOutputSurface != null) {
+ // Don't render to surface just yet. Callback will make that happen when it's time.
+ mSurfaceOutputs.offer(sample);
+ } else if (buffer == null) {
+ // Buffer with given ID has been flushed.
+ sample.dispose();
+ return;
+ }
+ mCallbacks.onOutput(sample, buffer);
+ }
+
+ @Override
+ public void onError(final boolean fatal) throws RemoteException {
+ reportError(fatal);
+ }
+
+ private synchronized void reportError(final boolean fatal) {
+ if (!mCodecProxyReleased) {
+ mCallbacks.onError(fatal);
+ }
+ }
+
+ private synchronized void setCodecProxyReleased() {
+ mCodecProxyReleased = true;
+ }
+ }
+
+ @WrapForJNI
+ public int GetInputFormatStride() {
+ if (mFormat.asFormat().containsKey(MediaFormat.KEY_STRIDE)) {
+ return mFormat.asFormat().getInteger(MediaFormat.KEY_STRIDE);
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public int GetInputFormatYPlaneHeight() {
+ if (mFormat.asFormat().containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ return mFormat.asFormat().getInteger(MediaFormat.KEY_SLICE_HEIGHT);
+ }
+ return 0;
+ }
+
+ @WrapForJNI
+ public static CodecProxy create(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ return RemoteManager.getInstance()
+ .createCodec(isEncoder, format, surface, callbacks, drmStubId);
+ }
+
+ public static CodecProxy createCodecProxy(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ return new CodecProxy(isEncoder, format, surface, callbacks, drmStubId);
+ }
+
+ private CodecProxy(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final Callbacks callbacks,
+ final String drmStubId) {
+ mIsEncoder = isEncoder;
+ mFormat = new FormatParam(format);
+ mOutputSurface = surface;
+ mRemoteDrmStubId = drmStubId;
+ mCallbacks = new CallbacksForwarder(callbacks);
+ }
+
+ boolean init(final ICodec remote) {
+ try {
+ remote.setCallbacks(mCallbacks);
+ if (!remote.configure(
+ mFormat,
+ mOutputSurface,
+ mIsEncoder ? MediaCodec.CONFIGURE_FLAG_ENCODE : 0,
+ mRemoteDrmStubId)) {
+ return false;
+ }
+ remote.start();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+
+ mRemote = remote;
+ return true;
+ }
+
+ boolean deinit() {
+ try {
+ mRemote.stop();
+ mRemote.release();
+ mRemote = null;
+ return true;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isAdaptivePlaybackSupported() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isAdaptivePlaybackSupported with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isAdaptivePlaybackSupported();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isHardwareAccelerated() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isHardwareAccelerated with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isHardwareAccelerated();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean isTunneledPlaybackSupported() {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot check isTunneledPlaybackSupported with an ended codec");
+ return false;
+ }
+ try {
+ return mRemote.isTunneledPlaybackSupported();
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized long input(
+ final ByteBuffer bytes, final BufferInfo info, final CryptoInfo cryptoInfo) {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot send input to an ended codec");
+ return INVALID_SESSION;
+ }
+
+ final boolean eos = info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM;
+
+ if (eos) {
+ return sendInput(Sample.EOS);
+ }
+
+ try {
+ final Sample s = mRemote.dequeueInput(info.size);
+ fillInputBuffer(s.bufferId, bytes, info.offset, info.size);
+ mSession = s.session;
+ return sendInput(s.set(info, cryptoInfo));
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "fail to dequeue input buffer", e);
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "fail to copy input data.", e);
+ // Balance dequeue/queue.
+ sendInput(null);
+ }
+ return INVALID_SESSION;
+ }
+
+ private void fillInputBuffer(
+ final int bufferId, final ByteBuffer bytes, final int offset, final int size)
+ throws RemoteException, IOException {
+ if (bytes == null || size == 0) {
+ Log.w(LOGTAG, "empty input");
+ return;
+ }
+
+ SampleBuffer buffer = mInputBuffers.get(bufferId);
+ if (buffer == null) {
+ buffer = mRemote.getInputBuffer(bufferId);
+ if (buffer != null) {
+ mInputBuffers.put(bufferId, buffer);
+ }
+ }
+
+ if (buffer.capacity() < size) {
+ final IOException e =
+ new IOException("data larger than capacity: " + size + " > " + buffer.capacity());
+ Log.e(LOGTAG, "cannot fill input.", e);
+ throw e;
+ }
+
+ buffer.readFromByteBuffer(bytes, offset, size);
+ }
+
+ private long sendInput(final Sample sample) {
+ try {
+ mRemote.queueInput(sample);
+ if (sample != null) {
+ sample.dispose();
+ mFlushed = false;
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "fail to queue input:" + sample, e);
+ return INVALID_SESSION;
+ }
+ return mSession;
+ }
+
+ @WrapForJNI
+ public synchronized boolean flush() {
+ if (mFlushed) {
+ return true;
+ }
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot flush an ended codec");
+ return false;
+ }
+ try {
+ if (DEBUG) {
+ Log.d(LOGTAG, "flush " + this);
+ }
+ resetBuffers();
+ mRemote.flush();
+ mFlushed = true;
+ } catch (final DeadObjectException e) {
+ return false;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+
+ private void resetBuffers() {
+ for (int i = 0; i < mInputBuffers.size(); ++i) {
+ mInputBuffers.valueAt(i).dispose();
+ }
+ mInputBuffers.clear();
+ for (int i = 0; i < mOutputBuffers.size(); ++i) {
+ mOutputBuffers.valueAt(i).dispose();
+ }
+ mOutputBuffers.clear();
+ }
+
+ @WrapForJNI
+ public boolean release() {
+ mCallbacks.setCodecProxyReleased();
+ synchronized (this) {
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ return true;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "release " + this);
+ }
+
+ if (!mSurfaceOutputs.isEmpty()) {
+ // Flushing output buffers to surface may cause some frames to be skipped and
+ // should not happen unless caller release codec before processing all buffers.
+ Log.w(LOGTAG, "release codec when " + mSurfaceOutputs.size() + " output buffers unhandled");
+ try {
+ for (final Sample s : mSurfaceOutputs) {
+ mRemote.releaseOutput(s, true);
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ mSurfaceOutputs.clear();
+ }
+
+ resetBuffers();
+
+ try {
+ RemoteManager.getInstance().releaseCodec(this);
+ } catch (final DeadObjectException e) {
+ return false;
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return false;
+ }
+ return true;
+ }
+ }
+
+ @WrapForJNI
+ public synchronized boolean setBitrate(final int bps) {
+ if (!mIsEncoder) {
+ Log.w(LOGTAG, "this api is encoder-only");
+ return false;
+ }
+
+ if (android.os.Build.VERSION.SDK_INT < 19) {
+ Log.w(LOGTAG, "this api was added in API level 19");
+ return false;
+ }
+
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ return true;
+ }
+
+ try {
+ mRemote.setBitrate(bps);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "remote fail to set rates:" + bps);
+ e.printStackTrace();
+ }
+ return true;
+ }
+
+ @WrapForJNI
+ public synchronized boolean releaseOutput(final Sample sample, final boolean render) {
+ if (mOutputSurface != null) {
+ if (!mSurfaceOutputs.remove(sample)) {
+ if (mRemote != null) Log.w(LOGTAG, "already released: " + sample);
+ return true;
+ }
+
+ if (DEBUG && !render) {
+ Log.d(LOGTAG, "drop output:" + sample.info.presentationTimeUs);
+ }
+ }
+
+ if (mRemote == null) {
+ Log.w(LOGTAG, "codec already ended");
+ sample.dispose();
+ return true;
+ }
+
+ try {
+ mRemote.releaseOutput(sample, render);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "remote fail to release output:" + sample.info.presentationTimeUs);
+ e.printStackTrace();
+ }
+ sample.dispose();
+
+ return true;
+ }
+
+ /* package */ void reportError(final boolean fatal) {
+ mCallbacks.reportError(fatal);
+ }
+
+ private synchronized SampleBuffer getOutputBuffer(final int id) {
+ if (mRemote == null) {
+ Log.e(LOGTAG, "cannot get buffer#" + id + " from an ended codec");
+ return null;
+ }
+
+ if (mOutputSurface != null || id == Sample.NO_BUFFER) {
+ return null;
+ }
+
+ SampleBuffer buffer = mOutputBuffers.get(id);
+ if (buffer != null) {
+ return buffer;
+ }
+
+ try {
+ buffer = mRemote.getOutputBuffer(id);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "cannot get buffer#" + id, e);
+ return null;
+ }
+ if (buffer != null) {
+ mOutputBuffers.put(id, buffer);
+ }
+
+ return buffer;
+ }
+
+ @WrapForJNI
+ public static boolean supportsCBCS() {
+ // Android N/API-24 supports CBCS but there seems to be a bug.
+ // See https://github.com/google/ExoPlayer/issues/4022
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.N_MR1)
+ @WrapForJNI
+ public static boolean setCryptoPatternIfNeeded(
+ final CryptoInfo info, final int blocksToEncrypt, final int blocksToSkip) {
+ if (supportsCBCS() && (blocksToEncrypt > 0 || blocksToSkip > 0)) {
+ info.setPattern(new CryptoInfo.Pattern(blocksToEncrypt, blocksToSkip));
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java
new file mode 100644
index 0000000000..03340530ee
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java
@@ -0,0 +1,178 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaFormat;
+import android.os.Bundle;
+import android.os.Parcel;
+import android.os.Parcelable;
+import java.nio.ByteBuffer;
+
+/**
+ * A wrapper to make {@link MediaFormat} parcelable. Supports following keys:
+ *
+ * <ul>
+ * <li>{@link MediaFormat#KEY_MIME}
+ * <li>{@link MediaFormat#KEY_WIDTH}
+ * <li>{@link MediaFormat#KEY_HEIGHT}
+ * <li>{@link MediaFormat#KEY_CHANNEL_COUNT}
+ * <li>{@link MediaFormat#KEY_SAMPLE_RATE}
+ * <li>{@link MediaFormat#KEY_BIT_RATE}
+ * <li>{@link MediaFormat#KEY_BITRATE_MODE}
+ * <li>{@link MediaFormat#KEY_COLOR_FORMAT}
+ * <li>{@link MediaFormat#KEY_FRAME_RATE}
+ * <li>{@link MediaFormat#KEY_I_FRAME_INTERVAL}
+ * <li>{@link MediaFormat#KEY_STRIDE}
+ * <li>{@link MediaFormat#KEY_SLICE_HEIGHT}
+ * <li>"csd-0"
+ * <li>"csd-1"
+ * </ul>
+ */
+public final class FormatParam implements Parcelable {
+ // Keys for codec specific config bits not exposed in {@link MediaFormat}.
+ private static final String KEY_CONFIG_0 = "csd-0";
+ private static final String KEY_CONFIG_1 = "csd-1";
+
+ private MediaFormat mFormat;
+
+ public MediaFormat asFormat() {
+ return mFormat;
+ }
+
+ public FormatParam(final MediaFormat format) {
+ mFormat = format;
+ }
+
+ protected FormatParam(final Parcel in) {
+ mFormat = new MediaFormat();
+ readFromParcel(in);
+ }
+
+ public static final Creator<FormatParam> CREATOR =
+ new Creator<FormatParam>() {
+ @Override
+ public FormatParam createFromParcel(final Parcel in) {
+ return new FormatParam(in);
+ }
+
+ @Override
+ public FormatParam[] newArray(final int size) {
+ return new FormatParam[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ public void readFromParcel(final Parcel in) {
+ final Bundle bundle = in.readBundle();
+ fromBundle(bundle);
+ }
+
+ private void fromBundle(final Bundle bundle) {
+ if (bundle.containsKey(MediaFormat.KEY_MIME)) {
+ mFormat.setString(MediaFormat.KEY_MIME, bundle.getString(MediaFormat.KEY_MIME));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_WIDTH)) {
+ mFormat.setInteger(MediaFormat.KEY_WIDTH, bundle.getInt(MediaFormat.KEY_WIDTH));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) {
+ mFormat.setInteger(MediaFormat.KEY_HEIGHT, bundle.getInt(MediaFormat.KEY_HEIGHT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ mFormat.setInteger(
+ MediaFormat.KEY_CHANNEL_COUNT, bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, bundle.getInt(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (bundle.containsKey(KEY_CONFIG_0)) {
+ mFormat.setByteBuffer(KEY_CONFIG_0, ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0)));
+ }
+ if (bundle.containsKey(KEY_CONFIG_1)) {
+ mFormat.setByteBuffer(KEY_CONFIG_1, ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1))));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_BIT_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_BIT_RATE, bundle.getInt(MediaFormat.KEY_BIT_RATE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_BITRATE_MODE)) {
+ mFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, bundle.getInt(MediaFormat.KEY_BITRATE_MODE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_COLOR_FORMAT)) {
+ mFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, bundle.getInt(MediaFormat.KEY_COLOR_FORMAT));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_FRAME_RATE)) {
+ mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, bundle.getInt(MediaFormat.KEY_FRAME_RATE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) {
+ mFormat.setInteger(
+ MediaFormat.KEY_I_FRAME_INTERVAL, bundle.getInt(MediaFormat.KEY_I_FRAME_INTERVAL));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_STRIDE)) {
+ mFormat.setInteger(MediaFormat.KEY_STRIDE, bundle.getInt(MediaFormat.KEY_STRIDE));
+ }
+ if (bundle.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ mFormat.setInteger(MediaFormat.KEY_SLICE_HEIGHT, bundle.getInt(MediaFormat.KEY_SLICE_HEIGHT));
+ }
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeBundle(toBundle());
+ }
+
+ private Bundle toBundle() {
+ final Bundle bundle = new Bundle();
+ if (mFormat.containsKey(MediaFormat.KEY_MIME)) {
+ bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) {
+ bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) {
+ bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
+ bundle.putInt(
+ MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) {
+ bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_0)) {
+ final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0);
+ bundle.putByteArray(KEY_CONFIG_0, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(KEY_CONFIG_1)) {
+ final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1);
+ bundle.putByteArray(KEY_CONFIG_1, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity()));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_BIT_RATE)) {
+ bundle.putInt(MediaFormat.KEY_BIT_RATE, mFormat.getInteger(MediaFormat.KEY_BIT_RATE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_BITRATE_MODE)) {
+ bundle.putInt(MediaFormat.KEY_BITRATE_MODE, mFormat.getInteger(MediaFormat.KEY_BITRATE_MODE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_COLOR_FORMAT)) {
+ bundle.putInt(MediaFormat.KEY_COLOR_FORMAT, mFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) {
+ bundle.putInt(MediaFormat.KEY_FRAME_RATE, mFormat.getInteger(MediaFormat.KEY_FRAME_RATE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) {
+ bundle.putInt(
+ MediaFormat.KEY_I_FRAME_INTERVAL, mFormat.getInteger(MediaFormat.KEY_I_FRAME_INTERVAL));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_STRIDE)) {
+ bundle.putInt(MediaFormat.KEY_STRIDE, mFormat.getInteger(MediaFormat.KEY_STRIDE));
+ }
+ if (mFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) {
+ bundle.putInt(MediaFormat.KEY_SLICE_HEIGHT, mFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT));
+ }
+ return bundle;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java
new file mode 100644
index 0000000000..6418375a57
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// A subset of the class AudioInfo in dom/media/MediaInfo.h
+@WrapForJNI
+public final class GeckoAudioInfo {
+ public final byte[] codecSpecificData;
+ public final int rate;
+ public final int channels;
+ public final int bitDepth;
+ public final int profile;
+ public final long duration;
+ public final String mimeType;
+
+ public GeckoAudioInfo(
+ final int rate,
+ final int channels,
+ final int bitDepth,
+ final int profile,
+ final long duration,
+ final String mimeType,
+ final byte[] codecSpecificData) {
+ this.rate = rate;
+ this.channels = channels;
+ this.bitDepth = bitDepth;
+ this.profile = profile;
+ this.duration = duration;
+ this.mimeType = mimeType;
+ this.codecSpecificData = codecSpecificData;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java
new file mode 100644
index 0000000000..cd732fe535
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java
@@ -0,0 +1,166 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+public final class GeckoHLSDemuxerWrapper {
+ private static final String LOGTAG = "GeckoHLSDemuxerWrapper";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+
+ // NOTE : These TRACK definitions should be synced with Gecko.
+ public enum TrackType {
+ UNDEFINED(0),
+ AUDIO(1),
+ VIDEO(2),
+ TEXT(3);
+ private int mType;
+
+ private TrackType(final int type) {
+ mType = type;
+ }
+
+ public int value() {
+ return mType;
+ }
+ }
+
+ private BaseHlsPlayer mPlayer = null;
+
+ public static class Callbacks extends JNIObject implements BaseHlsPlayer.DemuxerCallbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ Callbacks() {}
+
+ @Override
+ @WrapForJNI
+ public native void onInitialized(boolean hasAudio, boolean hasVideo);
+
+ @Override
+ @WrapForJNI
+ public native void onError(int errorCode);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // Callbacks
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ private BaseHlsPlayer.TrackType getPlayerTrackType(final int trackType) {
+ if (trackType == TrackType.AUDIO.value()) {
+ return BaseHlsPlayer.TrackType.AUDIO;
+ } else if (trackType == TrackType.VIDEO.value()) {
+ return BaseHlsPlayer.TrackType.VIDEO;
+ } else if (trackType == TrackType.TEXT.value()) {
+ return BaseHlsPlayer.TrackType.TEXT;
+ }
+ return BaseHlsPlayer.TrackType.UNDEFINED;
+ }
+
+ @WrapForJNI
+ public long getBuffered() {
+ assertTrue(mPlayer != null);
+ return mPlayer.getBufferedPosition();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static GeckoHLSDemuxerWrapper create(
+ final int id, final BaseHlsPlayer.DemuxerCallbacks callback) {
+ return new GeckoHLSDemuxerWrapper(id, callback);
+ }
+
+ @WrapForJNI
+ public int getNumberOfTracks(final int trackType) {
+ assertTrue(mPlayer != null);
+ final int tracks = mPlayer.getNumberOfTracks(getPlayerTrackType(trackType));
+ if (DEBUG) Log.d(LOGTAG, "[GetNumberOfTracks] type : " + trackType + ", num = " + tracks);
+ return tracks;
+ }
+
+ @WrapForJNI
+ public GeckoAudioInfo getAudioInfo(final int index) {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "[getAudioInfo] formatIndex : " + index);
+ final GeckoAudioInfo aInfo = mPlayer.getAudioInfo(index);
+ return aInfo;
+ }
+
+ @WrapForJNI
+ public GeckoVideoInfo getVideoInfo(final int index) {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index);
+ final GeckoVideoInfo vInfo = mPlayer.getVideoInfo(index);
+ return vInfo;
+ }
+
+ @WrapForJNI
+ public boolean seek(final long seekTime) {
+ // seekTime : microseconds.
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "seek : " + seekTime + " (Us)");
+ return mPlayer.seek(seekTime);
+ }
+
+ GeckoHLSDemuxerWrapper(final int id, final BaseHlsPlayer.DemuxerCallbacks callback) {
+ if (DEBUG) Log.d(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ...");
+ assertTrue(callback != null);
+ try {
+ mPlayer = GeckoPlayerFactory.getPlayer(id);
+ if (mPlayer != null) {
+ mPlayer.addDemuxerWrapperCallbackListener(callback);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ... error", e);
+ callback.onError(BaseHlsPlayer.DemuxerError.UNKNOWN.code());
+ }
+ }
+
+ @WrapForJNI
+ private GeckoHLSSample[] getSamples(final int mediaType, final int number) {
+ assertTrue(mPlayer != null);
+ ConcurrentLinkedQueue<GeckoHLSSample> samples = null;
+ // getA/VSamples will always return a non-null instance.
+ samples = mPlayer.getSamples(getPlayerTrackType(mediaType), number);
+ assertTrue(samples.size() <= number);
+ return samples.toArray(new GeckoHLSSample[samples.size()]);
+ }
+
+ @WrapForJNI
+ private long getNextKeyFrameTime() {
+ assertTrue(mPlayer != null);
+ return mPlayer.getNextKeyFrameTime();
+ }
+
+ @WrapForJNI
+ private boolean isLiveStream() {
+ assertTrue(mPlayer != null);
+ return mPlayer.isLiveStream();
+ }
+
+ @WrapForJNI // Called when native object is destroyed.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mPlayer != null) {
+ release();
+ }
+ }
+
+ private void release() {
+ assertTrue(mPlayer != null);
+ if (DEBUG) Log.d(LOGTAG, "release BaseHlsPlayer...");
+ GeckoPlayerFactory.removePlayer(mPlayer);
+ mPlayer.release();
+ mPlayer = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java
new file mode 100644
index 0000000000..c21789fdd0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java
@@ -0,0 +1,119 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+public class GeckoHLSResourceWrapper {
+ private static final String LOGTAG = "GeckoHLSResourceWrapper";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ private BaseHlsPlayer mPlayer = null;
+ private boolean mDestroy = false;
+
+ public static class Callbacks extends JNIObject implements BaseHlsPlayer.ResourceCallbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ Callbacks() {}
+
+ @Override
+ @WrapForJNI
+ public native void onLoad(String mediaUrl);
+
+ @Override
+ @WrapForJNI
+ public native void onDataArrived();
+
+ @Override
+ @WrapForJNI
+ public native void onError(int errorCode);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // Callbacks
+
+ private GeckoHLSResourceWrapper(
+ final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper created with url = " + url);
+ assertTrue(callback != null);
+
+ mPlayer = GeckoPlayerFactory.getPlayer();
+ try {
+ mPlayer.init(url, callback);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to create GeckoHlsResourceWrapper !", e);
+ callback.onError(BaseHlsPlayer.ResourceError.UNKNOWN.code());
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static GeckoHLSResourceWrapper create(
+ final String url, final BaseHlsPlayer.ResourceCallbacks callback) {
+ return new GeckoHLSResourceWrapper(url, callback);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public int getPlayerId() {
+ // GeckoHLSResourceWrapper should always be created before others
+ assertTrue(!mDestroy);
+ assertTrue(mPlayer != null);
+ return mPlayer.getId();
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void suspend() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper suspend");
+ if (mPlayer != null) {
+ mPlayer.suspend();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void resume() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper resume");
+ if (mPlayer != null) {
+ mPlayer.resume();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void play() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement played");
+ if (mPlayer != null) {
+ mPlayer.play();
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public void pause() {
+ if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement paused");
+ if (mPlayer != null) {
+ mPlayer.pause();
+ }
+ }
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ @WrapForJNI // Called when native object is mDestroy.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mDestroy) {
+ return;
+ }
+ mDestroy = true;
+ if (mPlayer != null) {
+ GeckoPlayerFactory.removePlayer(mPlayer);
+ mPlayer.release();
+ mPlayer = null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java
new file mode 100644
index 0000000000..d2ab76a13d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java
@@ -0,0 +1,93 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class GeckoHLSSample {
+ public static final GeckoHLSSample EOS;
+
+ static {
+ final BufferInfo eosInfo = new BufferInfo();
+ eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
+ EOS = new GeckoHLSSample(null, eosInfo, null, 0);
+ }
+
+ // Indicate the index of format which is used by this sample.
+ @WrapForJNI public final int formatIndex;
+
+ @WrapForJNI public long duration;
+
+ @WrapForJNI public final BufferInfo info;
+
+ @WrapForJNI public final CryptoInfo cryptoInfo;
+
+ private ByteBuffer mBuffer = null;
+
+ @WrapForJNI
+ public void writeToByteBuffer(final ByteBuffer dest) throws IOException {
+ if (mBuffer != null && dest != null && info.size > 0) {
+ dest.put(mBuffer);
+ }
+ }
+
+ @WrapForJNI
+ public boolean isEOS() {
+ return (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+ }
+
+ @WrapForJNI
+ public boolean isKeyFrame() {
+ return (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0;
+ }
+
+ public static GeckoHLSSample create(
+ final ByteBuffer src,
+ final BufferInfo info,
+ final CryptoInfo cryptoInfo,
+ final int formatIndex) {
+ return new GeckoHLSSample(src, info, cryptoInfo, formatIndex);
+ }
+
+ private GeckoHLSSample(
+ final ByteBuffer buffer,
+ final BufferInfo info,
+ final CryptoInfo cryptoInfo,
+ final int formatIndex) {
+ this.formatIndex = formatIndex;
+ duration = Long.MAX_VALUE;
+ this.mBuffer = buffer;
+ this.info = info;
+ this.cryptoInfo = cryptoInfo;
+ }
+
+ @Override
+ public String toString() {
+ if (isEOS()) {
+ return "EOS GeckoHLSSample";
+ }
+
+ final StringBuilder str = new StringBuilder();
+ str.append("{ info=")
+ .append("{ offset=")
+ .append(info.offset)
+ .append(", size=")
+ .append(info.size)
+ .append(", pts=")
+ .append(info.presentationTimeUs)
+ .append(", duration=")
+ .append(duration)
+ .append(", flags=")
+ .append(Integer.toHexString(info.flags))
+ .append(" }")
+ .append(" }");
+ return str.toString();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java
new file mode 100644
index 0000000000..a666e0e860
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java
@@ -0,0 +1,170 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Build;
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.List;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+public class GeckoHlsAudioRenderer extends GeckoHlsRendererBase {
+ public GeckoHlsAudioRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(C.TRACK_TYPE_AUDIO, eventDispatcher);
+ assertTrue(Build.VERSION.SDK_INT >= 16);
+ LOGTAG = getClass().getSimpleName();
+ DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ }
+
+ @Override
+ public final int supportsFormat(final Format format) {
+ /*
+ * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering
+ * formats with the same mime type, but
+ * the properties of the format exceed
+ * the renderer's capability.
+ * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose
+ * renderer for formats of the same
+ * top-level type, but is not capable of
+ * rendering the format or any other format
+ * with the same mime type because the
+ * sub-type is not supported.
+ * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering
+ * the format, either because it does not support
+ * the format's top-level type, or because it's
+ * a specialized renderer for a different mime type.
+ * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats,
+ * but may suffer a brief discontinuity (~50-100ms)
+ * when adaptation occurs.
+ */
+ final String mimeType = format.sampleMimeType;
+ if (!MimeTypes.isAudio(mimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+ List<MediaCodecInfo> decoderInfos = null;
+ try {
+ final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (decoderInfos == null || decoderInfos.isEmpty()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ }
+ final MediaCodecInfo info = decoderInfos.get(0);
+ /*
+ * Note : If the code can make it to this place, ExoPlayer assumes
+ * support for unknown sampleRate and channelCount when
+ * SDK version is less than 21, otherwise, further check is needed
+ * if there's no sampleRate/channelCount in format.
+ */
+ final boolean decoderCapable =
+ (Build.VERSION.SDK_INT < 21)
+ || ((format.sampleRate == Format.NO_VALUE
+ || info.isAudioSampleRateSupportedV21(format.sampleRate))
+ && (format.channelCount == Format.NO_VALUE
+ || info.isAudioChannelCountSupportedV21(format.channelCount)));
+ return RendererCapabilities.create(
+ decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES,
+ ADAPTIVE_NOT_SEAMLESS,
+ TUNNELING_NOT_SUPPORTED);
+ }
+
+ @Override
+ protected final void createInputBuffer() {
+ // We're not able to estimate the size for audio from format. So we rely
+ // on the dynamic allocation mechanism provided in DecoderInputBuffer.
+ mInputBuffer = null;
+ }
+
+ @Override
+ protected void resetRenderer() {
+ mInputBuffer = null;
+ mInitialized = false;
+ }
+
+ @Override
+ protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) {
+ // Do nothing
+ }
+
+ @Override
+ protected void handleFormatRead(final DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException {
+ onInputFormatChanged(mFormatHolder.format);
+ }
+
+ @Override
+ protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) {
+ mInputStreamEnded = true;
+ mDemuxedInputSamples.offer(GeckoHLSSample.EOS);
+ }
+
+ @Override
+ protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) {
+ final int size = bufferForRead.data.limit();
+ final byte[] realData = new byte[size];
+ bufferForRead.data.get(realData, 0, size);
+ final ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ final CryptoInfo cryptoInfo =
+ bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ final BufferInfo bufferInfo = new BufferInfo();
+ // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags.
+ int flags = 0;
+ flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
+ flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0;
+ bufferInfo.set(0, size, bufferForRead.timeUs, flags);
+
+ assertTrue(mFormats.size() >= 0);
+ // We add a new format in the list once format changes, so the formatIndex
+ // should indicate to the last(latest) format.
+ final GeckoHLSSample sample =
+ GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1);
+
+ mDemuxedInputSamples.offer(sample);
+
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(
+ LOGTAG,
+ "Demuxed sample PTS : "
+ + sample.info.presentationTimeUs
+ + ", duration :"
+ + sample.duration
+ + ", formatIndex("
+ + sample.formatIndex
+ + "), queue size : "
+ + mDemuxedInputSamples.size());
+ }
+ }
+
+ @Override
+ protected boolean clearInputSamplesQueue() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "clearInputSamplesQueue");
+ }
+ mDemuxedInputSamples.clear();
+ return true;
+ }
+
+ @Override
+ protected void notifyPlayerInputFormatChanged(final Format newFormat) {
+ mPlayerEventDispatcher.onAudioInputFormatChanged(newFormat);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java
new file mode 100644
index 0000000000..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<GeckoHLSSample> getSamples(
+ final TrackType trackType, final int number) {
+ if (trackType == TrackType.VIDEO) {
+ return mVRenderer != null
+ ? mVRenderer.getQueuedSamples(number)
+ : new ConcurrentLinkedQueue<GeckoHLSSample>();
+ } else if (trackType == TrackType.AUDIO) {
+ return mARenderer != null
+ ? mARenderer.getQueuedSamples(number)
+ : new ConcurrentLinkedQueue<GeckoHLSSample>();
+ } else {
+ return new ConcurrentLinkedQueue<GeckoHLSSample>();
+ }
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public long getBufferedPosition() {
+ return awaitPlayerThread(
+ () -> {
+ // Value returned by getBufferedPosition() is in milliseconds.
+ final long bufferedPos =
+ mPlayer == null ? 0L : Math.max(0L, mPlayer.getBufferedPosition() * 1000L);
+ if (DEBUG) {
+ Log.d(LOGTAG, "getBufferedPosition : " + bufferedPos + "(Us)");
+ }
+ return bufferedPos;
+ });
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public synchronized int getNumberOfTracks(final TrackType trackType) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getNumberOfTracks : type " + trackType);
+ }
+ if (trackType == TrackType.VIDEO) {
+ return mTracksInfo.getNumOfVideoTracks();
+ } else if (trackType == TrackType.AUDIO) {
+ return mTracksInfo.getNumOfAudioTracks();
+ }
+ return 0;
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public GeckoVideoInfo getVideoInfo(final int index) {
+ final Format fmt;
+ synchronized (this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getVideoInfo");
+ }
+ if (mVRenderer == null) {
+ Log.e(LOGTAG, "no render to get video info from. Index : " + index);
+ return null;
+ }
+ if (!mTracksInfo.hasVideo()) {
+ return null;
+ }
+ fmt = mVRenderer.getFormat(index);
+ if (fmt == null) {
+ return null;
+ }
+ }
+ final GeckoVideoInfo vInfo =
+ new GeckoVideoInfo(
+ fmt.width,
+ fmt.height,
+ fmt.width,
+ fmt.height,
+ fmt.rotationDegrees,
+ fmt.stereoMode,
+ getDuration(),
+ fmt.sampleMimeType,
+ null,
+ null);
+ return vInfo;
+ }
+
+ // Called on MFR's TaskQueue
+ @Override
+ public GeckoAudioInfo getAudioInfo(final int index) {
+ final Format fmt;
+ synchronized (this) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getAudioInfo");
+ }
+ if (mARenderer == null) {
+ Log.e(LOGTAG, "no render to get audio info from. Index : " + index);
+ return null;
+ }
+ if (!mTracksInfo.hasAudio()) {
+ return null;
+ }
+ fmt = mARenderer.getFormat(index);
+ if (fmt == null) {
+ return null;
+ }
+ }
+ /* According to https://github.com/google/ExoPlayer/blob
+ * /d979469659861f7fe1d39d153b90bdff1ab479cc/library/core/src/main
+ * /java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java#L221-L224,
+ * if the input audio format is not raw, exoplayer would assure that
+ * the sample's pcm encoding bitdepth is 16.
+ * For HLS content, it should always be 16.
+ */
+ assertTrue(!MimeTypes.AUDIO_RAW.equals(fmt.sampleMimeType));
+ // For HLS content, csd-0 is enough.
+ final byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0);
+ final GeckoAudioInfo aInfo =
+ new GeckoAudioInfo(
+ fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd);
+ return aInfo;
+ }
+
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public boolean seek(final long positionUs) {
+ synchronized (this) {
+ if (mPlayer == null) {
+ Log.d(LOGTAG, "Seek operation won't be performed as no player exists!");
+ return false;
+ }
+ }
+ return awaitPlayerThread(
+ () -> {
+ // Need to temporarily resume Exoplayer to download the chunks for getting the demuxed
+ // keyframe sample when HTMLMediaElement is paused. Suspend Exoplayer when collecting
+ // enough
+ // samples in onLoadingChanged.
+ if (mExoplayerSuspended) {
+ resumeExoplayer();
+ }
+ // positionUs : microseconds.
+ // NOTE : 1) It's not possible to seek media by tracktype via ExoPlayer Interface.
+ // 2) positionUs is samples PTS from MFR, we need to re-adjust it
+ // for ExoPlayer by subtracting sample start time.
+ // 3) Time unit for ExoPlayer.seek() is milliseconds.
+ try {
+ // TODO : Gather Timeline Period / Window information to develop
+ // complete timeline, and seekTime should be inside the duration.
+ Long startTime = Long.MAX_VALUE;
+ for (final GeckoHlsRendererBase r : mRenderers) {
+ if (r == mVRenderer
+ && mRendererController.isVideoRendererEnabled()
+ && mTracksInfo.hasVideo()
+ || r == mARenderer
+ && mRendererController.isAudioRendererEnabled()
+ && mTracksInfo.hasAudio()) {
+ // Find the min value of the start time
+ startTime = Math.min(startTime, r.getFirstSamplePTS());
+ }
+ }
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "seeking : "
+ + positionUs / 1000
+ + " (ms); startTime : "
+ + startTime / 1000
+ + " (ms)");
+ }
+ assertTrue(startTime != Long.MAX_VALUE && startTime != Long.MIN_VALUE);
+ mPlayer.seekTo(positionUs / 1000 - startTime / 1000);
+ } catch (final Exception e) {
+ if (mReleasing) {
+ return false;
+ }
+ if (mDemuxerCallbacks != null) {
+ mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code());
+ }
+ return false;
+ }
+ return true;
+ });
+ }
+
+ // Called on HLSDemuxer's TaskQueue
+ @Override
+ public synchronized long getNextKeyFrameTime() {
+ final long nextKeyFrameTime =
+ mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE;
+ return nextKeyFrameTime;
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void suspend() {
+ runOnPlayerThread(
+ () -> {
+ if (mExoplayerSuspended) {
+ return;
+ }
+ if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "suspend player id : " + mPlayerId);
+ }
+ suspendExoplayer();
+ }
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void resume() {
+ runOnPlayerThread(
+ () -> {
+ if (!mExoplayerSuspended) {
+ return;
+ }
+ if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "resume player id : " + mPlayerId);
+ }
+ resumeExoplayer();
+ }
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void play() {
+ runOnPlayerThread(
+ () -> {
+ if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "MediaDecoder played.");
+ }
+ mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PLAYING;
+ resumeExoplayer();
+ });
+ }
+
+ // Called on Gecko's main thread.
+ @Override
+ public synchronized void pause() {
+ runOnPlayerThread(
+ () -> {
+ if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "MediaDecoder paused.");
+ }
+ mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PAUSED;
+ suspendExoplayer();
+ });
+ }
+
+ private void suspendExoplayer() {
+ assertTrue(isPlayerThread());
+
+ if (mPlayer == null) {
+ return;
+ }
+ mExoplayerSuspended = true;
+ if (DEBUG) {
+ Log.d(LOGTAG, "suspend Exoplayer");
+ }
+ mPlayer.setPlayWhenReady(false);
+ }
+
+ private void resumeExoplayer() {
+ assertTrue(isPlayerThread());
+
+ if (mPlayer == null) {
+ return;
+ }
+ mExoplayerSuspended = false;
+ if (DEBUG) {
+ Log.d(LOGTAG, "resume Exoplayer");
+ }
+ mPlayer.setPlayWhenReady(true);
+ }
+
+ // Called on Gecko's main thread, when HLSDemuxer or HLSResource destructs.
+ @Override
+ public void release() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "releasing ... id : " + mPlayerId);
+ }
+
+ synchronized (this) {
+ if (mReleasing) {
+ return;
+ } else {
+ mReleasing = true;
+ }
+ }
+
+ runOnPlayerThread(
+ () -> {
+ if (mPlayer != null) {
+ mPlayer.removeListener(this);
+ mPlayer.stop();
+ mPlayer.release();
+ mVRenderer = null;
+ mARenderer = null;
+ mPlayer = null;
+ }
+ if (mThread != null) {
+ mThread.quit();
+ mThread = null;
+ }
+ mDemuxerCallbacks = null;
+ mResourceCallbacks = null;
+ mIsPlayerInitDone = false;
+ mIsDemuxerInitDone = false;
+ });
+ }
+
+ private void runOnPlayerThread(final Runnable task) {
+ assertTrue(mMainHandler != null);
+ if (isPlayerThread()) {
+ task.run();
+ } else {
+ mMainHandler.post(task);
+ }
+ }
+
+ private boolean isPlayerThread() {
+ return Thread.currentThread() == mMainHandler.getLooper().getThread();
+ }
+
+ private <T> T awaitPlayerThread(final Callable<T> task) {
+ assertTrue(!isPlayerThread());
+
+ try {
+ final FutureTask<T> wait = new FutureTask<T>(task);
+ mMainHandler.post(wait);
+ return wait.get();
+ } catch (final Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java
new file mode 100644
index 0000000000..ecb7b93d61
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java
@@ -0,0 +1,340 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.BaseRenderer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+
+public abstract class GeckoHlsRendererBase extends BaseRenderer {
+ protected static final int QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD = 1000000; // 1sec
+ protected final FormatHolder mFormatHolder = new FormatHolder();
+ /*
+ * DEBUG/LOGTAG will be set in the 2 subclass GeckoHlsAudioRenderer and
+ * GeckoHlsVideoRenderer, and we still wants to log message in the base class
+ * GeckoHlsRendererBase, so neither 'static' nor 'final' are applied to them.
+ */
+ protected boolean DEBUG;
+ protected String LOGTAG;
+ // Notify GeckoHlsPlayer about renderer's status, i.e. data has arrived.
+ protected GeckoHlsPlayer.ComponentEventDispatcher mPlayerEventDispatcher;
+
+ protected ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedInputSamples =
+ new ConcurrentLinkedQueue<>();
+
+ protected ByteBuffer mInputBuffer = null;
+ protected ArrayList<Format> mFormats = new ArrayList<Format>();
+ protected boolean mInitialized = false;
+ protected boolean mWaitingForData = true;
+ protected boolean mInputStreamEnded = false;
+ protected long mFirstSampleStartTime = Long.MIN_VALUE;
+
+ protected abstract void createInputBuffer() throws ExoPlaybackException;
+
+ protected abstract void handleReconfiguration(DecoderInputBuffer bufferForRead);
+
+ protected abstract void handleFormatRead(DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException;
+
+ protected abstract void handleEndOfStream(DecoderInputBuffer bufferForRead);
+
+ protected abstract void handleSamplePreparation(DecoderInputBuffer bufferForRead);
+
+ protected abstract void resetRenderer();
+
+ protected abstract boolean clearInputSamplesQueue();
+
+ protected abstract void notifyPlayerInputFormatChanged(Format newFormat);
+
+ private DecoderInputBuffer mBufferForRead =
+ new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
+ private final DecoderInputBuffer mFlagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance();
+
+ protected void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ public GeckoHlsRendererBase(
+ final int trackType, final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(trackType);
+ mPlayerEventDispatcher = eventDispatcher;
+ }
+
+ private boolean isQueuedEnoughData() {
+ if (mDemuxedInputSamples.isEmpty()) {
+ return false;
+ }
+
+ final Iterator<GeckoHLSSample> iter = mDemuxedInputSamples.iterator();
+ long firstPTS = 0;
+ if (iter.hasNext()) {
+ final GeckoHLSSample sample = iter.next();
+ firstPTS = sample.info.presentationTimeUs;
+ }
+ long lastPTS = firstPTS;
+ while (iter.hasNext()) {
+ final GeckoHLSSample sample = iter.next();
+ lastPTS = sample.info.presentationTimeUs;
+ }
+ return Math.abs(lastPTS - firstPTS) > QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD;
+ }
+
+ public Format getFormat(final int index) {
+ assertTrue(index >= 0);
+ final Format fmt = index < mFormats.size() ? mFormats.get(index) : null;
+ if (DEBUG) {
+ Log.d(LOGTAG, "getFormat : index = " + index + ", format : " + fmt);
+ }
+ return fmt;
+ }
+
+ public synchronized long getFirstSamplePTS() {
+ return mFirstSampleStartTime;
+ }
+
+ public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getQueuedSamples(final int number) {
+ final ConcurrentLinkedQueue<GeckoHLSSample> samples =
+ new ConcurrentLinkedQueue<GeckoHLSSample>();
+
+ GeckoHLSSample sample = null;
+ final int queuedSize = mDemuxedInputSamples.size();
+ for (int i = 0; i < queuedSize; i++) {
+ if (i >= number) {
+ break;
+ }
+ sample = mDemuxedInputSamples.poll();
+ samples.offer(sample);
+ }
+
+ sample = samples.isEmpty() ? null : samples.peek();
+ if (sample == null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "getQueuedSamples isEmpty, mWaitingForData = true !");
+ }
+ mWaitingForData = true;
+ } else if (mFirstSampleStartTime == Long.MIN_VALUE) {
+ mFirstSampleStartTime = sample.info.presentationTimeUs;
+ if (DEBUG) {
+ Log.d(LOGTAG, "mFirstSampleStartTime = " + mFirstSampleStartTime);
+ }
+ }
+ return samples;
+ }
+
+ protected void handleDrmInitChanged(final Format oldFormat, final Format newFormat) {
+ final Object oldDrmInit = oldFormat == null ? null : oldFormat.drmInitData;
+ final Object newDrnInit = newFormat.drmInitData;
+
+ // TODO: Notify MFR if the content is encrypted or not.
+ if (newDrnInit != oldDrmInit) {
+ if (newDrnInit != null) {
+ } else {
+ }
+ }
+ }
+
+ protected boolean canReconfigure(final Format oldFormat, final Format newFormat) {
+ // Referring to ExoPlayer's MediaCodecBaseRenderer, the default is set
+ // to false. Only override it in video renderer subclass.
+ return false;
+ }
+
+ protected void prepareReconfiguration() {
+ // Referring to ExoPlayer's MediaCodec related renderers, only video
+ // renderer handles this.
+ }
+
+ protected void updateCSDInfo(final Format format) {
+ // do nothing.
+ }
+
+ protected void onInputFormatChanged(final Format newFormat) throws ExoPlaybackException {
+ Format oldFormat;
+ try {
+ oldFormat = mFormats.get(mFormats.size() - 1);
+ } catch (final IndexOutOfBoundsException e) {
+ oldFormat = null;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onInputFormatChanged] old : " + oldFormat + " => new : " + newFormat);
+ }
+ mFormats.add(newFormat);
+ handleDrmInitChanged(oldFormat, newFormat);
+
+ if (mInitialized && canReconfigure(oldFormat, newFormat)) {
+ prepareReconfiguration();
+ } else {
+ resetRenderer();
+ maybeInitRenderer();
+ }
+
+ updateCSDInfo(newFormat);
+ notifyPlayerInputFormatChanged(newFormat);
+ }
+
+ protected void maybeInitRenderer() throws ExoPlaybackException {
+ if (mInitialized || mFormats.size() == 0) {
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "Initializing ... ");
+ }
+ try {
+ createInputBuffer();
+ mInitialized = true;
+ } catch (final OutOfMemoryError e) {
+ throw ExoPlaybackException.createForRenderer(
+ new RuntimeException(e),
+ getIndex(),
+ mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1),
+ RendererCapabilities.FORMAT_HANDLED);
+ }
+ }
+
+ /*
+ * The place we get demuxed data from HlsMediaSource(ExoPlayer).
+ * The data will then be converted to GeckoHLSSample and deliver to
+ * GeckoHlsDemuxerWrapper for further use.
+ * If the return value is ture, that means a GeckoHLSSample is queued
+ * successfully. We can try to feed more samples into queue.
+ * If the return value is false, that means we might encounter following
+ * situation 1) not initialized 2) input stream is ended 3) queue is full.
+ * 4) format changed. 5) exception happened.
+ */
+ protected synchronized boolean feedInputBuffersQueue() throws ExoPlaybackException {
+ if (!mInitialized || mInputStreamEnded || isQueuedEnoughData()) {
+ // Need to reinitialize the renderer or the input stream has ended
+ // or we just reached the maximum queue size.
+ return false;
+ }
+
+ mBufferForRead.data = mInputBuffer;
+ if (mBufferForRead.data != null) {
+ mBufferForRead.clear();
+ }
+
+ handleReconfiguration(mBufferForRead);
+
+ // Read data from HlsMediaSource
+ int result = C.RESULT_NOTHING_READ;
+ try {
+ result = readSource(mFormatHolder, mBufferForRead, false);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "[feedInput] Exception when readSource :", e);
+ return false;
+ }
+
+ if (result == C.RESULT_NOTHING_READ) {
+ return false;
+ }
+
+ if (result == C.RESULT_FORMAT_READ) {
+ handleFormatRead(mBufferForRead);
+ return true;
+ }
+
+ // We've read a buffer.
+ if (mBufferForRead.isEndOfStream()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Now we're at the End Of Stream.");
+ }
+ handleEndOfStream(mBufferForRead);
+ return false;
+ }
+
+ mBufferForRead.flip();
+
+ handleSamplePreparation(mBufferForRead);
+
+ maybeNotifyDataArrived();
+ return true;
+ }
+
+ private void maybeNotifyDataArrived() {
+ if (mWaitingForData && isQueuedEnoughData()) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onDataArrived");
+ }
+ mPlayerEventDispatcher.onDataArrived(getTrackType());
+ mWaitingForData = false;
+ }
+ }
+
+ private void readFormat() throws ExoPlaybackException {
+ mFlagsOnlyBuffer.clear();
+ final int result = readSource(mFormatHolder, mFlagsOnlyBuffer, true);
+ if (result == C.RESULT_FORMAT_READ) {
+ onInputFormatChanged(mFormatHolder.format);
+ }
+ }
+
+ @Override
+ protected void onEnabled(final boolean joining) {
+ // Do nothing.
+ }
+
+ @Override
+ protected void onDisabled() {
+ mFormats.clear();
+ resetRenderer();
+ }
+
+ @Override
+ public boolean isReady() {
+ return mFormats.size() != 0;
+ }
+
+ @Override
+ public boolean isEnded() {
+ return mInputStreamEnded;
+ }
+
+ @Override
+ protected synchronized void onPositionReset(final long positionUs, final boolean joining) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onPositionReset : positionUs = " + positionUs);
+ }
+ mInputStreamEnded = false;
+ if (mInitialized) {
+ clearInputSamplesQueue();
+ }
+ }
+
+ /*
+ * This is called by ExoPlayerImplInternal.java.
+ * ExoPlayer checks the status of renderer, i.e. isReady() / isEnded(), and
+ * calls renderer.render by passing its wall clock time.
+ */
+ @Override
+ public void render(final long positionUs, final long elapsedRealtimeUs)
+ throws ExoPlaybackException {
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(LOGTAG, "positionUs = " + positionUs + ", mInputStreamEnded = " + mInputStreamEnded);
+ }
+ if (mInputStreamEnded) {
+ return;
+ }
+ if (mFormats.size() == 0) {
+ readFormat();
+ }
+
+ maybeInitRenderer();
+ while (feedInputBuffersQueue()) {
+ // Do nothing
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java
new file mode 100644
index 0000000000..f2917ccbcc
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java
@@ -0,0 +1,518 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodec.BufferInfo;
+import android.media.MediaCodec.CryptoInfo;
+import android.os.Build;
+import android.util.Log;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.mozilla.geckoview.BuildConfig;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+public class GeckoHlsVideoRenderer extends GeckoHlsRendererBase {
+ /*
+ * By configuring these states, initialization data is provided for
+ * ExoPlayer's HlsMediaSource to parse HLS bitstream and then provide samples
+ * starting with an Access Unit Delimiter including SPS/PPS for TS,
+ * and provide samples starting with an AUD without SPS/PPS for FMP4.
+ */
+ private enum RECONFIGURATION_STATE {
+ NONE,
+ WRITE_PENDING,
+ QUEUE_PENDING
+ }
+
+ private boolean mRendererReconfigured;
+ private RECONFIGURATION_STATE mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+
+ // A list of the formats which may be included in the bitstream.
+ private Format[] mStreamFormats;
+ // The max width/height/inputBufferSize for specific codec format.
+ private CodecMaxValues mCodecMaxValues;
+ // A temporary queue for samples whose duration is not calculated yet.
+ private ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedNoDurationSamples =
+ new ConcurrentLinkedQueue<>();
+
+ // Contain CSD-0(SPS)/CSD-1(PPS) information (in AnnexB format) for
+ // prepending each keyframe. When video format changes, this information
+ // changes accordingly.
+ private byte[] mCSDInfo = null;
+
+ public GeckoHlsVideoRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) {
+ super(C.TRACK_TYPE_VIDEO, eventDispatcher);
+ assertTrue(Build.VERSION.SDK_INT >= 16);
+ LOGTAG = getClass().getSimpleName();
+ DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ }
+
+ @Override
+ public final int supportsMixedMimeTypeAdaptation() {
+ return ADAPTIVE_NOT_SEAMLESS;
+ }
+
+ @Override
+ public final int supportsFormat(final Format format) {
+ /*
+ * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering
+ * formats with the same mime type, but
+ * the properties of the format exceed
+ * the renderer's capability.
+ * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose
+ * renderer for formats of the same
+ * top-level type, but is not capable of
+ * rendering the format or any other format
+ * with the same mime type because the
+ * sub-type is not supported.
+ * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering
+ * the format, either because it does not support
+ * the format's top-level type, or because it's
+ * a specialized renderer for a different mime type.
+ * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats,
+ * but may suffer a brief discontinuity (~50-100ms)
+ * when adaptation occurs.
+ * ADAPTIVE_SEAMLESS : The Renderer can seamlessly adapt between formats.
+ */
+ final String mimeType = format.sampleMimeType;
+ if (!MimeTypes.isVideo(mimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+
+ List<MediaCodecInfo> decoderInfos = null;
+ try {
+ final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT;
+ decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false);
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (decoderInfos == null || decoderInfos.isEmpty()) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ }
+
+ boolean decoderCapable = false;
+ MediaCodecInfo info = null;
+ for (final MediaCodecInfo i : decoderInfos) {
+ if (i.isCodecSupported(format)) {
+ decoderCapable = true;
+ info = i;
+ }
+ }
+ if (decoderCapable && format.width > 0 && format.height > 0) {
+ if (Build.VERSION.SDK_INT < 21) {
+ try {
+ decoderCapable =
+ format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize();
+ } catch (final MediaCodecUtil.DecoderQueryException e) {
+ Log.e(LOGTAG, e.getMessage());
+ }
+ if (!decoderCapable) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "Check [legacyFrameSize, " + format.width + "x" + format.height + "]");
+ }
+ }
+ } else {
+ decoderCapable =
+ info.isVideoSizeAndRateSupportedV21(format.width, format.height, format.frameRate);
+ }
+ }
+
+ return RendererCapabilities.create(
+ decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES,
+ info != null && info.adaptive ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS,
+ TUNNELING_NOT_SUPPORTED);
+ }
+
+ @Override
+ protected final void createInputBuffer() throws ExoPlaybackException {
+ assertTrue(mFormats.size() > 0);
+ // Calculate maximum size which might be used for target format.
+ final Format currentFormat = mFormats.get(mFormats.size() - 1);
+ mCodecMaxValues = getCodecMaxValues(currentFormat, mStreamFormats);
+ // Create a buffer with maximal size for reading source.
+ // Note : Though we are able to dynamically enlarge buffer size by
+ // creating DecoderInputBuffer with specific BufferReplacementMode, we
+ // still allocate a calculated max size buffer for it at first to reduce
+ // runtime overhead.
+ try {
+ mInputBuffer = ByteBuffer.wrap(new byte[mCodecMaxValues.inputSize]);
+ } catch (final OutOfMemoryError e) {
+ Log.e(LOGTAG, "cannot allocate input buffer of size " + mCodecMaxValues.inputSize, e);
+ throw ExoPlaybackException.createForRenderer(
+ new Exception(e),
+ getIndex(),
+ mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1),
+ RendererCapabilities.FORMAT_HANDLED);
+ }
+ }
+
+ @Override
+ protected void resetRenderer() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[resetRenderer] mInitialized = " + mInitialized);
+ }
+ if (mInitialized) {
+ mRendererReconfigured = false;
+ mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+ mInputBuffer = null;
+ mCSDInfo = null;
+ mInitialized = false;
+ }
+ }
+
+ @Override
+ protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) {
+ // For adaptive reconfiguration OMX decoders expect all reconfiguration
+ // data to be supplied at the start of the buffer that also contains
+ // the first frame in the new format.
+ assertTrue(mFormats.size() > 0);
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.WRITE_PENDING) {
+ if (bufferForRead.data == null) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][WRITE_PENDING] bufferForRead.data is not initialized.");
+ }
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][WRITE_PENDING] put initialization data");
+ }
+ final Format currentFormat = mFormats.get(mFormats.size() - 1);
+ for (int i = 0; i < currentFormat.initializationData.size(); i++) {
+ final byte[] data = currentFormat.initializationData.get(i);
+ bufferForRead.data.put(data);
+ }
+ mRendererReconfigurationState = RECONFIGURATION_STATE.QUEUE_PENDING;
+ }
+ }
+
+ @Override
+ protected void handleFormatRead(final DecoderInputBuffer bufferForRead)
+ throws ExoPlaybackException {
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] 2 formats in a row.");
+ }
+ // We received two formats in a row. Clear the current buffer of any reconfiguration data
+ // associated with the first format.
+ bufferForRead.clear();
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ onInputFormatChanged(mFormatHolder.format);
+ }
+
+ @Override
+ protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) {
+ if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] isEndOfStream.");
+ }
+ // We received a new format immediately before the end of the stream. We need to clear
+ // the corresponding reconfiguration data from the current buffer, but re-write it into
+ // a subsequent buffer if there are any (e.g. if the user seeks backwards).
+ bufferForRead.clear();
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ mInputStreamEnded = true;
+ final GeckoHLSSample sample = GeckoHLSSample.EOS;
+ calculatDuration(sample);
+ }
+
+ @Override
+ protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) {
+ final int csdInfoSize = mCSDInfo != null ? mCSDInfo.length : 0;
+ final int dataSize = bufferForRead.data.limit();
+ final int size = bufferForRead.isKeyFrame() ? csdInfoSize + dataSize : dataSize;
+ final byte[] realData = new byte[size];
+ if (bufferForRead.isKeyFrame()) {
+ // Prepend the CSD information to the sample if it's a key frame.
+ System.arraycopy(mCSDInfo, 0, realData, 0, csdInfoSize);
+ bufferForRead.data.get(realData, csdInfoSize, dataSize);
+ } else {
+ bufferForRead.data.get(realData, 0, dataSize);
+ }
+ final ByteBuffer buffer = ByteBuffer.wrap(realData);
+ mInputBuffer = bufferForRead.data;
+ mInputBuffer.clear();
+
+ final CryptoInfo cryptoInfo =
+ bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null;
+ final BufferInfo bufferInfo = new BufferInfo();
+ // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags.
+ int flags = 0;
+ flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0;
+ flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0;
+ bufferInfo.set(0, size, bufferForRead.timeUs, flags);
+
+ assertTrue(mFormats.size() > 0);
+ // We add a new format in the list once format changes, so the formatIndex
+ // should indicate to the last(latest) format.
+ final GeckoHLSSample sample =
+ GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1);
+
+ // There's no duration information from the ExoPlayer's sample, we need
+ // to calculate it.
+ calculatDuration(sample);
+ mRendererReconfigurationState = RECONFIGURATION_STATE.NONE;
+ }
+
+ @Override
+ protected void onPositionReset(final long positionUs, final boolean joining) {
+ super.onPositionReset(positionUs, joining);
+ if (mInitialized && mRendererReconfigured && mFormats.size() != 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onPositionReset] WRITE_PENDING");
+ }
+ // Any reconfiguration data that we put shortly before the reset
+ // may be invalid. We avoid this issue by sending reconfiguration
+ // data following every position reset.
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+ }
+
+ @Override
+ protected boolean clearInputSamplesQueue() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "clearInputSamplesQueue");
+ }
+ mDemuxedInputSamples.clear();
+ mDemuxedNoDurationSamples.clear();
+ return true;
+ }
+
+ @Override
+ protected boolean canReconfigure(final Format oldFormat, final Format newFormat) {
+ final boolean canReconfig =
+ areAdaptationCompatible(oldFormat, newFormat)
+ && newFormat.width <= mCodecMaxValues.width
+ && newFormat.height <= mCodecMaxValues.height
+ && newFormat.maxInputSize <= mCodecMaxValues.inputSize;
+ if (DEBUG) {
+ Log.d(LOGTAG, "[canReconfigure] : " + canReconfig);
+ }
+ return canReconfig;
+ }
+
+ @Override
+ protected void prepareReconfiguration() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "[onInputFormatChanged] starting reconfiguration !");
+ }
+ mRendererReconfigured = true;
+ mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING;
+ }
+
+ @Override
+ protected void updateCSDInfo(final Format format) {
+ int size = 0;
+ for (int i = 0; i < format.initializationData.size(); i++) {
+ size += format.initializationData.get(i).length;
+ }
+ int startPos = 0;
+ mCSDInfo = new byte[size];
+ for (int i = 0; i < format.initializationData.size(); i++) {
+ final byte[] data = format.initializationData.get(i);
+ System.arraycopy(data, 0, mCSDInfo, startPos, data.length);
+ startPos += data.length;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "mCSDInfo [" + Utils.bytesToHex(mCSDInfo) + "]");
+ }
+ }
+
+ @Override
+ protected void notifyPlayerInputFormatChanged(final Format newFormat) {
+ mPlayerEventDispatcher.onVideoInputFormatChanged(newFormat);
+ }
+
+ private void calculateSamplesWithin(final GeckoHLSSample[] samples, final int range) {
+ // Calculate the first 'range' elements.
+ for (int i = 0; i < range; i++) {
+ // Comparing among samples in the window.
+ for (int j = -2; j < 14; j++) {
+ if (i + j >= 0
+ && i + j < range
+ && samples[i + j].info.presentationTimeUs > samples[i].info.presentationTimeUs) {
+ samples[i].duration =
+ Math.min(
+ samples[i].duration,
+ samples[i + j].info.presentationTimeUs - samples[i].info.presentationTimeUs);
+ }
+ }
+ }
+ }
+
+ private void calculatDuration(final GeckoHLSSample inputSample) {
+ /*
+ * NOTE :
+ * Since we customized renderer as a demuxer. Here we're not able to
+ * obtain duration from the DecoderInputBuffer as there's no duration inside.
+ * So we calcualte it by referring to nearby samples' timestamp.
+ * A temporary queue |mDemuxedNoDurationSamples| is used to queue demuxed
+ * samples from HlsMediaSource which have no duration information at first.
+ * We're choosing 16 as the comparing window size, because it's commonly
+ * used as a GOP size.
+ * Considering there're 16 demuxed samples in the _no duration_ queue already,
+ * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|
+ * Once a new demuxed(No duration) sample X (17th) is put into the
+ * temporary queue,
+ * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|X|
+ * we are able to calculate the correct duration for sample 0 by finding
+ * the closest but greater pts than sample 0 among these 16 samples,
+ * here, let's say sample -2 to 13.
+ */
+ if (inputSample != null) {
+ mDemuxedNoDurationSamples.offer(inputSample);
+ }
+ final int sizeOfNoDura = mDemuxedNoDurationSamples.size();
+ // A calculation window we've ever found suitable for both HLS TS & FMP4.
+ final int range = sizeOfNoDura >= 17 ? 17 : sizeOfNoDura;
+ final GeckoHLSSample[] inputArray =
+ mDemuxedNoDurationSamples.toArray(new GeckoHLSSample[sizeOfNoDura]);
+ if (range >= 17 && !mInputStreamEnded) {
+ calculateSamplesWithin(inputArray, range);
+
+ final GeckoHLSSample toQueue = mDemuxedNoDurationSamples.poll();
+ mDemuxedInputSamples.offer(toQueue);
+ if (BuildConfig.DEBUG_BUILD) {
+ Log.d(
+ LOGTAG,
+ "Demuxed sample PTS : "
+ + toQueue.info.presentationTimeUs
+ + ", duration :"
+ + toQueue.duration
+ + ", isKeyFrame("
+ + toQueue.isKeyFrame()
+ + ", formatIndex("
+ + toQueue.formatIndex
+ + "), queue size : "
+ + mDemuxedInputSamples.size()
+ + ", NoDuQueue size : "
+ + mDemuxedNoDurationSamples.size());
+ }
+ } else if (mInputStreamEnded) {
+ calculateSamplesWithin(inputArray, sizeOfNoDura);
+
+ // NOTE : We're not able to calculate the duration for the last sample.
+ // A workaround here is to assign a close duration to it.
+ long prevDuration = 33333;
+ GeckoHLSSample sample = null;
+ for (sample = mDemuxedNoDurationSamples.poll();
+ sample != null;
+ sample = mDemuxedNoDurationSamples.poll()) {
+ if (sample.duration == Long.MAX_VALUE) {
+ sample.duration = prevDuration;
+ if (DEBUG) {
+ Log.d(LOGTAG, "Adjust the PTS of the last sample to " + sample.duration + " (us)");
+ }
+ }
+ prevDuration = sample.duration;
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "last loop to offer samples - PTS : "
+ + sample.info.presentationTimeUs
+ + ", Duration : "
+ + sample.duration
+ + ", isEOS : "
+ + sample.isEOS());
+ }
+ mDemuxedInputSamples.offer(sample);
+ }
+ }
+ }
+
+ // Return the time of first keyframe sample in the queue.
+ // If there's no key frame in the queue, return the MAX_VALUE so
+ // MFR won't mistake for that which the decode is getting slow.
+ public long getNextKeyFrameTime() {
+ long nextKeyFrameTime = Long.MAX_VALUE;
+ for (final GeckoHLSSample sample : mDemuxedInputSamples) {
+ if (sample != null && (sample.info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ nextKeyFrameTime = sample.info.presentationTimeUs;
+ break;
+ }
+ }
+ return nextKeyFrameTime;
+ }
+
+ @Override
+ protected void onStreamChanged(final Format[] formats, final long offsetUs) {
+ mStreamFormats = formats;
+ }
+
+ private static CodecMaxValues getCodecMaxValues(
+ final Format format, final Format[] streamFormats) {
+ int maxWidth = format.width;
+ int maxHeight = format.height;
+ int maxInputSize = getMaxInputSize(format);
+ for (final Format streamFormat : streamFormats) {
+ if (areAdaptationCompatible(format, streamFormat)) {
+ maxWidth = Math.max(maxWidth, streamFormat.width);
+ maxHeight = Math.max(maxHeight, streamFormat.height);
+ maxInputSize = Math.max(maxInputSize, getMaxInputSize(streamFormat));
+ }
+ }
+ return new CodecMaxValues(maxWidth, maxHeight, maxInputSize);
+ }
+
+ private static int getMaxInputSize(final Format format) {
+ if (format.maxInputSize != Format.NO_VALUE) {
+ // The format defines an explicit maximum input size.
+ return format.maxInputSize;
+ }
+
+ if (format.width == Format.NO_VALUE || format.height == Format.NO_VALUE) {
+ // We can't infer a maximum input size without video dimensions.
+ return Format.NO_VALUE;
+ }
+
+ // Attempt to infer a maximum input size from the format.
+ final int maxPixels;
+ final int minCompressionRatio;
+ switch (format.sampleMimeType) {
+ case MimeTypes.VIDEO_H264:
+ // Round up width/height to an integer number of macroblocks.
+ maxPixels = ((format.width + 15) / 16) * ((format.height + 15) / 16) * 16 * 16;
+ minCompressionRatio = 2;
+ break;
+ default:
+ // Leave the default max input size.
+ return Format.NO_VALUE;
+ }
+ // Estimate the maximum input size assuming three channel 4:2:0 subsampled input frames.
+ return (maxPixels * 3) / (2 * minCompressionRatio);
+ }
+
+ private static boolean areAdaptationCompatible(final Format first, final Format second) {
+ return first.sampleMimeType.equals(second.sampleMimeType)
+ && getRotationDegrees(first) == getRotationDegrees(second);
+ }
+
+ private static int getRotationDegrees(final Format format) {
+ return format.rotationDegrees == Format.NO_VALUE ? 0 : format.rotationDegrees;
+ }
+
+ private static final class CodecMaxValues {
+ public final int width;
+ public final int height;
+ public final int inputSize;
+
+ public CodecMaxValues(final int width, final int height, final int inputSize) {
+ this.width = width;
+ this.height = height;
+ this.inputSize = inputSize;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java
new file mode 100644
index 0000000000..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<ByteBuffer> mSessionIds;
+ private HashMap<ByteBuffer, String> mSessionMIMETypes;
+ private ArrayDeque<PendingCreateSessionData> mPendingCreateSessionDataQueue;
+ private PendingKeyRequest mPendingKeyRequest;
+ private GeckoMediaDrm.Callbacks mCallbacks;
+
+ private MediaCrypto mCrypto;
+ protected MediaDrm mDrm;
+
+ public static final int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/
+ public static final int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/
+ public static final int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/
+
+ // Store session data while provisioning
+ private static class PendingCreateSessionData {
+ public final int mToken;
+ public final int mPromiseId;
+ public final byte[] mInitData;
+ public final String mMimeType;
+
+ private PendingCreateSessionData(
+ final int token, final int promiseId, final byte[] initData, final String mimeType) {
+ mToken = token;
+ mPromiseId = promiseId;
+ mInitData = initData;
+ mMimeType = mimeType;
+ }
+ }
+
+ private static class PendingKeyRequest {
+ public final ByteBuffer mSession;
+ public final byte[] mData;
+ public final String mMimeType;
+
+ private PendingKeyRequest(final ByteBuffer session, final byte[] data, final String mimeType) {
+ mSession = session;
+ mData = data;
+ mMimeType = mimeType;
+ }
+ }
+
+ public boolean isSecureDecoderComonentRequired(final String mimeType) {
+ if (mCrypto != null) {
+ return mCrypto.requiresSecureDecoderComponent(mimeType);
+ }
+ return false;
+ }
+
+ private static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ @SuppressLint("WrongConstant")
+ private void configureVendorSpecificProperty() {
+ assertTrue(mDrm != null);
+ if (mDrm == null) {
+ return;
+ }
+ // Support L3 for now
+ mDrm.setPropertyString("securityLevel", "L3");
+ // Refer to chromium, set multi-session mode for Widevine.
+ if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) {
+ mDrm.setPropertyString("privacyMode", "enable");
+ mDrm.setPropertyString("sessionSharing", "enable");
+ }
+ }
+
+ GeckoMediaDrmBridgeV21(final String keySystem) throws Exception {
+ LOGTAG = getClass().getSimpleName();
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21 ctor");
+
+ mProvisioningPromiseId = 0;
+ mSessionIds = new HashSet<ByteBuffer>();
+ mSessionMIMETypes = new HashMap<ByteBuffer, String>();
+ mPendingCreateSessionDataQueue = new ArrayDeque<PendingCreateSessionData>();
+
+ mSchemeUUID = convertKeySystemToSchemeUUID(keySystem);
+ mCryptoSessionId = null;
+
+ if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString());
+
+ // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions
+ // threw by the following steps.
+ mDrm = new MediaDrm(mSchemeUUID);
+ configureVendorSpecificProperty();
+ mDrm.setOnEventListener(new MediaDrmListener());
+ try {
+ // ensureMediaCryptoCreated may cause NotProvisionedException for the first time use.
+ // Need to start provisioning with a dummy promise id.
+ ensureMediaCryptoCreated();
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage());
+ startProvisioning(MAX_PROMISE_ID);
+ }
+ }
+
+ @Override
+ public void setCallbacks(final GeckoMediaDrm.Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ }
+
+ @Override
+ public void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ if (mProvisioningPromiseId > 0 && mCrypto == null) {
+ if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !");
+ savePendingCreateSessionData(
+ createSessionToken, promiseId,
+ initData, initDataType);
+ return;
+ }
+
+ ByteBuffer sessionId = null;
+ try {
+ final boolean hasMediaCrypto = ensureMediaCryptoCreated();
+ if (!hasMediaCrypto) {
+ onRejectPromise(promiseId, "MediaCrypto intance is not created !");
+ return;
+ }
+
+ sessionId = openSession();
+ if (sessionId == null) {
+ onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !");
+ return;
+ }
+
+ final MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType);
+ if (request == null) {
+ mDrm.closeSession(sessionId.array());
+ onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !");
+ return;
+ }
+ onSessionCreated(createSessionToken, promiseId, sessionId.array(), request.getData());
+ onSessionMessage(sessionId.array(), LICENSE_REQUEST_INITIAL, request.getData());
+ mSessionMIMETypes.put(sessionId, initDataType);
+ mSessionIds.add(sessionId);
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ " StringID : " + new String(sessionId.array(), UTF_8) + " is put into mSessionIds ");
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage());
+ if (sessionId != null) {
+ // The promise of this createSession will be either resolved
+ // or rejected after provisioning.
+ mDrm.closeSession(sessionId.array());
+ }
+ savePendingCreateSessionData(
+ createSessionToken, promiseId,
+ initData, initDataType);
+ startProvisioning(promiseId);
+ }
+ }
+
+ @Override
+ public void updateSession(final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId);
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8));
+ if (!sessionExists(session)) {
+ onRejectPromise(promiseId, "Invalid session during updateSession.");
+ return;
+ }
+
+ try {
+ final byte[] keySetId = mDrm.provideKeyResponse(session.array(), response);
+ if (DEBUG) {
+ final HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array());
+ for (final String strKey : infoMap.keySet()) {
+ final String strValue = infoMap.get(strKey);
+ Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")");
+ }
+ }
+ HandleKeyStatusChangeByDummyKey(sessionId);
+ onSessionUpdated(promiseId, session.array());
+ return;
+ } catch (final NotProvisionedException | DeniedByServerException | IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:", e);
+ onSessionError(session.array(), "Got exception during updateSession.");
+ onRejectPromise(promiseId, "Got exception during updateSession.");
+ }
+ release();
+ return;
+ }
+
+ @Override
+ public void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ if (mDrm == null) {
+ onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!");
+ return;
+ }
+
+ final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8));
+ mSessionIds.remove(session);
+ mDrm.closeSession(session.array());
+ onSessionClosed(promiseId, session.array());
+ }
+
+ @Override
+ public void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ if (mProvisionTask != null) {
+ mProvisionTask.cancel(true);
+ mProvisionTask = null;
+ }
+ if (mProvisioningPromiseId > 0) {
+ onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session.");
+ mProvisioningPromiseId = 0;
+ }
+ if (mPendingKeyRequest != null) {
+ mPendingKeyRequest = null;
+ }
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (pendingData != null) {
+ onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions.");
+ }
+ }
+ mPendingCreateSessionDataQueue = null;
+
+ if (mDrm != null) {
+ for (final ByteBuffer session : mSessionIds) {
+ mDrm.closeSession(session.array());
+ }
+ mDrm.release();
+ mDrm = null;
+ }
+ mSessionIds.clear();
+ mSessionIds = null;
+ mSessionMIMETypes.clear();
+ mSessionMIMETypes = null;
+
+ mCryptoSessionId = null;
+ if (mCrypto != null) {
+ mCrypto.release();
+ mCrypto = null;
+ }
+ if (mHandlerThread != null) {
+ mHandlerThread.quitSafely();
+ mHandlerThread = null;
+ }
+ mHandler = null;
+ }
+
+ @Override
+ public MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+ return mCrypto;
+ }
+
+ @SuppressLint("WrongConstant")
+ @Override
+ public void setServerCertificate(final byte[] cert) {
+ if (DEBUG) Log.d(LOGTAG, "setServerCertificate()");
+ if (mDrm == null) {
+ throw new IllegalStateException("MediaDrm instance doesn't exist !!");
+ }
+ mDrm.setPropertyByteArray("serviceCertificate", cert);
+ return;
+ }
+
+ protected void HandleKeyStatusChangeByDummyKey(final String sessionId) {
+ final SessionKeyInfo[] keyInfos = new SessionKeyInfo[1];
+ keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID, MediaDrm.KeyStatus.STATUS_USABLE);
+ onSessionBatchedKeyChanged(sessionId.getBytes(), keyInfos);
+ if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId);
+ }
+
+ protected void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+ }
+
+ protected void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+ }
+
+ protected void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+ }
+
+ protected void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+ }
+
+ protected void onSessionError(final byte[] sessionId, final String message) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionError(sessionId, message);
+ }
+ }
+
+ protected void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+
+ protected void onRejectPromise(final int promiseId, final String message) {
+ assertTrue(mCallbacks != null);
+ if (mCallbacks != null) {
+ mCallbacks.onRejectPromise(promiseId, message);
+ }
+ }
+
+ private MediaDrm.KeyRequest getKeyRequest(
+ final ByteBuffer aSession, final byte[] data, final String mimeType)
+ throws android.media.NotProvisionedException {
+ if (mProvisioningPromiseId > 0) {
+ if (DEBUG) Log.d(LOGTAG, "Now provisioning");
+ return null;
+ }
+
+ try {
+ final HashMap<String, String> optionalParameters = new HashMap<String, String>();
+ return mDrm.getKeyRequest(
+ aSession.array(), data, mimeType, MediaDrm.KEY_TYPE_STREAMING, optionalParameters);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e);
+ }
+ return null;
+ }
+
+ private class MediaDrmListener implements MediaDrm.OnEventListener {
+ @Override
+ public void onEvent(
+ final MediaDrm mediaDrm,
+ final byte[] sessionArray,
+ final int event,
+ final int extra,
+ final byte[] data) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()");
+ if (sessionArray == null) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session.");
+ return;
+ }
+ final ByteBuffer session = ByteBuffer.wrap(sessionArray);
+ if (!sessionExists(session)) {
+ if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session.");
+ return;
+ }
+ // On L, these events are treated as exceptions and handled correspondingly.
+ // Leaving this code block for logging message.
+ switch (event) {
+ case MediaDrm.EVENT_PROVISION_REQUIRED:
+ if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED");
+ break;
+ case MediaDrm.EVENT_KEY_REQUIRED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_KEY_REQUIRED, sessionId=" + new String(session.array(), UTF_8));
+ final String mimeType = mSessionMIMETypes.get(session);
+ MediaDrm.KeyRequest request = null;
+ try {
+ request = getKeyRequest(session, data, mimeType);
+ } catch (final android.media.NotProvisionedException e) {
+ Log.w(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED, Device not provisioned.", e);
+ startProvisioning(MAX_PROMISE_ID);
+ mPendingKeyRequest = new PendingKeyRequest(session, data, mimeType);
+ return;
+ }
+ requestLicense(sessionArray, request);
+ break;
+ case MediaDrm.EVENT_KEY_EXPIRED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + new String(session.array(), UTF_8));
+ break;
+ case MediaDrm.EVENT_VENDOR_DEFINED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + new String(session.array(), UTF_8));
+ break;
+ case MediaDrm.EVENT_SESSION_RECLAIMED:
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaDrm.EVENT_SESSION_RECLAIMED, sessionId="
+ + new String(session.array(), UTF_8));
+ break;
+ default:
+ if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event);
+ return;
+ }
+ }
+ }
+
+ private ByteBuffer openSession() throws android.media.NotProvisionedException {
+ try {
+ final byte[] sessionId = mDrm.openSession();
+ // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in
+ // case the underlying byte[] is modified.
+ return ByteBuffer.wrap(sessionId.clone());
+ } catch (final android.media.NotProvisionedException e) {
+ // Throw NotProvisionedException so that we can startProvisioning().
+ throw e;
+ } catch (final java.lang.RuntimeException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage());
+ release();
+ return null;
+ } catch (final android.media.MediaDrmException e) {
+ // Other MediaDrmExceptions (e.g. ResourceBusyException) are not
+ // recoverable.
+ release();
+ return null;
+ }
+ }
+
+ protected boolean sessionExists(final ByteBuffer session) {
+ if (mCryptoSessionId == null) {
+ if (DEBUG)
+ Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created.");
+ return false;
+ }
+ if (session == null) {
+ if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !");
+ return false;
+ }
+ return !session.equals(mCryptoSessionId) && mSessionIds.contains(session);
+ }
+
+ private class PostRequestTask extends AsyncTask<Void, Void, Void> {
+ private static final String LOGTAG = "PostRequestTask";
+
+ private int mPromiseId;
+ private String mURL;
+ private byte[] mDrmRequest;
+ private byte[] mResponseBody;
+
+ PostRequestTask(final int promiseId, final String url, final byte[] drmRequest) {
+ this.mPromiseId = promiseId;
+ this.mURL = url;
+ this.mDrmRequest = drmRequest;
+ }
+
+ @Override
+ protected Void doInBackground(final Void... params) {
+ HttpURLConnection urlConnection = null;
+ BufferedReader in = null;
+ try {
+ final URI finalURI =
+ new URI(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8"));
+ urlConnection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(finalURI);
+ urlConnection.setRequestMethod("POST");
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURI.toString());
+
+ // Add data
+ urlConnection.setRequestProperty("Accept", "*/*");
+ urlConnection.setRequestProperty("User-Agent", getCDMUserAgent());
+ urlConnection.setRequestProperty("Content-Type", "application/json");
+
+ // Execute HTTP Post Request
+ urlConnection.connect();
+
+ final int responseCode = urlConnection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), UTF_8));
+ String inputLine;
+ final StringBuffer response = new StringBuffer();
+
+ while ((inputLine = in.readLine()) != null) {
+ response.append(inputLine);
+ }
+ in.close();
+ mResponseBody = String.valueOf(response).getBytes(UTF_8);
+ if (DEBUG) Log.d(LOGTAG, "Provisioning, response received.");
+ if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length);
+ } else {
+ Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode);
+ }
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Got exception during posting provisioning request ...", e);
+ } catch (final URISyntaxException e) {
+ Log.e(LOGTAG, "Got exception during creating uri ...", e);
+ } finally {
+ if (urlConnection != null) {
+ urlConnection.disconnect();
+ }
+ try {
+ if (in != null) {
+ in.close();
+ }
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Exception during closing in ...", e);
+ }
+ }
+ return null;
+ }
+
+ @Override
+ protected void onPostExecute(final Void v) {
+ onProvisionResponse(mPromiseId, mResponseBody);
+ }
+ }
+
+ private boolean provideProvisionResponse(final byte[] response) {
+ if (response == null || response.length == 0) {
+ if (DEBUG) Log.d(LOGTAG, "Invalid provision response.");
+ return false;
+ }
+
+ try {
+ mDrm.provideProvisionResponse(response);
+ return true;
+ } catch (final android.media.DeniedByServerException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ } catch (final java.lang.IllegalStateException e) {
+ if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage());
+ }
+ return false;
+ }
+
+ private void savePendingCreateSessionData(
+ final int token, final int promiseId, final byte[] initData, final String mime) {
+ if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId);
+ mPendingCreateSessionDataQueue.offer(
+ new PendingCreateSessionData(token, promiseId, initData, mime));
+ }
+
+ private void processPendingCreateSessionData() {
+ if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... ");
+
+ assertTrue(mProvisioningPromiseId == 0);
+ try {
+ while (!mPendingCreateSessionDataQueue.isEmpty()) {
+ final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll();
+ if (pendingData == null) {
+ return;
+ }
+ if (DEBUG)
+ Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId);
+
+ createSession(
+ pendingData.mToken,
+ pendingData.mPromiseId,
+ pendingData.mMimeType,
+ pendingData.mInitData);
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e);
+ }
+ }
+
+ private void resumePendingOperations() {
+ if (mHandlerThread == null) {
+ mHandlerThread = new HandlerThread("PendingSessionOpsThread");
+ mHandlerThread.start();
+ }
+ if (mHandler == null) {
+ mHandler = new Handler(mHandlerThread.getLooper());
+ }
+ mHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mPendingKeyRequest != null) {
+ MediaDrm.KeyRequest request = null;
+ try {
+ request =
+ getKeyRequest(
+ mPendingKeyRequest.mSession,
+ mPendingKeyRequest.mData,
+ mPendingKeyRequest.mMimeType);
+ } catch (final NotProvisionedException e) {
+ Log.e(LOGTAG, "Cannot get key request after provisioning!");
+ return;
+ } finally {
+ mPendingKeyRequest = null;
+ }
+ requestLicense(mPendingKeyRequest.mSession.array(), request);
+ } else {
+ processPendingCreateSessionData();
+ }
+ }
+ });
+ }
+
+ private void requestLicense(final byte[] session, final MediaDrm.KeyRequest request) {
+ if (request == null) {
+ Log.e(LOGTAG, "null key request when requesting license");
+ return;
+ }
+ // The EME spec says the messageType is only for optimization and optional.
+ // Send 'License_request' as default when it's not available.
+ int requestType = LICENSE_REQUEST_INITIAL;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ requestType = request.getRequestType();
+ }
+ onSessionMessage(session, requestType, request.getData());
+ }
+
+ // Only triggered when failed on {openSession, getKeyRequest}
+ private void startProvisioning(final int promiseId) {
+ if (DEBUG) Log.d(LOGTAG, "startProvisioning()");
+ if (mProvisioningPromiseId > 0) {
+ // Already in provisioning.
+ return;
+ }
+ try {
+ mProvisioningPromiseId = promiseId;
+ final MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest();
+ mProvisionTask = new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData());
+ mProvisionTask.execute();
+ } catch (final Exception e) {
+ onRejectPromise(promiseId, "Exception happened in startProvisioning !");
+ mProvisioningPromiseId = 0;
+ }
+ }
+
+ private void onProvisionResponse(final int promiseId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()");
+ mProvisionTask = null;
+ mProvisioningPromiseId = 0;
+ final boolean success = provideProvisionResponse(response);
+ if (success) {
+ // Promise will either be resovled / rejected in createSession during
+ // resuming operations.
+ resumePendingOperations();
+ } else {
+ onRejectPromise(promiseId, "Failed to provide provision response.");
+ }
+ }
+
+ private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException {
+ if (mCrypto != null) {
+ return true;
+ }
+ try {
+ mCryptoSessionId = openSession();
+ if (mCryptoSessionId == null) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto");
+ return false;
+ }
+
+ if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) {
+ final byte[] cryptoSessionId = mCryptoSessionId.array();
+ mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId);
+ mSessionIds.add(mCryptoSessionId);
+ if (DEBUG)
+ Log.d(
+ LOGTAG,
+ "MediaCrypto successfully created! - SId "
+ + INVALID_SESSION_ID
+ + ", "
+ + new String(cryptoSessionId, UTF_8));
+ return true;
+ } else {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme.");
+ return false;
+ }
+ } catch (final android.media.MediaCryptoException e) {
+ if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage());
+ release();
+ return false;
+ } catch (final android.media.NotProvisionedException e) {
+ if (DEBUG)
+ Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage());
+ throw e;
+ }
+ }
+
+ private UUID convertKeySystemToSchemeUUID(final String keySystem) {
+ if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) {
+ return WIDEVINE_SCHEME_UUID;
+ }
+ if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem);
+ return new UUID(0L, 0L);
+ }
+
+ private String getCDMUserAgent() {
+ // This user agent is found and hard-coded in Android(L) source code and
+ // Chromium project. Not sure if it's gonna change in the future.
+ final String ua = "Widevine CDM v1.0";
+ return ua;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
new file mode 100644
index 0000000000..bee2635a81
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import static android.os.Build.VERSION_CODES.M;
+
+import android.annotation.TargetApi;
+import android.media.MediaDrm;
+import android.util.Log;
+import java.util.List;
+
+@TargetApi(M)
+public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 {
+ private static final boolean DEBUG = false;
+
+ GeckoMediaDrmBridgeV23(final String keySystem) throws Exception {
+ super(keySystem);
+ if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor");
+ mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null);
+ }
+
+ private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener {
+ @Override
+ public void onKeyStatusChange(
+ final MediaDrm mediaDrm,
+ final byte[] sessionId,
+ final List<MediaDrm.KeyStatus> keyInformation,
+ final boolean hasNewUsableKey) {
+ if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey);
+ if (keyInformation.size() == 0) {
+ return;
+ }
+ final SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()];
+ for (int i = 0; i < keyInformation.size(); i++) {
+ final MediaDrm.KeyStatus keyStatus = keyInformation.get(i);
+ keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(), keyStatus.getStatusCode());
+ }
+ onSessionBatchedKeyChanged(sessionId, keyInfos);
+ if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + new String(sessionId));
+ }
+ }
+
+ @Override
+ protected void HandleKeyStatusChangeByDummyKey(final String sessionId) {
+ // MediaDrm.KeyStatus information listener is supported on M+, there is no need to use
+ // dummy key id to report key status anymore.
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java
new file mode 100644
index 0000000000..47278115d3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import java.util.ArrayList;
+
+public final class GeckoPlayerFactory {
+ public static final ArrayList<BaseHlsPlayer> sPlayerList = new ArrayList<BaseHlsPlayer>();
+
+ static synchronized BaseHlsPlayer getPlayer() {
+ try {
+ final Class<?> cls = Class.forName("org.mozilla.gecko.media.GeckoHlsPlayer");
+ final BaseHlsPlayer player = (BaseHlsPlayer) cls.newInstance();
+ sPlayerList.add(player);
+ return player;
+ } catch (final Exception e) {
+ Log.e("GeckoPlayerFactory", "Class GeckoHlsPlayer not found or failed to create", e);
+ }
+ return null;
+ }
+
+ static synchronized BaseHlsPlayer getPlayer(final int id) {
+ for (final BaseHlsPlayer player : sPlayerList) {
+ if (player.getId() == id) {
+ return player;
+ }
+ }
+ Log.w("GeckoPlayerFactory", "No player found with id : " + id);
+ return null;
+ }
+
+ static synchronized void removePlayer(final @NonNull BaseHlsPlayer player) {
+ final int index = sPlayerList.indexOf(player);
+ if (index >= 0) {
+ sPlayerList.remove(player);
+ Log.d("GeckoPlayerFactory", "HlsPlayer with id(" + player.getId() + ") is removed.");
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java
new file mode 100644
index 0000000000..c641c58354
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java
@@ -0,0 +1,45 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+// A subset of the class VideoInfo in dom/media/MediaInfo.h
+@WrapForJNI
+public final class GeckoVideoInfo {
+ public final byte[] codecSpecificData;
+ public final byte[] extraData;
+ public final int displayWidth;
+ public final int displayHeight;
+ public final int pictureWidth;
+ public final int pictureHeight;
+ public final int rotation;
+ public final int stereoMode;
+ public final long duration;
+ public final String mimeType;
+
+ public GeckoVideoInfo(
+ final int displayWidth,
+ final int displayHeight,
+ final int pictureWidth,
+ final int pictureHeight,
+ final int rotation,
+ final int stereoMode,
+ final long duration,
+ final String mimeType,
+ final byte[] extraData,
+ final byte[] codecSpecificData) {
+ this.displayWidth = displayWidth;
+ this.displayHeight = displayHeight;
+ this.pictureWidth = pictureWidth;
+ this.pictureHeight = pictureHeight;
+ this.rotation = rotation;
+ this.stereoMode = stereoMode;
+ this.duration = duration;
+ this.mimeType = mimeType;
+ this.extraData = extraData;
+ this.codecSpecificData = codecSpecificData;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
new file mode 100644
index 0000000000..7c5102c63d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java
@@ -0,0 +1,490 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+import android.view.Surface;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+
+// Implement async API using MediaCodec sync mode (API v16).
+// This class uses internal worker thread/handler (mBufferPoller) to poll
+// input and output buffer and notifies the client through callbacks.
+final class JellyBeanAsyncCodec implements AsyncCodec {
+ private static final String LOGTAG = "GeckoAsyncCodecAPIv16";
+ private static final boolean DEBUG = false;
+
+ private static final int ERROR_CODEC = -10000;
+
+ private abstract class CancelableHandler extends Handler {
+ private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL'
+
+ protected CancelableHandler(final Looper looper) {
+ super(looper);
+ }
+
+ protected void cancel() {
+ removeCallbacksAndMessages(null);
+ sendEmptyMessage(MSG_CANCELLATION);
+ // Wait until handleMessageLocked() is done.
+ synchronized (this) {
+ }
+ }
+
+ protected boolean isCanceled() {
+ return hasMessages(MSG_CANCELLATION);
+ }
+
+ // Subclass should implement this and return true if it handles msg.
+ // Warning: Never, ever call super.handleMessage() in this method!
+ protected abstract boolean handleMessageLocked(Message msg);
+
+ public final void handleMessage(final Message msg) {
+ // Block cancel() during handleMessageLocked().
+ synchronized (this) {
+ if (isCanceled() || handleMessageLocked(msg)) {
+ return;
+ }
+ }
+
+ switch (msg.what) {
+ case MSG_CANCELLATION:
+ // Just a marker. Nothing to do here.
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this);
+ }
+ break;
+ default:
+ super.handleMessage(msg);
+ break;
+ }
+ }
+ }
+
+ // A handler to invoke AsyncCodec.Callbacks methods.
+ private final class CallbackSender extends CancelableHandler {
+ private static final int MSG_INPUT_BUFFER_AVAILABLE = 1;
+ private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2;
+ private static final int MSG_OUTPUT_FORMAT_CHANGE = 3;
+ private static final int MSG_ERROR = 4;
+ private Callbacks mCallbacks;
+
+ private CallbackSender(final Looper looper, final Callbacks callbacks) {
+ super(looper);
+ mCallbacks = callbacks;
+ }
+
+ public void notifyInputBuffer(final int index) {
+ if (isCanceled()) {
+ return;
+ }
+
+ final Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ private void processMessage(final Message msg) {
+ if (Looper.myLooper() == getLooper()) {
+ handleMessage(msg);
+ } else {
+ sendMessage(msg);
+ }
+ }
+
+ public void notifyOutputBuffer(final int index, final MediaCodec.BufferInfo info) {
+ if (isCanceled()) {
+ return;
+ }
+
+ final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info);
+ msg.arg1 = index;
+ processMessage(msg);
+ }
+
+ public void notifyOutputFormat(final MediaFormat format) {
+ if (isCanceled()) {
+ return;
+ }
+ processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format));
+ }
+
+ public void notifyError(final int result) {
+ Log.e(LOGTAG, "codec error:" + result);
+ processMessage(obtainMessage(MSG_ERROR, result, 0));
+ }
+
+ protected boolean handleMessageLocked(final Message msg) {
+ switch (msg.what) {
+ case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index.
+ mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this, msg.arg1);
+ break;
+ case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info.
+ mCallbacks.onOutputBufferAvailable(
+ JellyBeanAsyncCodec.this, msg.arg1, (MediaCodec.BufferInfo) msg.obj);
+ break;
+ case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format.
+ mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this, (MediaFormat) msg.obj);
+ break;
+ case MSG_ERROR: // arg1: error code.
+ mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1);
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ }
+ }
+
+ // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(),
+ // with 10ms time-out. Once triggered and successfully gets a buffer, it
+ // will schedule next polling until EOS or failure. To prevent it from
+ // automatically polling more buffer, use cancel() it inherits from
+ // CancelableHandler.
+ private final class BufferPoller extends CancelableHandler {
+ private static final int MSG_POLL_INPUT_BUFFERS = 1;
+ private static final int MSG_POLL_OUTPUT_BUFFERS = 2;
+
+ private static final long DEQUEUE_TIMEOUT_US = 10000;
+
+ public BufferPoller(final Looper looper) {
+ super(looper);
+ }
+
+ private void schedulePollingIfNotCanceled(final int what) {
+ if (isCanceled()) {
+ return;
+ }
+
+ schedulePolling(what);
+ }
+
+ private void schedulePolling(final int what) {
+ if (needsBuffer(what)) {
+ sendEmptyMessage(what);
+ }
+ }
+
+ private boolean needsBuffer(final int what) {
+ if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) {
+ return false;
+ }
+
+ if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected boolean handleMessageLocked(final Message msg) {
+ try {
+ switch (msg.what) {
+ case MSG_POLL_INPUT_BUFFERS:
+ pollInputBuffer();
+ break;
+ case MSG_POLL_OUTPUT_BUFFERS:
+ pollOutputBuffer();
+ break;
+ default:
+ return false;
+ }
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ }
+
+ return true;
+ }
+
+ private void pollInputBuffer() {
+ final int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ mCallbackSender.notifyInputBuffer(result);
+ } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ mBufferPoller.schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ } else {
+ mCallbackSender.notifyError(result);
+ }
+ }
+
+ private void pollOutputBuffer() {
+ boolean dequeueMoreBuffer = true;
+ final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
+ final int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US);
+ if (result >= 0) {
+ if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
+ mOutputEnded = true;
+ }
+ mCallbackSender.notifyOutputBuffer(result, info);
+ } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
+ mOutputBuffers = mCodec.getOutputBuffers();
+ } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
+ mOutputBuffers = mCodec.getOutputBuffers();
+ mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat());
+ } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) {
+ // When input ended, keep polling remaining output buffer until EOS.
+ dequeueMoreBuffer = mInputEnded;
+ } else {
+ mCallbackSender.notifyError(result);
+ dequeueMoreBuffer = false;
+ }
+
+ if (dequeueMoreBuffer) {
+ schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS);
+ }
+ }
+ }
+
+ private MediaCodec mCodec;
+ private ByteBuffer[] mInputBuffers;
+ private ByteBuffer[] mOutputBuffers;
+ private AsyncCodec.Callbacks mCallbacks;
+ private CallbackSender mCallbackSender;
+
+ private BufferPoller mBufferPoller;
+ private volatile boolean mInputEnded;
+ private volatile boolean mOutputEnded;
+
+ // Must be called on a thread with looper.
+ /* package */ JellyBeanAsyncCodec(final String name) throws IOException {
+ mCodec = MediaCodec.createByCodecName(name);
+ initBufferPoller(name + " buffer poller");
+ }
+
+ private void initBufferPoller(final String name) {
+ if (mBufferPoller != null) {
+ Log.e(LOGTAG, "poller already initialized");
+ return;
+ }
+ final HandlerThread thread = new HandlerThread(name);
+ thread.start();
+ mBufferPoller = new BufferPoller(thread.getLooper());
+ if (DEBUG) {
+ Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId());
+ }
+ }
+
+ @Override
+ public void setCallbacks(final AsyncCodec.Callbacks callbacks, final Handler handler) {
+ if (callbacks == null) {
+ return;
+ }
+
+ Looper looper = (handler == null) ? null : handler.getLooper();
+ if (looper == null) {
+ // Use this thread if no handler supplied.
+ looper = Looper.myLooper();
+ }
+ if (looper == null) {
+ // This thread has no looper. Use poller thread.
+ looper = mBufferPoller.getLooper();
+ }
+ mCallbackSender = new CallbackSender(looper, callbacks);
+ if (DEBUG) {
+ Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender);
+ }
+ }
+
+ @Override
+ public void configure(
+ final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) {
+ assertCallbacks();
+
+ mCodec.configure(format, surface, crypto, flags);
+ }
+
+ @Override
+ public boolean isAdaptivePlaybackSupported(final String mimeType) {
+ return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType);
+ }
+
+ @Override
+ public boolean isTunneledPlaybackSupported(final String mimeType) {
+ try {
+ return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP
+ && mCodec
+ .getCodecInfo()
+ .getCapabilitiesForType(mimeType)
+ .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
+ private void assertCallbacks() {
+ if (mCallbackSender == null) {
+ throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks().");
+ }
+ }
+
+ @Override
+ public void start() {
+ assertCallbacks();
+
+ mCodec.start();
+ mInputEnded = false;
+ mOutputEnded = false;
+ mInputBuffers = mCodec.getInputBuffers();
+ resumeReceivingInputs();
+ mOutputBuffers = mCodec.getOutputBuffers();
+ }
+
+ @Override
+ public void resumeReceivingInputs() {
+ for (int i = 0; i < mInputBuffers.length; i++) {
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+ }
+
+ @Override
+ public final void setBitrate(final int bps) {
+ if (android.os.Build.VERSION.SDK_INT >= 19) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps);
+ mCodec.setParameters(params);
+ }
+ }
+
+ @Override
+ public final void queueInputBuffer(
+ final int index,
+ final int offset,
+ final int size,
+ final long presentationTimeUs,
+ final int flags) {
+ assertCallbacks();
+
+ mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+
+ if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
+ && ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0)) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ mCodec.setParameters(params);
+ }
+
+ try {
+ mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ return;
+ }
+
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS);
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ }
+
+ @Override
+ public final void queueSecureInputBuffer(
+ final int index,
+ final int offset,
+ final MediaCodec.CryptoInfo cryptoInfo,
+ final long presentationTimeUs,
+ final int flags) {
+ assertCallbacks();
+
+ mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
+
+ try {
+ mCodec.queueSecureInputBuffer(index, offset, cryptoInfo, presentationTimeUs, flags);
+ } catch (final IllegalStateException e) {
+ e.printStackTrace();
+ mCallbackSender.notifyError(ERROR_CODEC);
+ return;
+ }
+
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS);
+ mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS);
+ }
+
+ @Override
+ public final void releaseOutputBuffer(final int index, final boolean render) {
+ assertCallbacks();
+
+ mCodec.releaseOutputBuffer(index, render);
+ }
+
+ @Override
+ public final ByteBuffer getInputBuffer(final int index) {
+ assertCallbacks();
+
+ return mInputBuffers[index];
+ }
+
+ @Override
+ public final ByteBuffer getOutputBuffer(final int index) {
+ assertCallbacks();
+
+ return mOutputBuffers[index];
+ }
+
+ @Override
+ public MediaFormat getInputFormat() {
+ return null;
+ }
+
+ @Override
+ public void flush() {
+ assertCallbacks();
+
+ mInputEnded = false;
+ mOutputEnded = false;
+ cancelPendingTasks();
+ mCodec.flush();
+ }
+
+ private void cancelPendingTasks() {
+ mBufferPoller.cancel();
+ mCallbackSender.cancel();
+ }
+
+ @Override
+ public void stop() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCodec.stop();
+ }
+
+ @Override
+ public void release() {
+ assertCallbacks();
+
+ cancelPendingTasks();
+ mCallbackSender = null;
+ mCodec.release();
+ stopBufferPoller();
+ }
+
+ private void stopBufferPoller() {
+ if (mBufferPoller == null) {
+ Log.e(LOGTAG, "no initialized poller.");
+ return;
+ }
+
+ mBufferPoller.getLooper().quit();
+ mBufferPoller = null;
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop poller " + this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java
new file mode 100644
index 0000000000..8afc96109d
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java
@@ -0,0 +1,250 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.TargetApi;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo.CodecCapabilities;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.view.Surface;
+import androidx.annotation.NonNull;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import org.mozilla.gecko.util.HardwareCodecCapabilityUtils;
+
+@TargetApi(Build.VERSION_CODES.LOLLIPOP)
+/* package */ final class LollipopAsyncCodec implements AsyncCodec {
+ private final MediaCodec mCodec;
+
+ private class CodecCallback extends MediaCodec.Callback {
+ private final Forwarder mForwarder;
+
+ private class Forwarder extends Handler {
+ private static final int MSG_INPUT_BUFFER_AVAILABLE = 1;
+ private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2;
+ private static final int MSG_OUTPUT_FORMAT_CHANGE = 3;
+ private static final int MSG_ERROR = 4;
+
+ private final Callbacks mTarget;
+
+ private Forwarder(final Looper looper, final Callbacks target) {
+ super(looper);
+ mTarget = target;
+ }
+
+ @Override
+ public void handleMessage(final Message msg) {
+ switch (msg.what) {
+ case MSG_INPUT_BUFFER_AVAILABLE:
+ mTarget.onInputBufferAvailable(LollipopAsyncCodec.this, msg.arg1); // index
+ break;
+ case MSG_OUTPUT_BUFFER_AVAILABLE:
+ mTarget.onOutputBufferAvailable(
+ LollipopAsyncCodec.this,
+ msg.arg1, // index
+ (MediaCodec.BufferInfo) msg.obj); // buffer info
+ break;
+ case MSG_OUTPUT_FORMAT_CHANGE:
+ mTarget.onOutputFormatChanged(
+ LollipopAsyncCodec.this, (MediaFormat) msg.obj); // output format
+ break;
+ case MSG_ERROR:
+ mTarget.onError(LollipopAsyncCodec.this, msg.arg1); // error code
+ break;
+ default:
+ super.handleMessage(msg);
+ }
+ }
+
+ private void onInput(final int index) {
+ notify(obtainMessage(MSG_INPUT_BUFFER_AVAILABLE, index, 0));
+ }
+
+ private void notify(final Message msg) {
+ if (Looper.myLooper() == getLooper()) {
+ handleMessage(msg);
+ } else {
+ sendMessage(msg);
+ }
+ }
+
+ private void onOutput(final int index, final MediaCodec.BufferInfo info) {
+ final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, index, 0, info);
+ notify(msg);
+ }
+
+ private void onOutputFormatChanged(final MediaFormat format) {
+ notify(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format));
+ }
+
+ private void onError(final MediaCodec.CodecException e) {
+ e.printStackTrace();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ notify(obtainMessage(MSG_ERROR, e.getErrorCode()));
+ } else {
+ notify(obtainMessage(MSG_ERROR, e.getLocalizedMessage()));
+ }
+ }
+ }
+
+ private CodecCallback(final Callbacks callbacks, final Handler handler) {
+ Looper looper = (handler == null) ? null : handler.getLooper();
+ if (looper == null) {
+ // Use this thread if no handler supplied.
+ looper = Looper.myLooper();
+ }
+ if (looper == null) {
+ // This thread has no looper. Use main thread.
+ looper = Looper.getMainLooper();
+ }
+
+ mForwarder = new Forwarder(looper, callbacks);
+ }
+
+ @Override
+ public void onInputBufferAvailable(@NonNull final MediaCodec codec, final int index) {
+ mForwarder.onInput(index);
+ }
+
+ @Override
+ public void onOutputBufferAvailable(
+ @NonNull final MediaCodec codec,
+ final int index,
+ @NonNull final MediaCodec.BufferInfo info) {
+ mForwarder.onOutput(index, info);
+ }
+
+ @Override
+ public void onOutputFormatChanged(
+ @NonNull final MediaCodec codec, @NonNull final MediaFormat format) {
+ mForwarder.onOutputFormatChanged(format);
+ }
+
+ @Override
+ public void onError(
+ @NonNull final MediaCodec codec, @NonNull final MediaCodec.CodecException e) {
+ mForwarder.onError(e);
+ }
+ }
+
+ /* package */ LollipopAsyncCodec(final String name) throws IOException {
+ mCodec = MediaCodec.createByCodecName(name);
+ }
+
+ @Override
+ public void setCallbacks(final Callbacks callbacks, final Handler handler) {
+ if (callbacks == null) {
+ return;
+ }
+
+ mCodec.setCallback(new CodecCallback(callbacks, handler));
+ }
+
+ @Override
+ public void configure(
+ final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) {
+ mCodec.configure(format, surface, crypto, flags);
+ }
+
+ @Override
+ public boolean isAdaptivePlaybackSupported(final String mimeType) {
+ return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType);
+ }
+
+ @Override
+ public boolean isTunneledPlaybackSupported(final String mimeType) {
+ try {
+ return mCodec
+ .getCodecInfo()
+ .getCapabilitiesForType(mimeType)
+ .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback);
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void start() {
+ mCodec.start();
+ }
+
+ @Override
+ public void stop() {
+ mCodec.stop();
+ }
+
+ @Override
+ public void flush() {
+ mCodec.flush();
+ }
+
+ @Override
+ public void resumeReceivingInputs() {
+ mCodec.start();
+ }
+
+ @Override
+ public void setBitrate(final int bps) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps);
+ mCodec.setParameters(params);
+ }
+
+ @Override
+ public void release() {
+ mCodec.release();
+ }
+
+ @Override
+ public ByteBuffer getInputBuffer(final int index) {
+ return mCodec.getInputBuffer(index);
+ }
+
+ @Override
+ public ByteBuffer getOutputBuffer(final int index) {
+ return mCodec.getOutputBuffer(index);
+ }
+
+ @Override
+ public MediaFormat getInputFormat() {
+ return mCodec.getInputFormat();
+ }
+
+ @Override
+ public void queueInputBuffer(
+ final int index,
+ final int offset,
+ final int size,
+ final long presentationTimeUs,
+ final int flags) {
+ if ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) {
+ final Bundle params = new Bundle();
+ params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0);
+ mCodec.setParameters(params);
+ }
+ mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ }
+
+ @Override
+ public void queueSecureInputBuffer(
+ final int index,
+ final int offset,
+ final MediaCodec.CryptoInfo info,
+ final long presentationTimeUs,
+ final int flags) {
+ mCodec.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags);
+ }
+
+ @Override
+ public void releaseOutputBuffer(final int index, final boolean render) {
+ mCodec.releaseOutputBuffer(index, render);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java
new file mode 100644
index 0000000000..7be8be6236
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java
@@ -0,0 +1,298 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.annotation.SuppressLint;
+import android.media.MediaCrypto;
+import android.media.MediaDrm;
+import android.os.Build;
+import android.util.Log;
+import java.util.ArrayList;
+import java.util.UUID;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+public final class MediaDrmProxy {
+ private static final String LOGTAG = "GeckoMediaDrmProxy";
+ private static final boolean DEBUG = false;
+ private static final UUID WIDEVINE_SCHEME_UUID =
+ new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL);
+
+ private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha";
+ @WrapForJNI private static final String AAC = "audio/mp4a-latm";
+ @WrapForJNI private static final String AVC = "video/avc";
+ @WrapForJNI private static final String VORBIS = "audio/vorbis";
+ @WrapForJNI private static final String VP8 = "video/x-vnd.on2.vp8";
+ @WrapForJNI private static final String VP9 = "video/x-vnd.on2.vp9";
+ @WrapForJNI private static final String OPUS = "audio/opus";
+ @WrapForJNI private static final String FLAC = "audio/flac";
+
+ public static final ArrayList<MediaDrmProxy> sProxyList = new ArrayList<MediaDrmProxy>();
+
+ // A flag to avoid using the native object that has been destroyed.
+ private boolean mDestroyed;
+ private GeckoMediaDrm mImpl;
+ private String mDrmStubId;
+
+ private static boolean isSystemSupported() {
+ // Support versions >= Marshmallow
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ if (DEBUG)
+ Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT);
+ return false;
+ }
+ return true;
+ }
+
+ @SuppressLint("NewApi")
+ @WrapForJNI
+ public static boolean isSchemeSupported(final String keySystem) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID)
+ && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID);
+ }
+ if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem);
+ return false;
+ }
+
+ @SuppressLint("NewApi")
+ @WrapForJNI
+ public static boolean IsCryptoSchemeSupported(final String keySystem, final String container) {
+ if (!isSystemSupported()) {
+ return false;
+ }
+ if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) {
+ return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container);
+ }
+ if (DEBUG)
+ Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container);
+ return false;
+ }
+
+ // Interface for callback to native.
+ public interface Callbacks {
+ void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request);
+
+ void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ void onSessionClosed(int promiseId, byte[] sessionId);
+
+ void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request);
+
+ void onSessionError(byte[] sessionId, String message);
+
+ // MediaDrm.KeyStatus is available in API level 23(M)
+ // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html
+ // For compatibility between L and M above, we'll unwrap the KeyStatus structure
+ // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy).
+ void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos);
+
+ void onRejectPromise(int promiseId, String message);
+ } // Callbacks
+
+ public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks {
+ @WrapForJNI(calledFrom = "gecko")
+ NativeMediaDrmProxyCallbacks() {}
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionCreated(
+ int createSessionToken, int promiseId, byte[] sessionId, byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionUpdated(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionClosed(int promiseId, byte[] sessionId);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionError(byte[] sessionId, String message);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos);
+
+ @Override
+ @WrapForJNI(dispatchTo = "gecko")
+ public native void onRejectPromise(int promiseId, String message);
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ throw new UnsupportedOperationException();
+ }
+ } // NativeMediaDrmProxyCallbacks
+
+ // A proxy to callback from LocalMediaDrmBridge to native instance.
+ public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks {
+ private final Callbacks mNativeCallbacks;
+ private final MediaDrmProxy mProxy;
+
+ public MediaDrmProxyCallbacks(final MediaDrmProxy proxy, final Callbacks callbacks) {
+ mNativeCallbacks = callbacks;
+ mProxy = proxy;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionError(sessionId, message);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ if (!mProxy.isDestroyed()) {
+ mNativeCallbacks.onRejectPromise(promiseId, message);
+ }
+ }
+ } // MediaDrmProxyCallbacks
+
+ public boolean isDestroyed() {
+ return mDestroyed;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ public static MediaDrmProxy create(final String keySystem, final Callbacks nativeCallbacks) {
+ final MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks);
+ return proxy;
+ }
+
+ MediaDrmProxy(final String keySystem, final Callbacks nativeCallbacks) {
+ if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy");
+ try {
+ mDrmStubId = UUID.randomUUID().toString();
+ final IMediaDrmBridge remoteBridge =
+ RemoteManager.getInstance().createRemoteMediaDrmBridge(keySystem, mDrmStubId);
+ mImpl = new RemoteMediaDrmBridge(remoteBridge);
+ mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks));
+ sProxyList.add(this);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Constructing MediaDrmProxy ... error", e);
+ }
+ }
+
+ @WrapForJNI
+ private void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId);
+ mImpl.createSession(createSessionToken, promiseId, initDataType, initData);
+ }
+
+ @WrapForJNI
+ private void updateSession(final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG)
+ Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.updateSession(promiseId, sessionId, response);
+ }
+
+ @WrapForJNI
+ private void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG)
+ Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")");
+ mImpl.closeSession(promiseId, sessionId);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private String getStubId() {
+ return mDrmStubId;
+ }
+
+ @WrapForJNI
+ public boolean setServerCertificate(final byte[] cert) {
+ try {
+ mImpl.setServerCertificate(cert);
+ return true;
+ } catch (final RuntimeException e) {
+ return false;
+ }
+ }
+
+ // Get corresponding MediaCrypto object by a generated UUID for MediaCodec.
+ // Will be called on MediaFormatReader's TaskQueue.
+ @WrapForJNI
+ public static MediaCrypto getMediaCrypto(final String stubId) {
+ for (final MediaDrmProxy proxy : sProxyList) {
+ if (proxy.getStubId().equals(stubId)) {
+ return proxy.getMediaCryptoFromBridge();
+ }
+ }
+ if (DEBUG) Log.d(LOGTAG, " NULL crytpo ");
+ return null;
+ }
+
+ @WrapForJNI // Called when natvie object is destroyed.
+ private void destroy() {
+ if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed.");
+ if (mDestroyed) {
+ return;
+ }
+ mDestroyed = true;
+ release();
+ }
+
+ private void release() {
+ if (DEBUG) Log.d(LOGTAG, "release");
+ sProxyList.remove(this);
+ mImpl.release();
+ }
+
+ private MediaCrypto getMediaCryptoFromBridge() {
+ return mImpl != null ? mImpl.getMediaCrypto() : null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java
new file mode 100644
index 0000000000..ef4fdc6932
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.IBinder;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.Log;
+import org.mozilla.gecko.mozglue.GeckoLoader;
+import org.mozilla.geckoview.BuildConfig;
+
+public final class MediaManager extends Service {
+ private static final String LOGTAG = "GeckoMediaManager";
+ private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL;
+ private static boolean sNativeLibLoaded;
+ private int mNumActiveRequests = 0;
+
+ private Binder mBinder =
+ new IMediaManager.Stub() {
+ @Override
+ public ICodec createCodec() throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "request codec. Current active requests:" + mNumActiveRequests);
+ mNumActiveRequests++;
+ return new Codec();
+ }
+
+ @Override
+ public IMediaDrmBridge createRemoteMediaDrmBridge(
+ final String keySystem, final String stubId) throws RemoteException {
+ if (DEBUG)
+ Log.d(LOGTAG, "request DRM bridge. Current active requests:" + mNumActiveRequests);
+ mNumActiveRequests++;
+ return new RemoteMediaDrmBridgeStub(keySystem, stubId);
+ }
+
+ @Override
+ public void endRequest() {
+ if (DEBUG) Log.d(LOGTAG, "end request. Current active requests:" + mNumActiveRequests);
+ if (mNumActiveRequests > 0) {
+ mNumActiveRequests--;
+ } else {
+ final RuntimeException e =
+ new RuntimeException("unmatched codec/DRM bridge creation and ending calls!");
+ Log.e(LOGTAG, "Error:", e);
+ }
+ }
+ };
+
+ @Override
+ public synchronized void onCreate() {
+ if (!sNativeLibLoaded) {
+ GeckoLoader.doLoadLibrary(this, "mozglue");
+ GeckoLoader.suppressCrashDialog();
+ sNativeLibLoaded = true;
+ }
+ }
+
+ @Override
+ public IBinder onBind(final Intent intent) {
+ return mBinder;
+ }
+
+ @Override
+ public boolean onUnbind(final Intent intent) {
+ Log.i(LOGTAG, "Media service has been unbound. Stopping.");
+ stopSelf();
+ if (mNumActiveRequests != 0) {
+ // Not unbound by RemoteManager -- caller process is dead.
+ Log.w(LOGTAG, "unbound while client still active.");
+ Process.killProcess(Process.myPid());
+ }
+ return false;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java
new file mode 100644
index 0000000000..62026f534f
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java
@@ -0,0 +1,254 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.media.MediaFormat;
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.NoSuchElementException;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.TelemetryUtils;
+import org.mozilla.gecko.gfx.GeckoSurface;
+
+public final class RemoteManager implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "GeckoRemoteManager";
+ private static final boolean DEBUG = false;
+ private static RemoteManager sRemoteManager = null;
+
+ public static synchronized RemoteManager getInstance() {
+ if (sRemoteManager == null) {
+ sRemoteManager = new RemoteManager();
+ }
+
+ sRemoteManager.init();
+ return sRemoteManager;
+ }
+
+ private List<CodecProxy> mCodecs = new LinkedList<CodecProxy>();
+ private List<IMediaDrmBridge> mDrmBridges = new LinkedList<IMediaDrmBridge>();
+
+ private volatile IMediaManager mRemote;
+
+ private final class RemoteConnection implements ServiceConnection {
+ @Override
+ public void onServiceConnected(final ComponentName name, final IBinder service) {
+ if (DEBUG) Log.d(LOGTAG, "service connected");
+ try {
+ service.linkToDeath(RemoteManager.this, 0);
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ }
+ synchronized (this) {
+ mRemote = IMediaManager.Stub.asInterface(service);
+ notify();
+ }
+ }
+
+ @Override
+ public void onServiceDisconnected(final ComponentName name) {
+ if (DEBUG) Log.d(LOGTAG, "service disconnected");
+ unlink();
+ }
+
+ private boolean connect() {
+ final Context appCtxt = GeckoAppShell.getApplicationContext();
+ appCtxt.bindService(
+ new Intent(appCtxt, MediaManager.class),
+ mConnection,
+ Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT);
+ waitConnect();
+ return mRemote != null;
+ }
+
+ // Wait up to 5s.
+ private synchronized void waitConnect() {
+ int waitCount = 0;
+ while (mRemote == null && waitCount < 5) {
+ try {
+ wait(1000);
+ waitCount++;
+ } catch (final InterruptedException e) {
+ if (DEBUG) {
+ e.printStackTrace();
+ }
+ }
+ }
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "wait ~" + waitCount + "s for connection: " + (mRemote == null ? "fail" : "ok"));
+ }
+ }
+
+ private synchronized void waitDisconnect() {
+ while (mRemote != null) {
+ try {
+ wait(1000);
+ } catch (final InterruptedException e) {
+ if (DEBUG) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ private synchronized void unlink() {
+ if (mRemote == null) {
+ return;
+ }
+ try {
+ mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0);
+ } catch (final NoSuchElementException e) {
+ Log.w(LOGTAG, "death recipient already released");
+ }
+ mRemote = null;
+ notify();
+ }
+ }
+
+ RemoteConnection mConnection = new RemoteConnection();
+
+ private synchronized boolean init() {
+ if (mRemote != null) {
+ return true;
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "init remote manager " + this);
+ return mConnection.connect();
+ }
+
+ public synchronized CodecProxy createCodec(
+ final boolean isEncoder,
+ final MediaFormat format,
+ final GeckoSurface surface,
+ final CodecProxy.Callbacks callbacks,
+ final String drmStubId) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize");
+ return null;
+ }
+ try {
+ final ICodec remote = mRemote.createCodec();
+ final CodecProxy proxy =
+ CodecProxy.createCodecProxy(isEncoder, format, surface, callbacks, drmStubId);
+ if (proxy.init(remote)) {
+ mCodecs.add(proxy);
+ return proxy;
+ } else {
+ return null;
+ }
+ } catch (final RemoteException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ public synchronized IMediaDrmBridge createRemoteMediaDrmBridge(
+ final String keySystem, final String stubId) {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize");
+ return null;
+ }
+ try {
+ final IMediaDrmBridge remoteBridge = mRemote.createRemoteMediaDrmBridge(keySystem, stubId);
+ mDrmBridges.add(remoteBridge);
+ return remoteBridge;
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e);
+ return null;
+ }
+ }
+
+ @Override
+ public void binderDied() {
+ Log.e(LOGTAG, "remote codec is dead");
+ TelemetryUtils.addToHistogram("MEDIA_DECODING_PROCESS_CRASH", 1);
+ handleRemoteDeath();
+ }
+
+ private synchronized void handleRemoteDeath() {
+ mConnection.waitDisconnect();
+
+ if (init() && recoverRemoteCodec()) {
+ notifyError(false);
+ } else {
+ notifyError(true);
+ }
+ }
+
+ private synchronized void notifyError(final boolean fatal) {
+ for (final CodecProxy proxy : mCodecs) {
+ proxy.reportError(fatal);
+ }
+ }
+
+ private synchronized boolean recoverRemoteCodec() {
+ if (DEBUG) Log.d(LOGTAG, "recover codec");
+ boolean ok = true;
+ try {
+ for (final CodecProxy proxy : mCodecs) {
+ ok &= proxy.init(mRemote.createCodec());
+ }
+ return ok;
+ } catch (final RemoteException e) {
+ return false;
+ }
+ }
+
+ public void releaseCodec(final CodecProxy proxy) throws DeadObjectException, RemoteException {
+ if (mRemote == null) {
+ if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet");
+ return;
+ }
+ proxy.deinit();
+ synchronized (this) {
+ if (mCodecs.remove(proxy)) {
+ try {
+ mRemote.endRequest();
+ releaseIfNeeded();
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "fail to report remote codec disconnection");
+ }
+ }
+ }
+ }
+
+ private void releaseIfNeeded() {
+ if (!mCodecs.isEmpty() || !mDrmBridges.isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) Log.d(LOGTAG, "release remote manager " + this);
+ mConnection.unlink();
+ final Context appCtxt = GeckoAppShell.getApplicationContext();
+ appCtxt.unbindService(mConnection);
+ }
+
+ public void onRemoteMediaDrmBridgeReleased(final IMediaDrmBridge remote) {
+ if (!mDrmBridges.contains(remote)) {
+ Log.e(LOGTAG, "Try to release unknown remote MediaDrm bridge: " + remote);
+ return;
+ }
+
+ synchronized (this) {
+ if (mDrmBridges.remove(remote)) {
+ try {
+ mRemote.endRequest();
+ releaseIfNeeded();
+ } catch (final RemoteException | NullPointerException e) {
+ Log.e(LOGTAG, "Fail to report remote DRM bridge disconnection");
+ }
+ }
+ }
+ }
+} // RemoteManager
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
new file mode 100644
index 0000000000..b90f720300
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+import android.util.Log;
+
+final class RemoteMediaDrmBridge implements GeckoMediaDrm {
+ private static final String LOGTAG = "RemoteMediaDrmBridge";
+ private static final boolean DEBUG = false;
+ private CallbacksForwarder mCallbacksFwd;
+ private IMediaDrmBridge mRemote;
+
+ // Forward callbacks from remote bridge stub to MediaDrmProxy.
+ private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub {
+ private final GeckoMediaDrm.Callbacks mProxyCallbacks;
+
+ CallbacksForwarder(final Callbacks callbacks) {
+ assertTrue(callbacks != null);
+ mProxyCallbacks = callbacks;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ mProxyCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ mProxyCallbacks.onSessionUpdated(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ mProxyCallbacks.onSessionClosed(promiseId, sessionId);
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ mProxyCallbacks.onSessionError(sessionId, message);
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ mProxyCallbacks.onRejectPromise(promiseId, message);
+ }
+ } // CallbacksForwarder
+
+ /* package-private */ static void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ public RemoteMediaDrmBridge(final IMediaDrmBridge remoteBridge) {
+ assertTrue(remoteBridge != null);
+ mRemote = remoteBridge;
+ }
+
+ @Override
+ public synchronized void setCallbacks(final Callbacks callbacks) {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(callbacks != null);
+ assertTrue(mRemote != null);
+
+ mCallbacksFwd = new CallbacksForwarder(callbacks);
+ try {
+ mRemote.setCallbacks(mCallbacksFwd);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception during setCallbacks", e);
+ }
+ }
+
+ @Override
+ public synchronized void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData) {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+
+ try {
+ mRemote.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while creating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(
+ final int promiseId, final String sessionId, final byte[] response) {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+
+ try {
+ mRemote.updateSession(promiseId, sessionId, response);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while updating remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(final int promiseId, final String sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+
+ try {
+ mRemote.closeSession(promiseId, sessionId);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while closing remote session.", e);
+ mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session.");
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+
+ try {
+ mRemote.release();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e);
+ }
+ RemoteManager.getInstance().onRemoteMediaDrmBridgeReleased(mRemote);
+ mRemote = null;
+ mCallbacksFwd = null;
+ }
+
+ @Override
+ public synchronized MediaCrypto getMediaCrypto() {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!");
+ assertTrue(false);
+ return null;
+ }
+
+ @Override
+ public synchronized void setServerCertificate(final byte[] cert) {
+ try {
+ mRemote.setServerCertificate(cert);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Got exception while setting server certificate.", e);
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
new file mode 100644
index 0000000000..f466529388
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java
@@ -0,0 +1,252 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCrypto;
+import android.os.Build;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import java.util.ArrayList;
+
+final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub
+ implements IBinder.DeathRecipient {
+ private static final String LOGTAG = "RemoteDrmBridgeStub";
+ private static final boolean DEBUG = false;
+ private volatile IMediaDrmBridgeCallbacks mCallbacks = null;
+
+ // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21.
+ private GeckoMediaDrm mBridge = null;
+
+ // mStubId is initialized during stub construction. It should be a unique
+ // string which is generated in MediaDrmProxy in Fennec App process and is
+ // used for Codec to obtain corresponding MediaCrypto as input to achieve
+ // decryption.
+ // The generated stubId will be delivered to Codec via a code path starting
+ // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec.
+ private String mStubId = "";
+
+ public static final ArrayList<RemoteMediaDrmBridgeStub> mBridgeStubs =
+ new ArrayList<RemoteMediaDrmBridgeStub>();
+
+ private String getId() {
+ return mStubId;
+ }
+
+ private MediaCrypto getMediaCryptoFromBridge() {
+ return mBridge != null ? mBridge.getMediaCrypto() : null;
+ }
+
+ public static synchronized MediaCrypto getMediaCrypto(final String stubId) {
+ if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()");
+
+ for (int i = 0; i < mBridgeStubs.size(); i++) {
+ if (mBridgeStubs.get(i) != null && mBridgeStubs.get(i).getId().equals(stubId)) {
+ return mBridgeStubs.get(i).getMediaCryptoFromBridge();
+ }
+ }
+ return null;
+ }
+
+ // Callback to RemoteMediaDrmBridge.
+ private final class Callbacks implements GeckoMediaDrm.Callbacks {
+ private IMediaDrmBridgeCallbacks mRemoteCallbacks;
+
+ public Callbacks(final IMediaDrmBridgeCallbacks remote) {
+ mRemoteCallbacks = remote;
+ }
+
+ @Override
+ public void onSessionCreated(
+ final int createSessionToken,
+ final int promiseId,
+ final byte[] sessionId,
+ final byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionCreated()");
+ try {
+ mRemoteCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionUpdated(final int promiseId, final byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()");
+ try {
+ mRemoteCallbacks.onSessionUpdated(promiseId, sessionId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionClosed(final int promiseId, final byte[] sessionId) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionClosed()");
+ try {
+ mRemoteCallbacks.onSessionClosed(promiseId, sessionId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionMessage(
+ final byte[] sessionId, final int sessionMessageType, final byte[] request) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionMessage()");
+ try {
+ mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionError(final byte[] sessionId, final String message) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionError()");
+ try {
+ mRemoteCallbacks.onSessionError(sessionId, message);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onSessionBatchedKeyChanged(
+ final byte[] sessionId, final SessionKeyInfo[] keyInfos) {
+ if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()");
+ try {
+ mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public void onRejectPromise(final int promiseId, final String message) {
+ if (DEBUG) Log.d(LOGTAG, "onRejectPromise()");
+ try {
+ mRemoteCallbacks.onRejectPromise(promiseId, message);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+ }
+
+ /* package-private */ void assertTrue(final boolean condition) {
+ if (DEBUG && !condition) {
+ throw new AssertionError("Expected condition to be true");
+ }
+ }
+
+ RemoteMediaDrmBridgeStub(final String keySystem, final String stubId) throws RemoteException {
+ if (Build.VERSION.SDK_INT < 21) {
+ Log.e(LOGTAG, "Pre-Lollipop should never enter here!!");
+ throw new RemoteException("Error, unsupported version!");
+ }
+ try {
+ if (Build.VERSION.SDK_INT < 23) {
+ mBridge = new GeckoMediaDrmBridgeV21(keySystem);
+ } else {
+ mBridge = new GeckoMediaDrmBridgeV23(keySystem);
+ }
+ mStubId = stubId;
+ mBridgeStubs.add(this);
+ } catch (final Exception e) {
+ throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation.");
+ }
+ }
+
+ @Override
+ public synchronized void setCallbacks(final IMediaDrmBridgeCallbacks callbacks)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "setCallbacks()");
+ assertTrue(mBridge != null);
+ assertTrue(callbacks != null);
+ mCallbacks = callbacks;
+ callbacks.asBinder().linkToDeath(this, 0);
+ mBridge.setCallbacks(new Callbacks(mCallbacks));
+ }
+
+ @Override
+ public synchronized void createSession(
+ final int createSessionToken,
+ final int promiseId,
+ final String initDataType,
+ final byte[] initData)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "createSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.createSession(createSessionToken, promiseId, initDataType, initData);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to createSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to createSession.");
+ }
+ }
+
+ @Override
+ public synchronized void updateSession(
+ final int promiseId, final String sessionId, final byte[] response) throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "updateSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.updateSession(promiseId, sessionId, response);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to updateSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to updateSession.");
+ }
+ }
+
+ @Override
+ public synchronized void closeSession(final int promiseId, final String sessionId)
+ throws RemoteException {
+ if (DEBUG) Log.d(LOGTAG, "closeSession()");
+ try {
+ assertTrue(mCallbacks != null);
+ assertTrue(mBridge != null);
+ mBridge.closeSession(promiseId, sessionId);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to closeSession.", e);
+ mCallbacks.onRejectPromise(promiseId, "Failed to closeSession.");
+ }
+ }
+
+ // IBinder.DeathRecipient
+ @Override
+ public synchronized void binderDied() {
+ Log.e(LOGTAG, "Binder died !!");
+ try {
+ release();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Exception ! Dead recipient !!", e);
+ }
+ }
+
+ @Override
+ public synchronized void release() {
+ if (DEBUG) Log.d(LOGTAG, "release()");
+ mBridgeStubs.remove(this);
+ if (mBridge != null) {
+ mBridge.release();
+ mBridge = null;
+ }
+ mCallbacks.asBinder().unlinkToDeath(this, 0);
+ mCallbacks = null;
+ mStubId = "";
+ }
+
+ @Override
+ public synchronized void setServerCertificate(final byte[] cert) {
+ try {
+ mBridge.setServerCertificate(cert);
+ } catch (final IllegalStateException e) {
+ Log.e(LOGTAG, "Failed to setServerCertificate.", e);
+ throw e;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java
new file mode 100644
index 0000000000..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<Sample> CREATOR =
+ new Creator<Sample>() {
+ @Override
+ public Sample createFromParcel(final Parcel in) {
+ return obtainSample(in);
+ }
+
+ @Override
+ public Sample[] newArray(final int size) {
+ return new Sample[size];
+ }
+
+ private Sample obtainSample(final Parcel in) {
+ final Sample s = obtain();
+ s.session = in.readLong();
+ s.bufferId = in.readInt();
+ s.readInfo(in);
+ s.readCrypto(in);
+ return s;
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int parcelableFlags) {
+ dest.writeLong(session);
+ dest.writeInt(bufferId);
+ writeInfo(dest);
+ writeCrypto(dest);
+ }
+
+ private void writeInfo(final Parcel dest) {
+ dest.writeInt(info.offset);
+ dest.writeInt(info.size);
+ dest.writeLong(info.presentationTimeUs);
+ dest.writeInt(info.flags);
+ }
+
+ private void writeCrypto(final Parcel dest) {
+ if (cryptoInfo != null) {
+ dest.writeInt(1);
+ dest.writeByteArray(cryptoInfo.iv);
+ dest.writeByteArray(cryptoInfo.key);
+ dest.writeInt(cryptoInfo.mode);
+ dest.writeIntArray(cryptoInfo.numBytesOfClearData);
+ dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData);
+ dest.writeInt(cryptoInfo.numSubSamples);
+ 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<SampleBuffer> CREATOR =
+ new Creator<SampleBuffer>() {
+ @Override
+ public SampleBuffer createFromParcel(final Parcel in) {
+ return new SampleBuffer(in);
+ }
+
+ @Override
+ public SampleBuffer[] newArray(final int size) {
+ return new SampleBuffer[size];
+ }
+ };
+
+ public int capacity() {
+ return mSharedMem != null ? mSharedMem.getSize() : 0;
+ }
+
+ public void readFromByteBuffer(final ByteBuffer src, final int offset, final int size)
+ throws IOException {
+ if (!src.isDirect()) {
+ throw new IOException("SharedMemBuffer only support reading from direct byte buffer.");
+ }
+ try {
+ nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size);
+ mSharedMem.flush();
+ } catch (final NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static native void nativeReadFromDirectBuffer(
+ ByteBuffer src, long dest, int offset, int size);
+
+ @WrapForJNI
+ public void writeToByteBuffer(final ByteBuffer dest, final int offset, final int size)
+ throws IOException {
+ if (!dest.isDirect()) {
+ throw new IOException("SharedMemBuffer only support writing to direct byte buffer.");
+ }
+ try {
+ nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size);
+ } catch (final NullPointerException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static native void nativeWriteToDirectBuffer(
+ long src, ByteBuffer dest, int offset, int size);
+
+ public void dispose() {
+ if (mSharedMem != null) {
+ mSharedMem.dispose();
+ mSharedMem = null;
+ }
+ }
+
+ @WrapForJNI
+ public boolean isValid() {
+ return mSharedMem != null;
+ }
+
+ @Override
+ public String toString() {
+ return "Buffer: " + mSharedMem;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
new file mode 100644
index 0000000000..a2101b3aeb
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java
@@ -0,0 +1,154 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.media.MediaCodec;
+import android.util.SparseArray;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.mozglue.SharedMemory;
+
+final class SamplePool {
+ private static final class Impl {
+ private final String mName;
+ private int mDefaultBufferSize = 4096;
+ private final List<Sample> mRecycledSamples = new ArrayList<>();
+ private final boolean mBufferless;
+
+ private int mNextBufferId = Sample.NO_BUFFER + 1;
+ private SparseArray<SampleBuffer> mBuffers = new SparseArray<>();
+
+ private Impl(final String name, final boolean bufferless) {
+ mName = name;
+ mBufferless = bufferless;
+ }
+
+ private void setDefaultBufferSize(final int size) {
+ if (mBufferless) {
+ throw new IllegalStateException("Setting buffer size of a bufferless pool is not allowed");
+ }
+ mDefaultBufferSize = size;
+ }
+
+ private synchronized Sample obtain(final int size) {
+ if (!mRecycledSamples.isEmpty()) {
+ return mRecycledSamples.remove(0);
+ }
+
+ if (mBufferless) {
+ return Sample.obtain();
+ } else {
+ return allocateSampleAndBuffer(size);
+ }
+ }
+
+ private Sample allocateSampleAndBuffer(final int size) {
+ final int id = mNextBufferId++;
+ try {
+ final SharedMemory shm = new SharedMemory(id, Math.max(size, mDefaultBufferSize));
+ mBuffers.put((Integer) id, new SampleBuffer(shm));
+ final Sample s = Sample.obtain();
+ s.bufferId = id;
+ return s;
+ } catch (final NoSuchMethodException | IOException e) {
+ mBuffers.delete(id);
+ throw new UnsupportedOperationException(e);
+ }
+ }
+
+ private synchronized SampleBuffer getBuffer(final int id) {
+ return mBuffers.get(id);
+ }
+
+ private synchronized void recycle(final Sample recycled) {
+ if (mBufferless || isUsefulSample(recycled)) {
+ mRecycledSamples.add(recycled);
+ } else {
+ disposeSample(recycled);
+ }
+ }
+
+ private boolean isUsefulSample(final Sample sample) {
+ return mBuffers.get(sample.bufferId).capacity() >= mDefaultBufferSize;
+ }
+
+ private synchronized void clear() {
+ for (final Sample s : mRecycledSamples) {
+ disposeSample(s);
+ }
+ mRecycledSamples.clear();
+
+ for (int i = 0; i < mBuffers.size(); ++i) {
+ mBuffers.valueAt(i).dispose();
+ }
+ mBuffers.clear();
+ }
+
+ private void disposeSample(final Sample sample) {
+ if (sample.bufferId != Sample.NO_BUFFER) {
+ mBuffers.get(sample.bufferId).dispose();
+ mBuffers.delete(sample.bufferId);
+ }
+ sample.dispose();
+ }
+
+ @Override
+ protected void finalize() {
+ clear();
+ }
+ }
+
+ private final Impl mInputs;
+ private final Impl mOutputs;
+
+ /* package */ SamplePool(final String name, final boolean renderToSurface) {
+ mInputs = new Impl(name + " input sample pool", false);
+ // Buffers are useless when rendering to surface.
+ mOutputs = new Impl(name + " output sample pool", renderToSurface);
+ }
+
+ /* package */ void setInputBufferSize(final int size) {
+ mInputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ void setOutputBufferSize(final int size) {
+ mOutputs.setDefaultBufferSize(size);
+ }
+
+ /* package */ Sample obtainInput(final int size) {
+ final Sample input = mInputs.obtain(size);
+ input.info.set(0, 0, 0, 0);
+ return input;
+ }
+
+ /* package */ Sample obtainOutput(final MediaCodec.BufferInfo info) {
+ final Sample output = mOutputs.obtain(info.size);
+ output.info.set(0, info.size, info.presentationTimeUs, info.flags);
+ return output;
+ }
+
+ /* package */ void recycleInput(final Sample sample) {
+ sample.cryptoInfo = null;
+ mInputs.recycle(sample);
+ }
+
+ /* package */ void recycleOutput(final Sample sample) {
+ mOutputs.recycle(sample);
+ }
+
+ /* package */ void reset() {
+ mInputs.clear();
+ mOutputs.clear();
+ }
+
+ /* package */ SampleBuffer getInputBuffer(final int id) {
+ return mInputs.getBuffer(id);
+ }
+
+ /* package */ SampleBuffer getOutputBuffer(final int id) {
+ return mOutputs.getBuffer(id);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java
new file mode 100644
index 0000000000..5e70a6f2a7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java
@@ -0,0 +1,50 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public final class SessionKeyInfo implements Parcelable {
+ @WrapForJNI public byte[] keyId;
+
+ @WrapForJNI public int status;
+
+ @WrapForJNI
+ public SessionKeyInfo(final byte[] keyId, final int status) {
+ this.keyId = keyId;
+ this.status = status;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int parcelableFlags) {
+ dest.writeByteArray(keyId);
+ dest.writeInt(status);
+ }
+
+ public static final Creator<SessionKeyInfo> CREATOR =
+ new Creator<SessionKeyInfo>() {
+ @Override
+ public SessionKeyInfo createFromParcel(final Parcel in) {
+ return new SessionKeyInfo(in);
+ }
+
+ @Override
+ public SessionKeyInfo[] newArray(final int size) {
+ return new SessionKeyInfo[size];
+ }
+ };
+
+ private SessionKeyInfo(final Parcel src) {
+ keyId = src.createByteArray();
+ status = src.readInt();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java
new file mode 100644
index 0000000000..5cc32e127c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.media;
+
+import android.util.Log;
+
+public class Utils {
+ public static long getThreadId() {
+ final Thread t = Thread.currentThread();
+ return t.getId();
+ }
+
+ public static String getThreadSignature() {
+ final Thread t = Thread.currentThread();
+ final long l = t.getId();
+ final String name = t.getName();
+ final long p = t.getPriority();
+ final String gname = t.getThreadGroup().getName();
+ return (name + ":(id)" + l + ":(priority)" + p + ":(group)" + gname);
+ }
+
+ public static void logThreadSignature() {
+ Log.d("ThreadUtils", getThreadSignature());
+ }
+
+ private static final char[] hexArray = "0123456789ABCDEF".toCharArray();
+
+ public static String bytesToHex(final byte[] bytes) {
+ final char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ final int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java
new file mode 100644
index 0000000000..701780171e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java
@@ -0,0 +1,440 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.mozglue;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+import android.util.Log;
+import dalvik.system.BaseDexClassLoader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.JNITarget;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+public final class GeckoLoader {
+ private static final String LOGTAG = "GeckoLoader";
+
+ private static File sGREDir;
+
+ /* Synchronized on GeckoLoader.class. */
+ private static boolean sSQLiteLibsLoaded;
+ private static boolean sNSSLibsLoaded;
+ private static boolean sMozGlueLoaded;
+
+ private GeckoLoader() {
+ // prevent instantiation
+ }
+
+ public static File getGREDir(final Context context) {
+ if (sGREDir == null) {
+ sGREDir = new File(context.getApplicationInfo().dataDir);
+ }
+ return sGREDir;
+ }
+
+ private static void setupDownloadEnvironment(final Context context) {
+ try {
+ File downloadDir =
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ File updatesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
+ if (downloadDir == null) {
+ downloadDir = new File(Environment.getExternalStorageDirectory().getPath(), "download");
+ }
+ if (updatesDir == null) {
+ updatesDir = downloadDir;
+ }
+ putenv("DOWNLOADS_DIRECTORY=" + downloadDir.getPath());
+ putenv("UPDATES_DIRECTORY=" + updatesDir.getPath());
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "No download directory found.", e);
+ }
+ }
+
+ private static void delTree(final File file) {
+ if (file.isDirectory()) {
+ final File[] children = file.listFiles();
+ for (final File child : children) {
+ delTree(child);
+ }
+ }
+ file.delete();
+ }
+
+ private static File getTmpDir(final Context context) {
+ // It's important that this folder is in the cache directory so users can actually
+ // clear it when it gets too big.
+ return new File(context.getCacheDir(), "gecko_temp");
+ }
+
+ private static String escapeDoubleQuotes(final String str) {
+ return str.replaceAll("\"", "\\\"");
+ }
+
+ private static void setupInitialPrefs(final Map<String, Object> prefs) {
+ if (prefs != null) {
+ final StringBuilder prefsEnv = new StringBuilder("MOZ_DEFAULT_PREFS=");
+ for (final String key : prefs.keySet()) {
+ final Object value = prefs.get(key);
+ if (value == null) {
+ continue;
+ }
+ prefsEnv.append(String.format("pref(\"%s\",", escapeDoubleQuotes(key)));
+ if (value instanceof String) {
+ prefsEnv.append(String.format("\"%s\"", escapeDoubleQuotes(value.toString())));
+ } else if (value instanceof Boolean) {
+ prefsEnv.append((Boolean) value ? "true" : "false");
+ } else {
+ prefsEnv.append(value.toString());
+ }
+
+ prefsEnv.append(");\n");
+ }
+
+ putenv(prefsEnv.toString());
+ }
+ }
+
+ @SuppressWarnings("deprecation") // for Build.CPU_ABI
+ public static synchronized void setupGeckoEnvironment(
+ final Context context,
+ final boolean isChildProcess,
+ final String profilePath,
+ final Collection<String> env,
+ final Map<String, Object> prefs,
+ final boolean xpcshell) {
+ for (final String e : env) {
+ putenv(e);
+ }
+
+ putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName());
+
+ if (!isChildProcess) {
+ setupDownloadEnvironment(context);
+
+ // profile home path
+ putenv("HOME=" + profilePath);
+
+ // setup the downloads path
+ File f = Environment.getDownloadCacheDirectory();
+ putenv("EXTERNAL_STORAGE=" + f.getPath());
+
+ // setup the app-specific cache path
+ f = context.getCacheDir();
+ putenv("CACHE_DIRECTORY=" + f.getPath());
+
+ f = context.getExternalFilesDir(null);
+ if (f != null) {
+ putenv("PUBLIC_STORAGE=" + f.getPath());
+ }
+
+ if (Build.VERSION.SDK_INT >= 17) {
+ final android.os.UserManager um =
+ (android.os.UserManager) context.getSystemService(Context.USER_SERVICE);
+ if (um != null) {
+ putenv(
+ "MOZ_ANDROID_USER_SERIAL_NUMBER="
+ + um.getSerialNumberForUser(android.os.Process.myUserHandle()));
+ } else {
+ Log.d(
+ LOGTAG,
+ "Unable to obtain user manager service on a device with SDK version "
+ + Build.VERSION.SDK_INT);
+ }
+ }
+
+ setupInitialPrefs(prefs);
+ }
+
+ // Xpcshell tests set up their own temp directory
+ if (!xpcshell) {
+ // setup the tmp path
+ final File f = getTmpDir(context);
+ if (!f.exists()) {
+ f.mkdirs();
+ }
+ putenv("TMPDIR=" + f.getPath());
+ }
+
+ putenv("LANG=" + Locale.getDefault().toString());
+
+ final Class<?> crashHandler = GeckoAppShell.getCrashHandlerService();
+ if (crashHandler != null) {
+ putenv(
+ "MOZ_ANDROID_CRASH_HANDLER=" + context.getPackageName() + "/" + crashHandler.getName());
+ }
+
+ putenv("MOZ_ANDROID_DEVICE_SDK_VERSION=" + Build.VERSION.SDK_INT);
+ putenv("MOZ_ANDROID_CPU_ABI=" + Build.CPU_ABI);
+
+ // env from extras could have reset out linker flags; set them again.
+ loadLibsSetupLocked(context);
+ }
+
+ // Adapted from
+ // https://source.chromium.org/chromium/chromium/src/+/main:base/android/java/src/org/chromium/base/BundleUtils.java;l=196;drc=c0fedddd4a1444653235912cfae3d44b544ded01
+ private static String getLibraryPath(final String libraryName) {
+ // Due to b/171269960 isolated split class loaders have an empty library path, so check
+ // the base module class loader first which loaded GeckoAppShell. If the library is not
+ // found there, attempt to construct the correct library path from the split.
+ String path =
+ ((BaseDexClassLoader) GeckoAppShell.class.getClassLoader()).findLibrary(libraryName);
+ if (path != null) {
+ return path;
+ }
+
+ // SplitCompat is installed on the application context, so check there for library paths
+ // which were added to that ClassLoader.
+ final ClassLoader classLoader = GeckoAppShell.getApplicationContext().getClassLoader();
+ if (classLoader instanceof BaseDexClassLoader) {
+ path = ((BaseDexClassLoader) classLoader).findLibrary(libraryName);
+ if (path != null) {
+ return path;
+ }
+ }
+
+ throw new RuntimeException("Could not find mozglue path.");
+ }
+
+ private static String getLibraryBase() {
+ final String mozglue = getLibraryPath("mozglue");
+ final int lastSlash = mozglue.lastIndexOf('/');
+ if (lastSlash < 0) {
+ throw new IllegalStateException("Invalid library path for libmozglue.so: " + mozglue);
+ }
+ final String base = mozglue.substring(0, lastSlash);
+ Log.i(LOGTAG, "Library base=" + base);
+ return base;
+ }
+
+ private static void loadLibsSetupLocked(final Context context) {
+ putenv("GRE_HOME=" + getGREDir(context).getPath());
+ putenv("MOZ_ANDROID_LIBDIR=" + getLibraryBase());
+ }
+
+ @RobocopTarget
+ public static synchronized void loadSQLiteLibs(final Context context) {
+ if (sSQLiteLibsLoaded) {
+ return;
+ }
+
+ loadMozGlue(context);
+ loadLibsSetupLocked(context);
+ loadSQLiteLibsNative();
+ sSQLiteLibsLoaded = true;
+ }
+
+ public static synchronized void loadNSSLibs(final Context context) {
+ if (sNSSLibsLoaded) {
+ return;
+ }
+
+ loadMozGlue(context);
+ loadLibsSetupLocked(context);
+ loadNSSLibsNative();
+ sNSSLibsLoaded = true;
+ }
+
+ @SuppressWarnings("deprecation")
+ private static String getCPUABI() {
+ return android.os.Build.CPU_ABI;
+ }
+
+ /**
+ * Copy a library out of our APK.
+ *
+ * @param context a Context.
+ * @param lib the name of the library; e.g., "mozglue".
+ * @param outDir the output directory for the .so. No trailing slash.
+ * @return true on success, false on failure.
+ */
+ private static boolean extractLibrary(
+ final Context context, final String lib, final String outDir) {
+ final String apkPath = context.getApplicationInfo().sourceDir;
+
+ // Sanity check.
+ if (!apkPath.endsWith(".apk")) {
+ Log.w(LOGTAG, "sourceDir is not an APK.");
+ return false;
+ }
+
+ // Try to extract the named library from the APK.
+ final File outDirFile = new File(outDir);
+ if (!outDirFile.isDirectory()) {
+ if (!outDirFile.mkdirs()) {
+ Log.e(LOGTAG, "Couldn't create " + outDir);
+ return false;
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ final String[] abis = Build.SUPPORTED_ABIS;
+ for (final String abi : abis) {
+ if (tryLoadWithABI(lib, outDir, apkPath, abi)) {
+ return true;
+ }
+ }
+ return false;
+ } else {
+ final String abi = getCPUABI();
+ return tryLoadWithABI(lib, outDir, apkPath, abi);
+ }
+ }
+
+ private static boolean tryLoadWithABI(
+ final String lib, final String outDir, final String apkPath, final String abi) {
+ try {
+ final ZipFile zipFile = new ZipFile(new File(apkPath));
+ try {
+ final String libPath = "lib/" + abi + "/lib" + lib + ".so";
+ final ZipEntry entry = zipFile.getEntry(libPath);
+ if (entry == null) {
+ Log.w(LOGTAG, libPath + " not found in APK " + apkPath);
+ return false;
+ }
+
+ final InputStream in = zipFile.getInputStream(entry);
+ try {
+ final String outPath = outDir + "/lib" + lib + ".so";
+ final FileOutputStream out = new FileOutputStream(outPath);
+ final byte[] bytes = new byte[1024];
+ int read;
+
+ Log.d(LOGTAG, "Copying " + libPath + " to " + outPath);
+ boolean failed = false;
+ try {
+ while ((read = in.read(bytes, 0, 1024)) != -1) {
+ out.write(bytes, 0, read);
+ }
+ } catch (final Exception e) {
+ Log.w(LOGTAG, "Failing library copy.", e);
+ failed = true;
+ } finally {
+ out.close();
+ }
+
+ if (failed) {
+ // Delete the partial copy so we don't fail to load it.
+ // Don't bother to check the return value -- there's nothing
+ // we can do about a failure.
+ new File(outPath).delete();
+ } else {
+ // Mark the file as executable. This doesn't seem to be
+ // necessary for the loader, but it's the normal state of
+ // affairs.
+ Log.d(LOGTAG, "Marking " + outPath + " as executable.");
+ new File(outPath).setExecutable(true);
+ }
+
+ return !failed;
+ } finally {
+ in.close();
+ }
+ } finally {
+ zipFile.close();
+ }
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "Failed to extract lib from APK.", e);
+ return false;
+ }
+ }
+
+ private static boolean attemptLoad(final String path) {
+ try {
+ System.load(path);
+ return true;
+ } catch (final Throwable e) {
+ Log.wtf(LOGTAG, "Couldn't load " + path + ": " + e);
+ }
+
+ return false;
+ }
+
+ /**
+ * The first two attempts at loading a library: directly, and then using the app library path.
+ *
+ * <p>Returns null or the cause exception.
+ */
+ public static Throwable doLoadLibrary(final Context context, final String lib) {
+ try {
+ // Attempt 1: the way that should work.
+ System.loadLibrary(lib);
+ return null;
+ } catch (final Throwable e) {
+ final String libPath = getLibraryPath(lib);
+ // Does it even exist?
+ if (new File(libPath).exists()) {
+ if (attemptLoad(libPath)) {
+ // Success!
+ return null;
+ }
+ throw new RuntimeException(
+ "Library exists but couldn't load." + "Path: " + libPath + " lib: " + lib, e);
+ }
+ throw new RuntimeException(
+ "Library doesn't exist when it should." + "Path: " + libPath + " lib: " + lib, e);
+ }
+ }
+
+ public static synchronized void loadMozGlue(final Context context) {
+ if (sMozGlueLoaded) {
+ return;
+ }
+
+ doLoadLibrary(context, "mozglue");
+ sMozGlueLoaded = true;
+ }
+
+ public static synchronized void loadGeckoLibs(final Context context) {
+ loadLibsSetupLocked(context);
+ loadGeckoLibsNative();
+ }
+
+ @SuppressWarnings("serial")
+ public static class AbortException extends Exception {
+ public AbortException(final String msg) {
+ super(msg);
+ }
+ }
+
+ @JNITarget
+ public static void abort(final String msg) {
+ final Thread thread = Thread.currentThread();
+ final Thread.UncaughtExceptionHandler uncaughtHandler = thread.getUncaughtExceptionHandler();
+ if (uncaughtHandler != null) {
+ uncaughtHandler.uncaughtException(thread, new AbortException(msg));
+ }
+ }
+
+ // These methods are implemented in mozglue/android/nsGeckoUtils.cpp
+ private static native void putenv(String map);
+
+ // These methods are implemented in mozglue/android/APKOpen.cpp
+ public static native void nativeRun(
+ String[] args,
+ int prefsFd,
+ int prefMapFd,
+ int ipcFd,
+ int crashFd,
+ int crashAnnotationFd,
+ boolean xpcshell,
+ String outFilePath);
+
+ private static native void loadGeckoLibsNative();
+
+ private static native void loadSQLiteLibsNative();
+
+ private static native void loadNSSLibsNative();
+
+ public static native void suppressCrashDialog();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java
new file mode 100644
index 0000000000..3b0f8cc96b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.mozglue;
+
+// Class that all classes with native methods extend from.
+public abstract class JNIObject {
+ // Pointer that references the native object. This is volatile because it may be accessed
+ // by multiple threads simultaneously.
+ private volatile long mHandle;
+
+ // Dispose of any reference to a native object.
+ //
+ // If the native instance is destroyed from the native side, this should never be
+ // called, so you should throw an UnsupportedOperationException. If instead you
+ // want to destroy the native side from the Java end, make override this with
+ // a native call, and the right thing will be done in the native code.
+ protected abstract void disposeNative();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java
new file mode 100644
index 0000000000..028cfd6590
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java
@@ -0,0 +1,12 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.mozglue;
+
+public interface NativeReference {
+ public void release();
+
+ public boolean isReleased();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java
new file mode 100644
index 0000000000..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<SharedMemory> CREATOR =
+ new Creator<SharedMemory>() {
+ @Override
+ public SharedMemory createFromParcel(final Parcel in) {
+ return new SharedMemory(in);
+ }
+
+ @Override
+ public SharedMemory[] newArray(final int size) {
+ return new SharedMemory[size];
+ }
+ };
+
+ @Override
+ public int describeContents() {
+ return CONTENTS_FILE_DESCRIPTOR;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ // We don't want ParcelFileDescriptor.writeToParcel() to close the fd.
+ dest.writeFileDescriptor(mDescriptor.getFileDescriptor());
+ dest.writeInt(mSize);
+ dest.writeInt(mId);
+ }
+
+ public SharedMemory(final int id, final int size) throws NoSuchMethodException, IOException {
+ if (sGetFDMethod == null) {
+ throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist.");
+ }
+ mBackedFile = new MemoryFile(null, size);
+ try {
+ final FileDescriptor fd = (FileDescriptor) sGetFDMethod.invoke(mBackedFile);
+ mDescriptor = ParcelFileDescriptor.dup(fd);
+ mSize = size;
+ mId = id;
+ mBackedFile.allowPurging(false);
+ } catch (final Exception e) {
+ e.printStackTrace();
+ close();
+ throw new IOException(e.getMessage());
+ }
+ }
+
+ public void flush() {
+ if (!mIsMapped) {
+ return;
+ }
+
+ unmap(mHandle, mSize);
+ mHandle = 0;
+ mIsMapped = false;
+ }
+
+ public void close() {
+ flush();
+
+ if (mDescriptor != null) {
+ try {
+ mDescriptor.close();
+ } catch (final IOException e) {
+ e.printStackTrace();
+ }
+ mDescriptor = null;
+ }
+ }
+
+ // Should only be called by process that allocates shared memory.
+ public void dispose() {
+ if (!isValid()) {
+ return;
+ }
+
+ close();
+
+ if (mBackedFile != null) {
+ mBackedFile.close();
+ mBackedFile = null;
+ }
+ }
+
+ private native void unmap(long address, int size);
+
+ public boolean isValid() {
+ return mDescriptor != null;
+ }
+
+ public int getSize() {
+ return mSize;
+ }
+
+ private int getFD() {
+ return isValid() ? mDescriptor.getFd() : -1;
+ }
+
+ public long getPointer() {
+ if (!isValid()) {
+ return 0;
+ }
+
+ if (!mIsMapped) {
+ try {
+ mHandle = map(getFD(), mSize);
+ } catch (final NullPointerException e) {
+ Log.e(LOGTAG, "SharedMemory#" + mId + " error.", e);
+ throw e;
+ }
+ if (mHandle != 0) {
+ mIsMapped = true;
+ }
+ }
+ return mHandle;
+ }
+
+ private native long map(int fd, int size);
+
+ @Override
+ protected void finalize() throws Throwable {
+ if (mBackedFile != null) {
+ Log.w(LOGTAG, "dispose() not called before finalizing");
+ }
+ dispose();
+
+ super.finalize();
+ }
+
+ @Override
+ public String toString() {
+ return "SHM("
+ + getSize()
+ + " bytes): id="
+ + mId
+ + ", backing="
+ + mBackedFile
+ + ",fd="
+ + mDescriptor;
+ }
+
+ @Override
+ public boolean equals(final Object that) {
+ return (this == that) || ((that instanceof SharedMemory) && (hashCode() == that.hashCode()));
+ }
+
+ @Override
+ public int hashCode() {
+ return mId;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja
new file mode 100644
index 0000000000..fa2f336566
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+public class GeckoChildProcessServices {
+ /* package */ static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = {{MOZ_ANDROID_CONTENT_SERVICE_COUNT}};
+ public static final class gmplugin extends GeckoServiceChildProcess {}
+ public static final class socket extends GeckoServiceChildProcess {}
+ public static final class gpu extends GeckoServiceGpuProcess {}
+ public static final class utility extends GeckoServiceChildProcess {}
+ public static final class ipdlunittest extends GeckoServiceChildProcess {}
+
+{% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %}
+ public static final class tab{{ id }} extends GeckoServiceChildProcess {}
+{% endfor %}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java
new file mode 100644
index 0000000000..039396f9e8
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java
@@ -0,0 +1,927 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.os.DeadObjectException;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.collection.ArrayMap;
+import androidx.collection.ArraySet;
+import androidx.collection.SimpleArrayMap;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoNetworkManager;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.GeckoThread.FileDescriptors;
+import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.TelemetryUtils;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.CompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.gfx.RemoteSurfaceAllocator;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.gecko.process.ServiceAllocator.PriorityLevel;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+import org.mozilla.geckoview.GeckoResult;
+
+public final class GeckoProcessManager extends IProcessManager.Stub {
+ private static final String LOGTAG = "GeckoProcessManager";
+ private static final GeckoProcessManager INSTANCE = new GeckoProcessManager();
+ private static final int INVALID_PID = 0;
+
+ // This id univocally identifies the current process manager instance
+ private final String mInstanceId;
+
+ public static GeckoProcessManager getInstance() {
+ return INSTANCE;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void setEditableChildParent(
+ final IGeckoEditableChild child, final IGeckoEditableParent parent) {
+ try {
+ child.transferParent(parent);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Cannot set parent", e);
+ }
+ }
+
+ @WrapForJNI(stubName = "GetEditableParent", dispatchTo = "gecko")
+ private static native void nativeGetEditableParent(
+ IGeckoEditableChild child, long contentId, long tabId);
+
+ @Override // IProcessManager
+ public void getEditableParent(
+ final IGeckoEditableChild child, final long contentId, final long tabId) {
+ nativeGetEditableParent(child, contentId, tabId);
+ }
+
+ /**
+ * Returns the surface allocator interface to be used by child processes to allocate Surfaces. The
+ * service bound to the returned interface may live in either the GPU process or parent process.
+ */
+ @Override // IProcessManager
+ public ISurfaceAllocator getSurfaceAllocator() {
+ final GeckoResult<Boolean> gpuEnabled = GeckoAppShell.isGpuProcessEnabled();
+
+ try {
+ final GeckoResult<ISurfaceAllocator> allocator = new GeckoResult<>();
+ if (gpuEnabled.poll(1000)) {
+ // The GPU process is enabled, so look it up and ask it for its surface allocator.
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ final Selector selector = new Selector(GeckoProcessType.GPU);
+ final GpuProcessConnection conn =
+ (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn != null) {
+ allocator.complete(conn.getSurfaceAllocator());
+ } else {
+ // If we cannot find a GPU process, it has probably been killed and not yet
+ // restarted. Return null here, and allow the caller to try again later.
+ // We definitely do *not* want to return the parent process allocator instead, as
+ // that will result in surfaces being allocated in the parent process, which
+ // therefore won't be usable when the GPU process is eventually launched.
+ allocator.complete(null);
+ }
+ });
+ } else {
+ // The GPU process is disabled, so return the parent process allocator instance.
+ allocator.complete(RemoteSurfaceAllocator.getInstance(0));
+ }
+ return allocator.poll(100);
+ } catch (final Throwable e) {
+ Log.e(LOGTAG, "Error in getSurfaceAllocator", e);
+ return null;
+ }
+ }
+
+ @WrapForJNI
+ public static CompositorSurfaceManager getCompositorSurfaceManager() {
+ final Selector selector = new Selector(GeckoProcessType.GPU);
+ final GpuProcessConnection conn =
+ (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return null;
+ }
+ return conn.getCompositorSurfaceManager();
+ }
+
+ /** Gecko uses this class to uniquely identify a process managed by GeckoProcessManager. */
+ public static final class Selector {
+ private final GeckoProcessType mType;
+ private final int mPid;
+
+ @WrapForJNI
+ private Selector(@NonNull final GeckoProcessType type, final int pid) {
+ if (pid == INVALID_PID) {
+ throw new RuntimeException("Invalid PID");
+ }
+
+ mType = type;
+ mPid = pid;
+ }
+
+ @WrapForJNI
+ private Selector(@NonNull final GeckoProcessType type) {
+ mType = type;
+ mPid = INVALID_PID;
+ }
+
+ public GeckoProcessType getType() {
+ return mType;
+ }
+
+ public int getPid() {
+ return mPid;
+ }
+
+ @Override
+ public boolean equals(final Object obj) {
+ if (obj == null) {
+ return false;
+ }
+
+ if (obj == ((Object) this)) {
+ return true;
+ }
+
+ final Selector other = (Selector) obj;
+ return mType == other.mType && mPid == other.mPid;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {mType, mPid});
+ }
+ }
+
+ private static final class IncompleteChildConnectionException extends RuntimeException {
+ public IncompleteChildConnectionException(@NonNull final String msg) {
+ super(msg);
+ }
+ }
+
+ /**
+ * Maintains state pertaining to an individual child process. Inheriting from
+ * ServiceAllocator.InstanceInfo enables this class to work with ServiceAllocator.
+ */
+ private static class ChildConnection extends ServiceAllocator.InstanceInfo {
+ private IChildProcess mChild;
+ private GeckoResult<IChildProcess> mPendingBind;
+ private int mPid;
+
+ protected ChildConnection(
+ @NonNull final ServiceAllocator allocator,
+ @NonNull final GeckoProcessType type,
+ @NonNull final PriorityLevel initialPriority) {
+ super(allocator, type, initialPriority);
+ mPid = INVALID_PID;
+ }
+
+ public int getPid() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (mChild == null) {
+ throw new IncompleteChildConnectionException(
+ "Calling ChildConnection.getPid() on an incomplete connection");
+ }
+
+ return mPid;
+ }
+
+ private GeckoResult<IChildProcess> completeFailedBind(
+ @NonNull final ServiceAllocator.BindException e) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ Log.e(LOGTAG, "Failed bind", e);
+
+ if (mPendingBind == null) {
+ throw new IllegalStateException("Bind failed with null mPendingBind");
+ }
+
+ final GeckoResult<IChildProcess> bindResult = mPendingBind;
+ mPendingBind = null;
+ unbind().accept(v -> bindResult.completeExceptionally(e));
+ return bindResult;
+ }
+
+ public GeckoResult<IChildProcess> bind() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mChild != null) {
+ // Already bound
+ return GeckoResult.fromValue(mChild);
+ }
+
+ if (mPendingBind != null) {
+ // Bind in progress
+ return mPendingBind;
+ }
+
+ mPendingBind = new GeckoResult<>();
+ try {
+ if (!bindService()) {
+ throw new ServiceAllocator.BindException("Cannot connect to process");
+ }
+ } catch (final ServiceAllocator.BindException e) {
+ return completeFailedBind(e);
+ }
+
+ return mPendingBind;
+ }
+
+ public GeckoResult<Void> unbind() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mPendingBind != null) {
+ // We called unbind() while bind() was still pending completion
+ return mPendingBind.then(child -> unbind());
+ }
+
+ if (mChild == null) {
+ // Not bound in the first place
+ return GeckoResult.fromValue(null);
+ }
+
+ unbindService();
+
+ return GeckoResult.fromValue(null);
+ }
+
+ @Override
+ protected void onBinderConnected(final IBinder service) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final IChildProcess child = IChildProcess.Stub.asInterface(service);
+ try {
+ mPid = child.getPid();
+ onBinderConnected(child);
+ } catch (final DeadObjectException e) {
+ unbindService();
+
+ // mPendingBind might be null if a bind was initiated by the system (eg Service Restart)
+ if (mPendingBind != null) {
+ mPendingBind.completeExceptionally(e);
+ mPendingBind = null;
+ }
+
+ return;
+ } catch (final RemoteException e) {
+ throw new RuntimeException(e);
+ }
+
+ mChild = child;
+ GeckoProcessManager.INSTANCE.mConnections.onBindComplete(this);
+
+ // mPendingBind might be null if a bind was initiated by the system (eg Service Restart)
+ if (mPendingBind != null) {
+ mPendingBind.complete(mChild);
+ mPendingBind = null;
+ }
+ }
+
+ // Subclasses of ChildConnection can override this method to make any IChildProcess calls
+ // specific to their process type immediately after connection.
+ protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {}
+
+ @Override
+ protected void onReleaseResources() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ // NB: This must happen *before* resetting mPid!
+ GeckoProcessManager.INSTANCE.mConnections.removeConnection(this);
+
+ mChild = null;
+ mPid = INVALID_PID;
+ }
+ }
+
+ private static class NonContentConnection extends ChildConnection {
+ public NonContentConnection(
+ @NonNull final ServiceAllocator allocator, @NonNull final GeckoProcessType type) {
+ super(allocator, type, PriorityLevel.FOREGROUND);
+ if (type == GeckoProcessType.CONTENT) {
+ throw new AssertionError("Attempt to create a NonContentConnection as CONTENT");
+ }
+ }
+
+ protected void onAppForeground() {
+ setPriorityLevel(PriorityLevel.FOREGROUND);
+ }
+
+ protected void onAppBackground() {
+ setPriorityLevel(PriorityLevel.IDLE);
+ }
+ }
+
+ private static final class GpuProcessConnection extends NonContentConnection {
+ private CompositorSurfaceManager mCompositorSurfaceManager;
+ private ISurfaceAllocator mSurfaceAllocator;
+
+ // Unique ID used to identify each GPU process instance. Will always be non-zero,
+ // and unlike the process' pid cannot be the same value for successive instances.
+ private int mUniqueGpuProcessId;
+ // Static counter used to initialize each instance's mUniqueGpuProcessId
+ private static int sUniqueGpuProcessIdCounter = 0;
+
+ public GpuProcessConnection(@NonNull final ServiceAllocator allocator) {
+ super(allocator, GeckoProcessType.GPU);
+
+ // Initialize the unique ID ensuring we skip 0 (as that is reserved for parent process
+ // allocators).
+ if (sUniqueGpuProcessIdCounter == 0) {
+ sUniqueGpuProcessIdCounter++;
+ }
+ mUniqueGpuProcessId = sUniqueGpuProcessIdCounter++;
+ }
+
+ @Override
+ protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {
+ mCompositorSurfaceManager = new CompositorSurfaceManager(child.getCompositorSurfaceManager());
+ mSurfaceAllocator = child.getSurfaceAllocator(mUniqueGpuProcessId);
+ }
+
+ public CompositorSurfaceManager getCompositorSurfaceManager() {
+ return mCompositorSurfaceManager;
+ }
+
+ public ISurfaceAllocator getSurfaceAllocator() {
+ return mSurfaceAllocator;
+ }
+ }
+
+ private static final class SocketProcessConnection extends NonContentConnection {
+ private boolean mIsForeground = true;
+ private boolean mIsNetworkUp = true;
+
+ public SocketProcessConnection(@NonNull final ServiceAllocator allocator) {
+ super(allocator, GeckoProcessType.SOCKET);
+ GeckoProcessManager.INSTANCE.mConnections.enableNetworkNotifications();
+ }
+
+ public void onNetworkStateChange(final boolean isNetworkUp) {
+ mIsNetworkUp = isNetworkUp;
+ prioritize();
+ }
+
+ @Override
+ protected void onAppForeground() {
+ mIsForeground = true;
+ prioritize();
+ }
+
+ @Override
+ protected void onAppBackground() {
+ mIsForeground = false;
+ prioritize();
+ }
+
+ private static final PriorityLevel[][] sPriorityStates = initPriorityStates();
+
+ private static PriorityLevel[][] initPriorityStates() {
+ final PriorityLevel[][] states = new PriorityLevel[2][2];
+ // Background, no network
+ states[0][0] = PriorityLevel.IDLE;
+ // Background, network
+ states[0][1] = PriorityLevel.BACKGROUND;
+ // Foreground, no network
+ states[1][0] = PriorityLevel.IDLE;
+ // Foreground, network
+ states[1][1] = PriorityLevel.FOREGROUND;
+ return states;
+ }
+
+ private void prioritize() {
+ final PriorityLevel nextPriority =
+ sPriorityStates[mIsForeground ? 1 : 0][mIsNetworkUp ? 1 : 0];
+ setPriorityLevel(nextPriority);
+ }
+ }
+
+ private static final class ContentConnection extends ChildConnection {
+ private static final String TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME =
+ "GV_CONTENT_PROCESS_LIFETIME_MS";
+
+ private TelemetryUtils.UptimeTimer mLifetimeTimer = null;
+
+ public ContentConnection(
+ @NonNull final ServiceAllocator allocator, @NonNull final PriorityLevel initialPriority) {
+ super(allocator, GeckoProcessType.CONTENT, initialPriority);
+ }
+
+ @Override
+ protected void onBinderConnected(final IBinder service) {
+ mLifetimeTimer = new TelemetryUtils.UptimeTimer(TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME);
+ super.onBinderConnected(service);
+ }
+
+ @Override
+ protected void onReleaseResources() {
+ if (mLifetimeTimer != null) {
+ mLifetimeTimer.stop();
+ mLifetimeTimer = null;
+ }
+
+ super.onReleaseResources();
+ }
+ }
+
+ /** This class manages the state surrounding existing connections and their priorities. */
+ private static final class ConnectionManager extends JNIObject {
+ // Connections to non-content processes
+ private final ArrayMap<GeckoProcessType, NonContentConnection> mNonContentConnections;
+ // Mapping of pid to content process
+ private final SimpleArrayMap<Integer, ContentConnection> mContentPids;
+ // Set of initialized content process connections
+ private final ArraySet<ContentConnection> mContentConnections;
+ // Set of bound but uninitialized content connections
+ private final ArraySet<ContentConnection> mNonStartedContentConnections;
+ // Allocator for service IDs
+ private final ServiceAllocator mServiceAllocator;
+ private boolean mIsObservingNetwork = false;
+
+ public ConnectionManager() {
+ mNonContentConnections = new ArrayMap<GeckoProcessType, NonContentConnection>();
+ mContentPids = new SimpleArrayMap<Integer, ContentConnection>();
+ mContentConnections = new ArraySet<ContentConnection>();
+ mNonStartedContentConnections = new ArraySet<ContentConnection>();
+ mServiceAllocator = new ServiceAllocator();
+
+ // Attach to native once JNI is ready.
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) {
+ attachTo(this);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.JNI_READY, ConnectionManager.class, "attachTo", this);
+ }
+ }
+
+ private void enableNetworkNotifications() {
+ if (mIsObservingNetwork) {
+ return;
+ }
+
+ mIsObservingNetwork = true;
+
+ // Ensure that GeckoNetworkManager is monitoring network events so that we can
+ // prioritize the socket process.
+ ThreadUtils.runOnUiThread(
+ () -> {
+ GeckoNetworkManager.getInstance().enableNotifications();
+ });
+
+ observeNetworkNotifications();
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void attachTo(ConnectionManager instance);
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private native void observeNetworkNotifications();
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onBackground() {
+ XPCOMEventTarget.runOnLauncherThread(() -> onAppBackgroundInternal());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onForeground() {
+ XPCOMEventTarget.runOnLauncherThread(() -> onAppForegroundInternal());
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onNetworkStateChange(final boolean isUp) {
+ XPCOMEventTarget.runOnLauncherThread(() -> onNetworkStateChangeInternal(isUp));
+ }
+
+ @Override
+ protected native void disposeNative();
+
+ private void onAppBackgroundInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ for (final NonContentConnection conn : mNonContentConnections.values()) {
+ conn.onAppBackground();
+ }
+ }
+
+ private void onAppForegroundInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ for (final NonContentConnection conn : mNonContentConnections.values()) {
+ conn.onAppForeground();
+ }
+ }
+
+ private void onNetworkStateChangeInternal(final boolean isUp) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final SocketProcessConnection conn =
+ (SocketProcessConnection) mNonContentConnections.get(GeckoProcessType.SOCKET);
+ if (conn == null) {
+ return;
+ }
+
+ conn.onNetworkStateChange(isUp);
+ }
+
+ private void removeContentConnection(@NonNull final ChildConnection conn) {
+ if (!mContentConnections.remove(conn)) {
+ throw new RuntimeException("Attempt to remove non-registered connection");
+ }
+ mNonStartedContentConnections.remove(conn);
+
+ final int pid;
+
+ try {
+ pid = conn.getPid();
+ } catch (final IncompleteChildConnectionException e) {
+ // conn lost its binding before it was able to retrieve its pid. It follows that
+ // mContentPids does not have an entry for this connection, so we can just return.
+ return;
+ }
+
+ if (pid == INVALID_PID) {
+ return;
+ }
+
+ final ChildConnection removed = mContentPids.remove(Integer.valueOf(pid));
+ if (removed != null && removed != conn) {
+ throw new RuntimeException(
+ "Integrity error - connection mismatch for pid " + Integer.toString(pid));
+ }
+ }
+
+ public void removeConnection(@NonNull final ChildConnection conn) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (conn.getType() == GeckoProcessType.CONTENT) {
+ removeContentConnection(conn);
+ return;
+ }
+
+ final ChildConnection removed = mNonContentConnections.remove(conn.getType());
+ if (removed != conn) {
+ throw new RuntimeException(
+ "Integrity error - connection mismatch for process type " + conn.getType().toString());
+ }
+ }
+
+ /** Saves any state information that was acquired upon start completion. */
+ public void onBindComplete(@NonNull final ChildConnection conn) {
+ if (conn.getType() == GeckoProcessType.CONTENT) {
+ final int pid = conn.getPid();
+ if (pid == INVALID_PID) {
+ throw new AssertionError(
+ "PID is invalid even though our caller just successfully retrieved it after binding");
+ }
+
+ mContentPids.put(pid, (ContentConnection) conn);
+ }
+ }
+
+ /** Retrieve the ChildConnection for an already running content process. */
+ private ContentConnection getExistingContentConnection(@NonNull final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (selector.getType() != GeckoProcessType.CONTENT) {
+ throw new IllegalArgumentException("Selector is not for content!");
+ }
+
+ return mContentPids.get(selector.getPid());
+ }
+
+ /** Unconditionally create a new content connection for the specified priority. */
+ private ContentConnection getNewContentConnection(@NonNull final PriorityLevel newPriority) {
+ final ContentConnection result = new ContentConnection(mServiceAllocator, newPriority);
+ mContentConnections.add(result);
+
+ return result;
+ }
+
+ /** Retrieve the ChildConnection for an already running child process of any type. */
+ public ChildConnection getExistingConnection(@NonNull final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final GeckoProcessType type = selector.getType();
+
+ if (type == GeckoProcessType.CONTENT) {
+ return getExistingContentConnection(selector);
+ }
+
+ return mNonContentConnections.get(type);
+ }
+
+ /**
+ * Retrieve a ChildConnection for a content process for the purposes of starting. If there are
+ * any preloaded content processes already running, we will use one of those. Otherwise we will
+ * allocate a new ChildConnection.
+ */
+ private ChildConnection getContentConnectionForStart() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ if (mNonStartedContentConnections.isEmpty()) {
+ return getNewContentConnection(PriorityLevel.FOREGROUND);
+ }
+
+ final ChildConnection conn =
+ mNonStartedContentConnections.removeAt(mNonStartedContentConnections.size() - 1);
+ conn.setPriorityLevel(PriorityLevel.FOREGROUND);
+ return conn;
+ }
+
+ /** Retrieve or create a new child process for the specified non-content process. */
+ private ChildConnection getNonContentConnection(@NonNull final GeckoProcessType type) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (type == GeckoProcessType.CONTENT) {
+ throw new IllegalArgumentException("Content processes not supported by this method");
+ }
+
+ NonContentConnection connection = mNonContentConnections.get(type);
+ if (connection == null) {
+ if (type == GeckoProcessType.SOCKET) {
+ connection = new SocketProcessConnection(mServiceAllocator);
+ } else if (type == GeckoProcessType.GPU) {
+ connection = new GpuProcessConnection(mServiceAllocator);
+ } else {
+ connection = new NonContentConnection(mServiceAllocator, type);
+ }
+
+ mNonContentConnections.put(type, connection);
+ }
+
+ return connection;
+ }
+
+ /** Retrieve a ChildConnection for the purposes of starting a new child process. */
+ public ChildConnection getConnectionForStart(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ return getContentConnectionForStart();
+ }
+
+ return getNonContentConnection(type);
+ }
+
+ /** Retrieve a ChildConnection for the purposes of preloading a new child process. */
+ public ChildConnection getConnectionForPreload(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ final ContentConnection conn = getNewContentConnection(PriorityLevel.BACKGROUND);
+ mNonStartedContentConnections.add(conn);
+ return conn;
+ }
+
+ return getNonContentConnection(type);
+ }
+ }
+
+ private final ConnectionManager mConnections;
+
+ private GeckoProcessManager() {
+ mConnections = new ConnectionManager();
+ mInstanceId = UUID.randomUUID().toString();
+ }
+
+ public void preload(final GeckoProcessType... types) {
+ XPCOMEventTarget.launcherThread()
+ .execute(
+ () -> {
+ for (final GeckoProcessType type : types) {
+ final ChildConnection connection = mConnections.getConnectionForPreload(type);
+ connection.bind();
+ }
+ });
+ }
+
+ public void crashChild(@NonNull final Selector selector) {
+ XPCOMEventTarget.launcherThread()
+ .execute(
+ () -> {
+ final ChildConnection conn = mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.bind()
+ .accept(
+ proc -> {
+ try {
+ proc.crash();
+ } catch (final RemoteException e) {
+ }
+ });
+ });
+ }
+
+ @WrapForJNI
+ private static void shutdownProcess(final Selector selector) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.unbind();
+ }
+
+ @WrapForJNI
+ private static void setProcessPriority(
+ @NonNull final Selector selector,
+ @NonNull final PriorityLevel priorityLevel,
+ final int relativeImportance) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector);
+ if (conn == null) {
+ return;
+ }
+
+ conn.setPriorityLevel(priorityLevel, relativeImportance);
+ });
+ }
+
+ @WrapForJNI
+ private static GeckoResult<Integer> start(
+ final GeckoProcessType type,
+ final String[] args,
+ final int prefsFd,
+ final int prefMapFd,
+ final int ipcFd,
+ final int crashFd,
+ final int crashAnnotationFd) {
+ final GeckoResult<Integer> result = new GeckoResult<>();
+ final StartInfo info =
+ new StartInfo(
+ type,
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .userSerialNumber(System.getenv("MOZ_ANDROID_USER_SERIAL_NUMBER"))
+ .extras(GeckoThread.getActiveExtras())
+ .flags(filterFlagsForChild(GeckoThread.getActiveFlags()))
+ .fds(
+ FileDescriptors.builder()
+ .prefs(prefsFd)
+ .prefMap(prefMapFd)
+ .ipc(ipcFd)
+ .crashReporter(crashFd)
+ .crashAnnotation(crashAnnotationFd)
+ .build())
+ .build());
+
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ INSTANCE
+ .start(info)
+ .accept(result::complete, result::completeExceptionally)
+ .finally_(info.pfds::close);
+ });
+
+ return result;
+ }
+
+ private static int filterFlagsForChild(final int flags) {
+ return flags & GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ }
+
+ private static class StartInfo {
+ final GeckoProcessType type;
+ final String crashHandler;
+ final GeckoThread.InitInfo init;
+
+ final ParcelFileDescriptors pfds;
+
+ private StartInfo(final GeckoProcessType type, final GeckoThread.InitInfo initInfo) {
+ this.type = type;
+ this.init = initInfo;
+ crashHandler =
+ GeckoAppShell.getCrashHandlerService() != null
+ ? GeckoAppShell.getCrashHandlerService().getName()
+ : null;
+ // The native side owns the File Descriptors so we cannot call adopt here.
+ pfds = ParcelFileDescriptors.from(initInfo.fds);
+ }
+ }
+
+ private static final int MAX_RETRIES = 3;
+
+ private GeckoResult<Integer> start(final StartInfo info) {
+ return start(info, new ArrayList<>());
+ }
+
+ private GeckoResult<Integer> retry(
+ final StartInfo info, final List<Throwable> retryLog, final Throwable error) {
+ retryLog.add(error);
+
+ if (error instanceof StartException) {
+ final StartException startError = (StartException) error;
+ if (startError.errorCode == IChildProcess.STARTED_BUSY) {
+ // This process is owned by a different runtime, so we can't use
+ // it. We will keep retrying indefinitely until we find a non-busy process.
+ // Note: this strategy is pretty bad, we go through each process in
+ // sequence until one works, the multiple runtime case is test-only
+ // for now, so that's ok. We can improve on this if we eventually
+ // end up needing something fancier.
+ return start(info, retryLog);
+ }
+ }
+
+ // If we couldn't unbind there's something very wrong going on and we bail
+ // immediately.
+ if (retryLog.size() >= MAX_RETRIES || error instanceof UnbindException) {
+ return GeckoResult.fromException(fromRetryLog(retryLog));
+ }
+
+ return start(info, retryLog);
+ }
+
+ private String serializeLog(final List<Throwable> retryLog) {
+ if (retryLog == null || retryLog.size() == 0) {
+ return "Empty log.";
+ }
+
+ final StringBuilder message = new StringBuilder();
+
+ for (final Throwable error : retryLog) {
+ if (error instanceof UnbindException) {
+ message.append("Could not unbind: ");
+ } else if (error instanceof StartException) {
+ message.append("Cannot restart child: ");
+ } else {
+ message.append("Error while binding: ");
+ }
+ message.append(error);
+ message.append(";");
+ }
+
+ return message.toString();
+ }
+
+ private RuntimeException fromRetryLog(final List<Throwable> retryLog) {
+ return new RuntimeException(serializeLog(retryLog), retryLog.get(retryLog.size() - 1));
+ }
+
+ private GeckoResult<Integer> start(final StartInfo info, final List<Throwable> retryLog) {
+ return startInternal(info).then(GeckoResult::fromValue, error -> retry(info, retryLog, error));
+ }
+
+ private static class StartException extends RuntimeException {
+ public final int errorCode;
+
+ public StartException(final int errorCode, final int pid) {
+ super("Could not start process, errorCode: " + errorCode + " PID: " + pid);
+ this.errorCode = errorCode;
+ }
+ }
+
+ private GeckoResult<Integer> startInternal(final StartInfo info) {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ final ChildConnection connection = mConnections.getConnectionForStart(info.type);
+ return connection
+ .bind()
+ .map(
+ child -> {
+ final int result =
+ child.start(
+ this,
+ mInstanceId,
+ info.init.args,
+ info.init.extras,
+ info.init.flags,
+ info.init.userSerialNumber,
+ info.crashHandler,
+ info.pfds.prefs,
+ info.pfds.prefMap,
+ info.pfds.ipc,
+ info.pfds.crashReporter,
+ info.pfds.crashAnnotation);
+ if (result == IChildProcess.STARTED_OK) {
+ return connection.getPid();
+ } else {
+ throw new StartException(result, connection.getPid());
+ }
+ })
+ .then(GeckoResult::fromValue, error -> handleBindError(connection, error));
+ }
+
+ private GeckoResult<Integer> handleBindError(
+ final ChildConnection connection, final Throwable error) {
+ return connection
+ .unbind()
+ .then(
+ unused -> GeckoResult.fromException(error),
+ unbindError -> GeckoResult.fromException(new UnbindException(unbindError)));
+ }
+
+ private static class UnbindException extends RuntimeException {
+ public UnbindException(final Throwable cause) {
+ super(cause);
+ }
+ }
+} // GeckoProcessManager
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java
new file mode 100644
index 0000000000..812a27614c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java
@@ -0,0 +1,40 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+@WrapForJNI
+public enum GeckoProcessType {
+ // These need to match the stringified names from the GeckoProcessType enum
+ PARENT("default"),
+ PLUGIN("plugin"),
+ CONTENT("tab"),
+ IPDLUNITTEST("ipdlunittest"),
+ GMPLUGIN("gmplugin"),
+ GPU("gpu"),
+ VR("vr"),
+ RDD("rdd"),
+ SOCKET("socket"),
+ REMOTESANDBOXBROKER("sandboxbroker"),
+ FORKSERVER("forkserver"),
+ UTILITY("utility");
+
+ private final String mGeckoName;
+
+ private GeckoProcessType(final String geckoName) {
+ mGeckoName = geckoName;
+ }
+
+ @Override
+ public String toString() {
+ return mGeckoName;
+ }
+
+ @WrapForJNI
+ private static final GeckoProcessType fromInt(final int type) {
+ return values()[type];
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java
new file mode 100644
index 0000000000..e030a47c74
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java
@@ -0,0 +1,213 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Binder;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.Process;
+import android.os.RemoteException;
+import android.util.Log;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.GeckoThread.FileDescriptors;
+import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.ICompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class GeckoServiceChildProcess extends Service {
+ private static final String LOGTAG = "ServiceChildProcess";
+
+ private static IProcessManager sProcessManager;
+ private static String sOwnerProcessId;
+ private final MemoryController mMemoryController = new MemoryController();
+
+ // Makes sure we don't reuse this process
+ private static boolean sCreateCalled;
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static void getEditableParent(
+ final IGeckoEditableChild child, final long contentId, final long tabId) {
+ try {
+ sProcessManager.getEditableParent(child, contentId, tabId);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Cannot get editable", e);
+ }
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ Log.i(LOGTAG, "onCreate");
+
+ if (sCreateCalled) {
+ // We don't support reusing processes, and this could get us in a really weird state,
+ // so let's throw here.
+ throw new RuntimeException("Cannot reuse process.");
+ }
+ sCreateCalled = true;
+
+ GeckoAppShell.setApplicationContext(getApplicationContext());
+ GeckoThread.launch(); // Preload Gecko.
+ }
+
+ protected static class ChildProcessBinder extends IChildProcess.Stub {
+ @Override
+ public int getPid() {
+ return Process.myPid();
+ }
+
+ @Override
+ public int start(
+ final IProcessManager procMan,
+ final String mainProcessId,
+ final String[] args,
+ final Bundle extras,
+ final int flags,
+ final String userSerialNumber,
+ final String crashHandlerService,
+ final ParcelFileDescriptor prefsPfd,
+ final ParcelFileDescriptor prefMapPfd,
+ final ParcelFileDescriptor ipcPfd,
+ final ParcelFileDescriptor crashReporterPfd,
+ final ParcelFileDescriptor crashAnnotationPfd) {
+
+ final ParcelFileDescriptors pfds =
+ ParcelFileDescriptors.builder()
+ .prefs(prefsPfd)
+ .prefMap(prefMapPfd)
+ .ipc(ipcPfd)
+ .crashReporter(crashReporterPfd)
+ .crashAnnotation(crashAnnotationPfd)
+ .build();
+
+ synchronized (GeckoServiceChildProcess.class) {
+ if (sOwnerProcessId != null && !sOwnerProcessId.equals(mainProcessId)) {
+ Log.w(
+ LOGTAG,
+ "This process belongs to a different GeckoRuntime owner: "
+ + sOwnerProcessId
+ + " process: "
+ + mainProcessId);
+ // We need to close the File Descriptors here otherwise we will leak them causing a
+ // shutdown hang.
+ pfds.close();
+ return IChildProcess.STARTED_BUSY;
+ }
+ if (sProcessManager != null) {
+ Log.e(LOGTAG, "Child process already started");
+ pfds.close();
+ return IChildProcess.STARTED_FAIL;
+ }
+ sProcessManager = procMan;
+ sOwnerProcessId = mainProcessId;
+ }
+
+ final FileDescriptors fds = pfds.detach();
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (crashHandlerService != null) {
+ try {
+ @SuppressWarnings("unchecked")
+ final Class<? extends Service> crashHandler =
+ (Class<? extends Service>) Class.forName(crashHandlerService);
+
+ // Native crashes are reported through pipes, so we don't have to
+ // do anything special for that.
+ GeckoAppShell.setCrashHandlerService(crashHandler);
+ GeckoAppShell.ensureCrashHandling(crashHandler);
+ } catch (final ClassNotFoundException e) {
+ Log.w(LOGTAG, "Couldn't find crash handler service " + crashHandlerService);
+ }
+ }
+
+ final GeckoThread.InitInfo info =
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .extras(extras)
+ .flags(flags)
+ .userSerialNumber(userSerialNumber)
+ .fds(fds)
+ .build();
+
+ if (GeckoThread.init(info)) {
+ GeckoThread.launch();
+ }
+ }
+ });
+ return IChildProcess.STARTED_OK;
+ }
+
+ @Override
+ public void crash() {
+ GeckoThread.crash();
+ }
+
+ @Override
+ public ICompositorSurfaceManager getCompositorSurfaceManager() {
+ Log.e(
+ LOGTAG, "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process");
+ throw new AssertionError(
+ "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process.");
+ }
+
+ @Override
+ public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) {
+ Log.e(LOGTAG, "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process");
+ throw new AssertionError(
+ "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process.");
+ }
+ }
+
+ protected Binder createBinder() {
+ return new ChildProcessBinder();
+ }
+
+ private final Binder mBinder = createBinder();
+
+ @Override
+ public void onDestroy() {
+ Log.i(LOGTAG, "Destroying GeckoServiceChildProcess");
+ System.exit(0);
+ }
+
+ @Override
+ public IBinder onBind(final Intent intent) {
+ // Calling stopSelf ensures that whenever the client unbinds the process dies immediately.
+ stopSelf();
+ return mBinder;
+ }
+
+ @Override
+ public void onTrimMemory(final int level) {
+ mMemoryController.onTrimMemory(level);
+
+ // This is currently a no-op in Service, but let's future-proof.
+ super.onTrimMemory(level);
+ }
+
+ @Override
+ public void onLowMemory() {
+ mMemoryController.onLowMemory();
+ super.onLowMemory();
+ }
+
+ /**
+ * Returns the surface allocator interface that should be used by this process to allocate
+ * Surfaces, for consumption in either the GPU process or parent process.
+ */
+ public static ISurfaceAllocator getSurfaceAllocator() throws RemoteException {
+ return sProcessManager.getSurfaceAllocator();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java
new file mode 100644
index 0000000000..e4312c7e67
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java
@@ -0,0 +1,63 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.os.Binder;
+import android.util.SparseArray;
+import android.view.Surface;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.gfx.ICompositorSurfaceManager;
+import org.mozilla.gecko.gfx.ISurfaceAllocator;
+import org.mozilla.gecko.gfx.RemoteSurfaceAllocator;
+
+public class GeckoServiceGpuProcess extends GeckoServiceChildProcess {
+ private static final String LOGTAG = "ServiceGpuProcess";
+
+ private static final class GpuProcessBinder extends GeckoServiceChildProcess.ChildProcessBinder {
+ @Override
+ public ICompositorSurfaceManager getCompositorSurfaceManager() {
+ return RemoteCompositorSurfaceManager.getInstance();
+ }
+
+ @Override
+ public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) {
+ return RemoteSurfaceAllocator.getInstance(allocatorId);
+ }
+ }
+
+ @Override
+ protected Binder createBinder() {
+ return new GpuProcessBinder();
+ }
+
+ public static final class RemoteCompositorSurfaceManager extends ICompositorSurfaceManager.Stub {
+ private static RemoteCompositorSurfaceManager mInstance;
+
+ @WrapForJNI
+ private static synchronized RemoteCompositorSurfaceManager getInstance() {
+ if (mInstance == null) {
+ mInstance = new RemoteCompositorSurfaceManager();
+ }
+ return mInstance;
+ }
+
+ private final SparseArray<Surface> mSurfaces = new SparseArray<Surface>();
+
+ @Override
+ public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) {
+ if (surface != null) {
+ mSurfaces.put(widgetId, surface);
+ } else {
+ mSurfaces.remove(widgetId);
+ }
+ }
+
+ @WrapForJNI
+ public synchronized Surface getCompositorSurface(final int widgetId) {
+ return mSurfaces.get(widgetId);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java
new file mode 100644
index 0000000000..f2dcb7a52b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java
@@ -0,0 +1,74 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.content.ComponentCallbacks2;
+import android.content.res.Configuration;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoAppShell;
+
+public class MemoryController implements ComponentCallbacks2 {
+ private static final String LOGTAG = "MemoryController";
+ private long mLastLowMemoryNotificationTime = 0;
+
+ // Allowed elapsed time between full GCs while under constant memory pressure
+ private static final long LOW_MEMORY_ONGOING_RESET_TIME_MS = 10000;
+
+ private static final int LOW = 0;
+ private static final int MODERATE = 1;
+ private static final int CRITICAL = 2;
+
+ private int memoryLevelFromTrim(final int level) {
+ if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE
+ || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) {
+ return CRITICAL;
+ } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
+ return MODERATE;
+ }
+ return LOW;
+ }
+
+ public void onTrimMemory(final int level) {
+ Log.i(LOGTAG, "onTrimMemory(" + level + ")");
+ onMemoryNotification(memoryLevelFromTrim(level));
+ }
+
+ @Override
+ public void onConfigurationChanged(final @NonNull Configuration newConfig) {}
+
+ public void onLowMemory() {
+ Log.i(LOGTAG, "onLowMemory");
+ onMemoryNotification(CRITICAL);
+ }
+
+ private void onMemoryNotification(final int level) {
+ if (level == LOW) {
+ // The trim level is too low to be actionable
+ return;
+ }
+
+ // See nsIMemory.idl for descriptions of the various arguments to the "memory-pressure"
+ // observer.
+ final String observerArg;
+
+ final long currentNotificationTime = System.currentTimeMillis();
+ if (level == CRITICAL
+ || (currentNotificationTime - mLastLowMemoryNotificationTime)
+ >= LOW_MEMORY_ONGOING_RESET_TIME_MS) {
+ // We do a full "low-memory" notification for both new and last-ditch onTrimMemory requests.
+ observerArg = "low-memory";
+ mLastLowMemoryNotificationTime = currentNotificationTime;
+ } else {
+ // If it has been less than ten seconds since the last time we sent a "low-memory"
+ // notification, we send a "low-memory-ongoing" notification instead.
+ // This prevents Gecko from re-doing full GC's repeatedly over and over in succession,
+ // as they are expensive and quickly result in diminishing returns.
+ observerArg = "low-memory-ongoing";
+ }
+
+ GeckoAppShell.notifyObservers("memory-pressure", observerArg);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java
new file mode 100644
index 0000000000..8058d71601
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java
@@ -0,0 +1,613 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.annotation.TargetApi;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import java.security.SecureRandom;
+import java.util.BitSet;
+import java.util.EnumMap;
+import java.util.HashSet;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.UUID;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.XPCOMEventTarget;
+
+/* package */ final class ServiceAllocator {
+ private static final String LOGTAG = "ServiceAllocator";
+ private static final int MAX_NUM_ISOLATED_CONTENT_SERVICES =
+ GeckoChildProcessServices.MAX_NUM_ISOLATED_CONTENT_SERVICES;
+
+ private static boolean hasQApis() {
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
+ }
+
+ /**
+ * Possible priority levels that are available to child services. Each one maps to a flag that is
+ * passed into Context.bindService().
+ */
+ @WrapForJNI
+ public static enum PriorityLevel {
+ FOREGROUND(Context.BIND_IMPORTANT),
+ BACKGROUND(0),
+ IDLE(Context.BIND_WAIVE_PRIORITY);
+
+ private final int mAndroidFlag;
+
+ private PriorityLevel(final int androidFlag) {
+ mAndroidFlag = androidFlag;
+ }
+
+ public int getAndroidFlag() {
+ return mAndroidFlag;
+ }
+ }
+
+ public static final class BindException extends RuntimeException {
+ public BindException(@NonNull final String msg) {
+ super(msg);
+ }
+ }
+
+ private interface BindServiceDelegate {
+ boolean bindService(ServiceConnection binding, PriorityLevel priority);
+
+ String getServiceName();
+ }
+
+ /**
+ * Abstract class that holds the essential per-service data that is required to work with
+ * ServiceAllocator. ServiceAllocator clients should extend this class when implementing their
+ * per-service connection objects.
+ */
+ public abstract static class InstanceInfo {
+ private class Binding implements ServiceConnection {
+ /**
+ * This implementation of ServiceConnection.onServiceConnected simply bounces the connection
+ * notification over to the launcher thread (if it is not already on it).
+ */
+ @Override
+ public final void onServiceConnected(final ComponentName name, final IBinder service) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ onBinderConnectedInternal(service);
+ });
+ }
+
+ /**
+ * This implementation of ServiceConnection.onServiceDisconnected simply bounces the
+ * disconnection notification over to the launcher thread (if it is not already on it).
+ */
+ @Override
+ public final void onServiceDisconnected(final ComponentName name) {
+ XPCOMEventTarget.runOnLauncherThread(
+ () -> {
+ onBinderConnectionLostInternal();
+ });
+ }
+ }
+
+ private class DefaultBindDelegate implements BindServiceDelegate {
+ @Override
+ public boolean bindService(
+ @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent intent = new Intent();
+ intent.setClassName(context, getServiceName());
+ return bindServiceDefault(context, intent, binding, getAndroidFlags(priority));
+ }
+
+ @Override
+ public String getServiceName() {
+ return getSvcClassNameDefault(InstanceInfo.this);
+ }
+ }
+
+ private class IsolatedBindDelegate implements BindServiceDelegate {
+ @Override
+ public boolean bindService(
+ @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent intent = new Intent();
+ intent.setClassName(context, getServiceName());
+ return bindServiceIsolated(
+ context, intent, getAndroidFlags(priority), getIdInternal(), binding);
+ }
+
+ @Override
+ public String getServiceName() {
+ return ServiceUtils.buildIsolatedSvcName(getType());
+ }
+ }
+
+ private final ServiceAllocator mAllocator;
+ private final GeckoProcessType mType;
+ private final String mId;
+ private final EnumMap<PriorityLevel, Binding> mBindings;
+ private final BindServiceDelegate mBindDelegate;
+
+ private boolean mCalledConnected = false;
+ private boolean mCalledConnectionLost = false;
+ private boolean mIsDefunct = false;
+
+ private PriorityLevel mCurrentPriority;
+ private int mRelativeImportance = 0;
+
+ protected InstanceInfo(
+ @NonNull final ServiceAllocator allocator,
+ @NonNull final GeckoProcessType type,
+ @NonNull final PriorityLevel initialPriority) {
+ mAllocator = allocator;
+ mType = type;
+ mId = mAllocator.allocate(type);
+ mBindings = new EnumMap<PriorityLevel, Binding>(PriorityLevel.class);
+ mBindDelegate = getBindServiceDelegate();
+
+ mCurrentPriority = initialPriority;
+ }
+
+ private BindServiceDelegate getBindServiceDelegate() {
+ if (mType != GeckoProcessType.CONTENT) {
+ // Non-content services just use default binding
+ return this.new DefaultBindDelegate();
+ }
+
+ // Content services defer to the alloc policy
+ return mAllocator.mContentAllocPolicy.getBindServiceDelegate(this);
+ }
+
+ public PriorityLevel getPriorityLevel() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ return mCurrentPriority;
+ }
+
+ public boolean setPriorityLevel(@NonNull final PriorityLevel newPriority) {
+ return setPriorityLevel(newPriority, 0);
+ }
+
+ public boolean setPriorityLevel(
+ @NonNull final PriorityLevel newPriority, final int relativeImportance) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ mCurrentPriority = newPriority;
+ mRelativeImportance = relativeImportance;
+
+ // If we haven't bound yet then we can just return
+ if (mBindings.size() == 0) {
+ return true;
+ }
+
+ // Otherwise we need to update our bindings
+ return updateBindings();
+ }
+
+ /**
+ * Only content services have unique IDs. This method throws if called for a non-content service
+ * type.
+ */
+ public String getId() {
+ if (mId == null) {
+ throw new RuntimeException("This service does not have a unique id");
+ }
+
+ return mId;
+ }
+
+ /** This method is infallible and returns an empty string for non-content services. */
+ private String getIdInternal() {
+ return mId == null ? "" : mId;
+ }
+
+ public boolean isContent() {
+ return mType == GeckoProcessType.CONTENT;
+ }
+
+ public GeckoProcessType getType() {
+ return mType;
+ }
+
+ protected boolean bindService() {
+ if (mIsDefunct) {
+ final String errorMsg =
+ "Attempt to bind a defunct InstanceInfo for " + mType + " child process";
+ throw new BindException(errorMsg);
+ }
+
+ return updateBindings();
+ }
+
+ /**
+ * Unbinds the service described by |this| and releases our unique ID. This method may safely be
+ * called multiple times even if we are already defunct.
+ */
+ protected void unbindService() {
+ XPCOMEventTarget.assertOnLauncherThread();
+
+ // This could happen if a service death races with our attempt to shut it down.
+ if (mIsDefunct) {
+ return;
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ // Make a clone of mBindings to iterate over since we're going to mutate the original
+ final EnumMap<PriorityLevel, Binding> cloned = mBindings.clone();
+ for (final Entry<PriorityLevel, Binding> entry : cloned.entrySet()) {
+ try {
+ context.unbindService(entry.getValue());
+ } catch (final IllegalArgumentException e) {
+ // The binding was already dead. That's okay.
+ }
+
+ mBindings.remove(entry.getKey());
+ }
+
+ if (mBindings.size() != 0) {
+ throw new IllegalStateException("Unable to release all bindings");
+ }
+
+ mIsDefunct = true;
+ mAllocator.release(this);
+ onReleaseResources();
+ }
+
+ private void onBinderConnectedInternal(@NonNull final IBinder service) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ // We only care about the first time this is called; subsequent bindings can be ignored.
+ if (mCalledConnected) {
+ return;
+ }
+
+ mCalledConnected = true;
+
+ onBinderConnected(service);
+ }
+
+ private void onBinderConnectionLostInternal() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ // We only care about the first time this is called; subsequent connection errors can be
+ // ignored.
+ if (mCalledConnectionLost) {
+ return;
+ }
+
+ mCalledConnectionLost = true;
+
+ onBinderConnectionLost();
+ }
+
+ protected abstract void onBinderConnected(@NonNull final IBinder service);
+
+ protected abstract void onReleaseResources();
+
+ // Optionally overridable by subclasses, but this is a sane default
+ protected void onBinderConnectionLost() {
+ // The binding has lost its connection, but the binding itself might still be active.
+ // Gecko itself will request a process restart, so here we attempt to unbind so that
+ // Android does not try to automatically restart and reconnect the service.
+ unbindService();
+ }
+
+ /**
+ * This function relies on the fact that the PriorityLevel enum is ordered from highest priority
+ * to lowest priority. We examine the ordinal of the current priority setting, and then iterate
+ * across all possible priority levels, adjusting as necessary. Any priority levels whose
+ * ordinals are less than then current priority level ordinal must be unbound, while all
+ * priority levels whose ordinals are greater than or equal to the current priority level
+ * ordinal must be bound.
+ */
+ @TargetApi(29)
+ private boolean updateBindings() {
+ XPCOMEventTarget.assertOnLauncherThread();
+ int numBindSuccesses = 0;
+ int numBindFailures = 0;
+ int numUnbindSuccesses = 0;
+
+ final Context context = GeckoAppShell.getApplicationContext();
+
+ // This code assumes that the order of the PriorityLevel enum is highest to lowest
+ final int curPriorityOrdinal = mCurrentPriority.ordinal();
+ final PriorityLevel[] levels = PriorityLevel.values();
+
+ for (int curLevelIdx = 0; curLevelIdx < levels.length; ++curLevelIdx) {
+ final PriorityLevel curLevel = levels[curLevelIdx];
+ final Binding existingBinding = mBindings.get(curLevel);
+ final boolean hasExistingBinding = existingBinding != null;
+
+ if (curLevelIdx < curPriorityOrdinal) {
+ // Remove if present
+ if (hasExistingBinding) {
+ try {
+ context.unbindService(existingBinding);
+ ++numUnbindSuccesses;
+ mBindings.remove(curLevel);
+ } catch (final IllegalArgumentException e) {
+ // The binding was already dead. That's okay.
+ ++numUnbindSuccesses;
+ mBindings.remove(curLevel);
+ }
+ }
+ } else {
+ // Normally we only need to do a bind if we do not yet have an existing binding
+ // for this priority level.
+ boolean bindNeeded = !hasExistingBinding;
+
+ // We only update the service group when the binding for this level already
+ // exists and no binds have occurred yet during the current updateBindings call.
+ if (hasExistingBinding && hasQApis() && (numBindSuccesses + numBindFailures) == 0) {
+ // NB: Right now we're passing 0 as the |group| argument, indicating that
+ // the process is not grouped with any other processes. Once we support
+ // Fission we should re-evaluate this.
+ context.updateServiceGroup(existingBinding, 0, mRelativeImportance);
+ // Now we need to call bindService with the existing binding to make this
+ // change take effect.
+ bindNeeded = true;
+ }
+
+ if (bindNeeded) {
+ final Binding useBinding = hasExistingBinding ? existingBinding : this.new Binding();
+ if (mBindDelegate.bindService(useBinding, curLevel)) {
+ ++numBindSuccesses;
+ if (!hasExistingBinding) {
+ mBindings.put(curLevel, useBinding);
+ }
+ } else {
+ ++numBindFailures;
+ }
+ }
+ }
+ }
+
+ final String svcName = mBindDelegate.getServiceName();
+ final StringBuilder builder = new StringBuilder(svcName);
+ builder
+ .append(" updateBindings: ")
+ .append(mCurrentPriority)
+ .append(" priority, ")
+ .append(mRelativeImportance)
+ .append(" importance, ")
+ .append(numBindSuccesses)
+ .append(" successful binds, ")
+ .append(numBindFailures)
+ .append(" failed binds, ")
+ .append(numUnbindSuccesses)
+ .append(" successful unbinds");
+ Log.d(LOGTAG, builder.toString());
+
+ return numBindFailures == 0;
+ }
+ }
+
+ private interface ContentAllocationPolicy {
+ /**
+ * @return BindServiceDelegate that will be used for binding a new content service.
+ */
+ BindServiceDelegate getBindServiceDelegate(InstanceInfo info);
+
+ /**
+ * Allocate an unused service ID for use by the caller.
+ *
+ * @return The new service id.
+ */
+ String allocate();
+
+ /**
+ * Release a previously used service ID.
+ *
+ * @param id The service id being released.
+ */
+ void release(final String id);
+ }
+
+ /**
+ * This policy is intended for Android versions &lt; 10, as well as for content process services
+ * that are not defined as isolated processes. In this case, the number of possible content
+ * service IDs has a fixed upper bound, so we use a BitSet to manage their allocation.
+ */
+ private static final class DefaultContentPolicy implements ContentAllocationPolicy {
+ private final int mMaxNumSvcs;
+ private final BitSet mAllocator;
+ private final SecureRandom mRandom;
+
+ public DefaultContentPolicy() {
+ mMaxNumSvcs = getContentServiceCount();
+ mAllocator = new BitSet(mMaxNumSvcs);
+ mRandom = new SecureRandom();
+ }
+
+ @Override
+ public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) {
+ return info.new DefaultBindDelegate();
+ }
+
+ @Override
+ public String allocate() {
+ final int[] available = new int[mMaxNumSvcs];
+ int size = 0;
+ for (int i = 0; i < mMaxNumSvcs; i++) {
+ if (!mAllocator.get(i)) {
+ available[size] = i;
+ size++;
+ }
+ }
+
+ if (size == 0) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ final int next = available[mRandom.nextInt(size)];
+ mAllocator.set(next);
+ return Integer.toString(next);
+ }
+
+ @Override
+ public void release(final String stringId) {
+ final int id = Integer.valueOf(stringId);
+ if (!mAllocator.get(id)) {
+ throw new IllegalStateException("Releasing an unallocated id=" + id);
+ }
+
+ mAllocator.clear(id);
+ }
+
+ /**
+ * @return The number of content services defined in our manifest.
+ */
+ private static int getContentServiceCount() {
+ return ServiceUtils.getServiceCount(
+ GeckoAppShell.getApplicationContext(), GeckoProcessType.CONTENT);
+ }
+ }
+
+ /**
+ * This policy is intended for Android versions &gt;= 10 when our content process services are
+ * defined in our manifest as having isolated processes. Since isolated services share a single
+ * service definition, there is no longer an Android-induced hard limit on the number of content
+ * processes that may be started. We simply use a monotonically-increasing counter to generate
+ * unique instance IDs in this case.
+ */
+ private static final class IsolatedContentPolicy implements ContentAllocationPolicy {
+ private final Set<String> mRunningServiceIds = new HashSet<>();
+
+ @Override
+ public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) {
+ return info.new IsolatedBindDelegate();
+ }
+
+ /**
+ * We generate a new instance ID simply by incrementing a counter. We do track how many content
+ * services are currently active for the purposes of maintaining the configured limit on number
+ * of simultaneous content processes.
+ */
+ @Override
+ public String allocate() {
+ if (mRunningServiceIds.size() >= MAX_NUM_ISOLATED_CONTENT_SERVICES) {
+ throw new RuntimeException("No more content services available");
+ }
+
+ final String newId = UUID.randomUUID().toString();
+ mRunningServiceIds.add(newId);
+ return newId;
+ }
+
+ /** Just drop the count of active services. */
+ @Override
+ public void release(final String id) {
+ if (!mRunningServiceIds.remove(id)) {
+ throw new IllegalStateException("Releasing an unallocated id");
+ }
+ }
+ }
+
+ /** The policy used for allocating content processes. */
+ private ContentAllocationPolicy mContentAllocPolicy = null;
+
+ /**
+ * Allocate a service ID.
+ *
+ * @param type The type of service.
+ * @return Integer encapsulating the service ID, or null if no ID is necessary.
+ */
+ private String allocate(@NonNull final GeckoProcessType type) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (type != GeckoProcessType.CONTENT) {
+ // No unique id necessary
+ return null;
+ }
+
+ // Lazy initialization of mContentAllocPolicy to ensure that it is constructed on the
+ // launcher thread.
+ if (mContentAllocPolicy == null) {
+ if (canBindIsolated(GeckoProcessType.CONTENT)) {
+ mContentAllocPolicy = new IsolatedContentPolicy();
+ } else {
+ mContentAllocPolicy = new DefaultContentPolicy();
+ }
+ }
+
+ return mContentAllocPolicy.allocate();
+ }
+
+ /**
+ * Free a defunct service's ID if necessary.
+ *
+ * @param info The InstanceInfo-derived object that contains essential information for tearing
+ * down the child service.
+ */
+ private void release(@NonNull final InstanceInfo info) {
+ XPCOMEventTarget.assertOnLauncherThread();
+ if (!info.isContent()) {
+ return;
+ }
+
+ mContentAllocPolicy.release(info.getId());
+ }
+
+ /**
+ * Find out whether the desired service type is defined in our manifest as having an isolated
+ * process.
+ *
+ * @param type Service type to query
+ * @return true if this service type may use isolated binding, otherwise false.
+ */
+ private static boolean canBindIsolated(@NonNull final GeckoProcessType type) {
+ if (!hasQApis()) {
+ return false;
+ }
+
+ final Context context = GeckoAppShell.getApplicationContext();
+ final int svcFlags = ServiceUtils.getServiceFlags(context, type);
+ return (svcFlags & ServiceInfo.FLAG_ISOLATED_PROCESS) != 0;
+ }
+
+ /** Convert PriorityLevel into the flags argument to Context.bindService() et al */
+ private static int getAndroidFlags(@NonNull final PriorityLevel priority) {
+ return Context.BIND_AUTO_CREATE | priority.getAndroidFlag();
+ }
+
+ /** Obtain the class name to use for service binding in the default (ie, non-isolated) case. */
+ private static String getSvcClassNameDefault(@NonNull final InstanceInfo info) {
+ return ServiceUtils.buildSvcName(info.getType(), info.getIdInternal());
+ }
+
+ /**
+ * Wrapper for bindService() that utilizes the Context.bindService() overload that accepts an
+ * Executor argument, when available. Otherwise it falls back to the legacy overload.
+ */
+ @TargetApi(29)
+ private static boolean bindServiceDefault(
+ @NonNull final Context context,
+ @NonNull final Intent intent,
+ @NonNull final ServiceConnection conn,
+ final int flags) {
+ if (hasQApis()) {
+ // We always specify the launcher thread as our Executor.
+ return context.bindService(intent, flags, XPCOMEventTarget.launcherThread(), conn);
+ }
+
+ return context.bindService(intent, conn, flags);
+ }
+
+ @TargetApi(29)
+ private static boolean bindServiceIsolated(
+ @NonNull final Context context,
+ @NonNull final Intent intent,
+ final int flags,
+ @NonNull final String instanceId,
+ @NonNull final ServiceConnection conn) {
+ // We always specify the launcher thread as our Executor.
+ return context.bindIsolatedService(
+ intent, flags, instanceId, XPCOMEventTarget.launcherThread(), conn);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java
new file mode 100644
index 0000000000..695c69666b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java
@@ -0,0 +1,141 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.process;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ServiceInfo;
+import androidx.annotation.NonNull;
+
+/* package */ final class ServiceUtils {
+ private static final String DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX = "0";
+
+ private ServiceUtils() {}
+
+ /**
+ * @return StringBuilder containing the name of a service class but not qualifed with any unique
+ * identifiers.
+ */
+ private static StringBuilder startSvcName(@NonNull final GeckoProcessType type) {
+ final StringBuilder builder = new StringBuilder(GeckoChildProcessServices.class.getName());
+ builder.append("$").append(type);
+ return builder;
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the name of its class, including any qualifiers that
+ * are needed to uniquely identify its manifest definition.
+ */
+ public static String buildSvcName(
+ @NonNull final GeckoProcessType type, final String... suffixes) {
+ final StringBuilder builder = startSvcName(type);
+
+ for (final String suffix : suffixes) {
+ builder.append(suffix);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the name of its class to be used for the purpose of
+ * binding as an isolated service.
+ *
+ * <p>Content services are defined in the manifest as "tab0" through "tabN" for some value of N.
+ * For the purposes of binding to an isolated content service, we simply need to repeatedly re-use
+ * the definition of "tab0", the "0" being stored as the
+ * DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX constant.
+ */
+ public static String buildIsolatedSvcName(@NonNull final GeckoProcessType type) {
+ if (type == GeckoProcessType.CONTENT) {
+ return buildSvcName(type, DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX);
+ }
+
+ // Non-content services do not require any unique IDs
+ return buildSvcName(type);
+ }
+
+ /**
+ * Given a service's GeckoProcessType, obtain the unqualified name of its class.
+ *
+ * @return The name of the class that hosts the implementation of the service corresponding to
+ * type, but without any unique identifiers that may be required to actually instantiate it.
+ */
+ private static String buildSvcNamePrefix(@NonNull final GeckoProcessType type) {
+ return startSvcName(type).toString();
+ }
+
+ /**
+ * Extracts flags from the manifest definition of a service.
+ *
+ * @param context Context to use for extraction
+ * @param type Service type
+ * @return flags that are specified in the service's definition in our manifest.
+ * @see android.content.pm.ServiceInfo for explanation of the various flags.
+ */
+ public static int getServiceFlags(
+ @NonNull final Context context, @NonNull final GeckoProcessType type) {
+ final ComponentName component = new ComponentName(context, buildIsolatedSvcName(type));
+ final PackageManager pkgMgr = context.getPackageManager();
+
+ try {
+ final ServiceInfo svcInfo = pkgMgr.getServiceInfo(component, 0);
+ // svcInfo is never null
+ return svcInfo.flags;
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Obtain the list of all services defined for |context|. */
+ private static ServiceInfo[] getServiceList(@NonNull final Context context) {
+ final PackageInfo packageInfo;
+ try {
+ packageInfo =
+ context
+ .getPackageManager()
+ .getPackageInfo(context.getPackageName(), PackageManager.GET_SERVICES);
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new AssertionError("Should not happen: Can't get package info of own package");
+ }
+ return packageInfo.services;
+ }
+
+ /**
+ * Count the number of service definitions in our manifest that satisfy bindings for a particular
+ * service type.
+ *
+ * @param context Context object to use for extracting the service definitions
+ * @param type The type of service to count
+ * @return The number of available service definitions.
+ */
+ public static int getServiceCount(
+ @NonNull final Context context, @NonNull final GeckoProcessType type) {
+ final ServiceInfo[] svcList = getServiceList(context);
+ final String serviceNamePrefix = buildSvcNamePrefix(type);
+
+ int result = 0;
+ for (final ServiceInfo svc : svcList) {
+ final String svcName = svc.name;
+ // If svcName starts with serviceNamePrefix, then both strings must either be equal
+ // or else the first subsequent character in svcName must be a digit.
+ // This guards against any future GeckoProcessType whose string representation shares
+ // a common prefix with another GeckoProcessType value.
+ if (svcName.startsWith(serviceNamePrefix)
+ && (svcName.length() == serviceNamePrefix.length()
+ || Character.isDigit(svcName.codePointAt(serviceNamePrefix.length())))) {
+ ++result;
+ }
+ }
+
+ if (result <= 0) {
+ throw new RuntimeException("Could not count " + serviceNamePrefix + " services in manifest");
+ }
+
+ return result;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
new file mode 100644
index 0000000000..b8d7ea3107
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java
@@ -0,0 +1,21 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+@RobocopTarget
+public interface BundleEventListener {
+ /**
+ * Handles a message sent from Gecko.
+ *
+ * @param event The name of the event being sent.
+ * @param message The message data.
+ * @param callback The callback interface for this message. A callback is provided only if the
+ * originating call included a callback argument; otherwise, callback will be null.
+ */
+ void handleMessage(String event, GeckoBundle message, EventCallback callback);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java
new file mode 100644
index 0000000000..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<String, Object> prefs;
+ protected Map<String, String> env;
+ protected List<String> args;
+
+ public static class ConfigException extends RuntimeException {
+ public ConfigException(final String message) {
+ super(message);
+ }
+ }
+
+ public static @NonNull DebugConfig fromFile(final @NonNull File configFile)
+ throws FileNotFoundException {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
+ // There are a lot of problems with SnakeYaml on older version let's just bail.
+ throw new ConfigException("Config version is only supported for SDK_INT >= 21.");
+ }
+
+ final 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<String, String> entry : env.entrySet()) {
+ result.putString("env" + c, entry.getKey() + "=" + entry.getValue());
+ c += 1;
+ }
+
+ return result;
+ }
+
+ @Nullable
+ public String[] mergeIntoArgs(final @Nullable String[] initArgs) {
+ if (args == null) {
+ return initArgs;
+ }
+
+ Log.d(LOGTAG, "Adding arguments from debug config: " + args);
+
+ final ArrayList<String> combinedArgs = new ArrayList<>();
+ if (initArgs != null) {
+ combinedArgs.addAll(Arrays.asList(initArgs));
+ }
+ combinedArgs.addAll(args);
+
+ return combinedArgs.toArray(new String[combinedArgs.size()]);
+ }
+
+ @Nullable
+ public Map<String, Object> mergeIntoPrefs(final @Nullable Map<String, Object> initPrefs) {
+ if (prefs == null) {
+ return initPrefs;
+ }
+
+ Log.d(LOGTAG, "Adding prefs from debug config: " + prefs);
+
+ final Map<String, Object> combinedPrefs = new HashMap<>();
+ if (initPrefs != null) {
+ combinedPrefs.putAll(initPrefs);
+ }
+ combinedPrefs.putAll(prefs);
+
+ return Collections.unmodifiableMap(combinedPrefs);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
new file mode 100644
index 0000000000..3ef469ac1b
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java
@@ -0,0 +1,58 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import androidx.annotation.Nullable;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.geckoview.GeckoResult;
+
+/**
+ * Callback interface for Gecko requests.
+ *
+ * <p>For each instance of EventCallback, exactly one of sendResponse, sendError, or sendCancel must
+ * be called to prevent observer leaks. If more than one send* method is called, or if a single send
+ * method is called multiple times, an {@link IllegalStateException} will be thrown.
+ */
+@RobocopTarget
+@WrapForJNI(calledFrom = "gecko")
+public interface EventCallback {
+ /**
+ * Sends a success response with the given data.
+ *
+ * @param response The response data to send to Gecko. Can be any of the types accepted by
+ * JSONObject#put(String, Object).
+ */
+ void sendSuccess(Object response);
+
+ /**
+ * Sends an error response with the given data.
+ *
+ * @param response The response data to send to Gecko. Can be any of the types accepted by
+ * JSONObject#put(String, Object).
+ */
+ void sendError(Object response);
+
+ /**
+ * Resolve this Event callback with the result from the {@link GeckoResult}.
+ *
+ * @param response the result that will be used for this callback.
+ */
+ default <T> void resolveTo(final @Nullable GeckoResult<T> response) {
+ if (response == null) {
+ sendSuccess(null);
+ return;
+ }
+ response.accept(
+ this::sendSuccess,
+ throwable -> {
+ // Don't propagate Errors, just crash
+ if (!(throwable instanceof Exception)) {
+ throw new GeckoResult.UncaughtException(throwable);
+ }
+ sendError(throwable.getMessage());
+ });
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
new file mode 100644
index 0000000000..01b177fe21
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java
@@ -0,0 +1,72 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.os.Handler;
+import android.os.Looper;
+
+final class GeckoBackgroundThread extends Thread {
+ private static final String LOOPER_NAME = "GeckoBackgroundThread";
+
+ // Guarded by 'GeckoBackgroundThread.class'.
+ private static Handler handler;
+ private static Thread thread;
+
+ // The initial Runnable to run on the new thread. Its purpose
+ // is to avoid us having to wait for the new thread to start.
+ private Runnable mInitialRunnable;
+
+ // Singleton, so private constructor.
+ private GeckoBackgroundThread(final Runnable initialRunnable) {
+ mInitialRunnable = initialRunnable;
+ }
+
+ @Override
+ public void run() {
+ setName(LOOPER_NAME);
+ Looper.prepare();
+
+ synchronized (GeckoBackgroundThread.class) {
+ handler = new Handler();
+ GeckoBackgroundThread.class.notifyAll();
+ }
+
+ if (mInitialRunnable != null) {
+ mInitialRunnable.run();
+ mInitialRunnable = null;
+ }
+
+ Looper.loop();
+ }
+
+ private static void startThread(final Runnable initialRunnable) {
+ thread = new GeckoBackgroundThread(initialRunnable);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ // Get a Handler for a looper thread, or create one if it doesn't yet exist.
+ /*package*/ static synchronized Handler getHandler() {
+ if (thread == null) {
+ startThread(null);
+ }
+
+ while (handler == null) {
+ try {
+ GeckoBackgroundThread.class.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ return handler;
+ }
+
+ /*package*/ static synchronized void post(final Runnable runnable) {
+ if (thread == null) {
+ startThread(runnable);
+ return;
+ }
+ getHandler().post(runnable);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java
new file mode 100644
index 0000000000..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<String, Object> mMap;
+
+ /** Construct an empty GeckoBundle. */
+ public GeckoBundle() {
+ mMap = new SimpleArrayMap<>();
+ }
+
+ /**
+ * Construct an empty GeckoBundle with specific capacity.
+ *
+ * @param capacity Initial capacity.
+ */
+ public GeckoBundle(final int capacity) {
+ mMap = new SimpleArrayMap<>(capacity);
+ }
+
+ /**
+ * Construct a copy of another GeckoBundle.
+ *
+ * @param bundle GeckoBundle to copy from.
+ */
+ public GeckoBundle(final GeckoBundle bundle) {
+ mMap = new SimpleArrayMap<>(bundle.mMap);
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private GeckoBundle(final String[] keys, final Object[] values) {
+ final int len = keys.length;
+ mMap = new SimpleArrayMap<>(len);
+ for (int i = 0; i < len; i++) {
+ mMap.put(keys[i], values[i]);
+ }
+ }
+
+ /** Clear all mappings. */
+ public void clear() {
+ mMap.clear();
+ }
+
+ /**
+ * Returns whether a mapping exists. Null String, Bundle, or arrays are treated as nonexistent.
+ *
+ * @param key Key to look for.
+ * @return True if the specified key exists and the value is not null.
+ */
+ public boolean containsKey(final String key) {
+ return mMap.get(key) != null;
+ }
+
+ /**
+ * Returns the value associated with a mapping as an Object.
+ *
+ * @param key Key to look for.
+ * @return Mapping value or null if the mapping does not exist.
+ */
+ public Object get(final String key) {
+ return mMap.get(key);
+ }
+
+ /**
+ * Returns the value associated with a boolean mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Boolean value
+ */
+ public boolean getBoolean(final String key, final boolean defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : (Boolean) value;
+ }
+
+ /**
+ * Returns the value associated with a boolean mapping, or false if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean value
+ */
+ public boolean getBoolean(final String key) {
+ return getBoolean(key, false);
+ }
+
+ /**
+ * Returns the value associated with a Boolean mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Boolean value
+ */
+ public Boolean getBooleanObject(final String key, final Boolean defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : (Boolean) value;
+ }
+
+ /**
+ * Returns the value associated with a Boolean mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean value
+ */
+ public Boolean getBooleanObject(final String key) {
+ return getBooleanObject(key, null);
+ }
+
+ /**
+ * Returns the value associated with a boolean array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return Boolean array value
+ */
+ public boolean[] getBooleanArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0 ? EMPTY_BOOLEAN_ARRAY : (boolean[]) value;
+ }
+
+ /**
+ * Returns the value associated with a double mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Double value
+ */
+ public double getDouble(final String key, final double defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Number) value).doubleValue();
+ }
+
+ /**
+ * Returns the value associated with a double mapping, or 0.0 if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Double value
+ */
+ public double getDouble(final String key) {
+ return getDouble(key, 0.0);
+ }
+
+ private static double[] getDoubleArray(final int[] array) {
+ final int len = array.length;
+ final double[] ret = new double[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = (double) array[i];
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value associated with a double array mapping, or null if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @return Double array value
+ */
+ public double[] getDoubleArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_DOUBLE_ARRAY
+ : value instanceof int[] ? getDoubleArray((int[]) value) : (double[]) value;
+ }
+
+ /**
+ * Returns the value associated with an int mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Int value
+ */
+ public int getInt(final String key, final int defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Number) value).intValue();
+ }
+
+ /**
+ * Returns the value associated with an int mapping, or 0 if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int value
+ */
+ public int getInt(final String key) {
+ return getInt(key, 0);
+ }
+
+ /**
+ * Returns the value associated with an Integer mapping, or defaultValue if the mapping does not
+ * exist.
+ *
+ * @param key Key to look for.
+ * @param defaultValue Value to return if mapping does not exist.
+ * @return Int value
+ */
+ public Integer getInteger(final String key, final Integer defaultValue) {
+ final Object value = mMap.get(key);
+ return value == null ? defaultValue : ((Integer) value);
+ }
+
+ /**
+ * Returns the value associated with an Integer mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int value
+ */
+ public Integer getInteger(final String key) {
+ return getInteger(key, null);
+ }
+
+ private static int[] getIntArray(final double[] array) {
+ final int len = array.length;
+ final int[] ret = new int[len];
+ for (int i = 0; i < len; i++) {
+ ret[i] = (int) array[i];
+ }
+ return ret;
+ }
+
+ /**
+ * Returns the value associated with an int array mapping, or null if the mapping does not exist.
+ *
+ * @param key Key to look for.
+ * @return Int array value
+ */
+ public int[] getIntArray(final String key) {
+ final Object value = mMap.get(key);
+ return value == null
+ ? null
+ : Array.getLength(value) == 0
+ ? EMPTY_INT_ARRAY
+ : value instanceof double[] ? getIntArray((double[]) value) : (int[]) value;
+ }
+
+ /**
+ * Returns the value associated with an 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<Boolean> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final boolean[] array = new boolean[value.size()];
+ int i = 0;
+ for (final Boolean element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a double value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDouble(final String key, final double value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final double[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final Double[] value) {
+ putDoubleArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putDoubleArray(final String key, final Collection<Double> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.size()];
+ int i = 0;
+ for (final Double element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to an int value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putInt(final String key, final int value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to an int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final int[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final Integer[] value) {
+ putIntArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a int array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putIntArray(final String key, final Collection<Integer> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final int[] array = new int[value.size()];
+ int i = 0;
+ for (final Integer element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a long value stored as a double value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLong(final String key, final long value) {
+ mMap.put(key, (double) value);
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final long[] value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.length];
+ for (int i = 0; i < value.length; i++) {
+ array[i] = (double) value[i];
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final Long[] value) {
+ putLongArray(key, Arrays.asList(value));
+ }
+
+ /**
+ * Map a key to a long array value stored as a double array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putLongArray(final String key, final Collection<Long> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final double[] array = new double[value.size()];
+ int i = 0;
+ for (final Long element : value) {
+ array[i++] = (double) element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a String value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putString(final String key, final String value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a String array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putStringArray(final String key, final String[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a String array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putStringArray(final String key, final Collection<String> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final String[] array = new String[value.size()];
+ int i = 0;
+ for (final String element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Map a key to a GeckoBundle value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundle(final String key, final GeckoBundle value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a GeckoBundle array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundleArray(final String key, final GeckoBundle[] value) {
+ mMap.put(key, value);
+ }
+
+ /**
+ * Map a key to a GeckoBundle array value.
+ *
+ * @param key Key to map.
+ * @param value Value to map to.
+ */
+ public void putBundleArray(final String key, final Collection<GeckoBundle> value) {
+ if (value == null) {
+ mMap.put(key, null);
+ return;
+ }
+ final GeckoBundle[] array = new GeckoBundle[value.size()];
+ int i = 0;
+ for (final GeckoBundle element : value) {
+ array[i++] = element;
+ }
+ mMap.put(key, array);
+ }
+
+ /**
+ * Remove a mapping.
+ *
+ * @param key Key to remove.
+ */
+ public void remove(final String key) {
+ mMap.remove(key);
+ }
+
+ /**
+ * Returns number of mappings in this GeckoBundle.
+ *
+ * @return Number of mappings.
+ */
+ public int size() {
+ return mMap.size();
+ }
+
+ private static Object normalizeValue(final Object value) {
+ if (value instanceof Integer) {
+ // We treat int and double as the same type.
+ return ((Integer) value).doubleValue();
+
+ } else if (value instanceof int[]) {
+ // We treat int[] and double[] as the same type.
+ final int[] array = (int[]) value;
+ return array.length == 0 ? EMPTY_STRING_ARRAY : getDoubleArray(array);
+
+ } else if (value != null && value.getClass().isArray()) {
+ // We treat arrays of all nulls as the same type, including empty arrays.
+ final int len = Array.getLength(value);
+ for (int i = 0; i < len; i++) {
+ if (Array.get(value, i) != null) {
+ return value;
+ }
+ }
+ return len == 0 ? EMPTY_STRING_ARRAY : new String[len];
+ }
+ return value;
+ }
+
+ @Override // Object
+ public boolean equals(final Object other) {
+ if (!(other instanceof GeckoBundle)) {
+ return false;
+ }
+
+ // Support library's SimpleArrayMap.equals is buggy, so roll our own version.
+ final SimpleArrayMap<String, Object> otherMap = ((GeckoBundle) other).mMap;
+ if (mMap == otherMap) {
+ return true;
+ }
+ if (mMap.size() != otherMap.size()) {
+ return false;
+ }
+
+ for (int i = 0; i < mMap.size(); i++) {
+ final String thisKey = mMap.keyAt(i);
+ final int otherKey = otherMap.indexOfKey(thisKey);
+ if (otherKey < 0) {
+ return false;
+ }
+ final Object thisValue = normalizeValue(mMap.valueAt(i));
+ final Object otherValue = normalizeValue(otherMap.valueAt(otherKey));
+ if (thisValue == otherValue) {
+ continue;
+ } else if (thisValue == null || otherValue == null) {
+ return false;
+ }
+
+ final Class<?> thisClass = thisValue.getClass();
+ final Class<?> otherClass = otherValue.getClass();
+ if (thisClass != otherClass && !thisClass.equals(otherClass)) {
+ return false;
+ } else if (!thisClass.isArray()) {
+ if (!thisValue.equals(otherValue)) {
+ return false;
+ }
+ continue;
+ }
+
+ // Work with both primitive arrays and Object arrays, unlike Arrays.equals().
+ final int thisLen = Array.getLength(thisValue);
+ final int otherLen = Array.getLength(otherValue);
+ if (thisLen != otherLen) {
+ return false;
+ }
+ for (int j = 0; j < thisLen; j++) {
+ final Object thisElem = Array.get(thisValue, j);
+ final Object otherElem = Array.get(otherValue, j);
+ if (thisElem != otherElem
+ && (thisElem == null || otherElem == null || !thisElem.equals(otherElem))) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ @Override // Object
+ public int hashCode() {
+ return mMap.hashCode();
+ }
+
+ @Override // Object
+ public String toString() {
+ return mMap.toString();
+ }
+
+ public JSONObject toJSONObject() throws JSONException {
+ final JSONObject out = new JSONObject();
+ for (int i = 0; i < mMap.size(); i++) {
+ final Object value = mMap.valueAt(i);
+ final Object jsonValue;
+
+ if (value instanceof GeckoBundle) {
+ jsonValue = ((GeckoBundle) value).toJSONObject();
+ } else if (value instanceof GeckoBundle[]) {
+ final GeckoBundle[] array = (GeckoBundle[]) value;
+ final JSONArray jsonArray = new JSONArray();
+ for (final GeckoBundle element : array) {
+ jsonArray.put(element == null ? JSONObject.NULL : element.toJSONObject());
+ }
+ jsonValue = jsonArray;
+ } else if (Build.VERSION.SDK_INT >= 19) {
+ final Object wrapped = JSONObject.wrap(value);
+ jsonValue = wrapped != null ? wrapped : value.toString();
+ } else if (value == null) {
+ jsonValue = JSONObject.NULL;
+ } else if (value.getClass().isArray()) {
+ final JSONArray jsonArray = new JSONArray();
+ for (int j = 0; j < Array.getLength(value); j++) {
+ jsonArray.put(Array.get(value, j));
+ }
+ jsonValue = jsonArray;
+ } else {
+ jsonValue = value;
+ }
+ out.put(mMap.keyAt(i), jsonValue);
+ }
+ return out;
+ }
+
+ public Bundle toBundle() {
+ final Bundle out = new Bundle(mMap.size());
+ for (int i = 0; i < mMap.size(); i++) {
+ final String key = mMap.keyAt(i);
+ final Object val = mMap.valueAt(i);
+
+ if (val == null) {
+ out.putString(key, null);
+ } else if (val instanceof GeckoBundle) {
+ out.putBundle(key, ((GeckoBundle) val).toBundle());
+ } else if (val instanceof GeckoBundle[]) {
+ final GeckoBundle[] array = (GeckoBundle[]) val;
+ final Parcelable[] parcelables = new Parcelable[array.length];
+ for (int j = 0; j < array.length; j++) {
+ if (array[j] != null) {
+ parcelables[j] = array[j].toBundle();
+ }
+ }
+ out.putParcelableArray(key, parcelables);
+ } else if (val instanceof Boolean) {
+ out.putBoolean(key, (Boolean) val);
+ } else if (val instanceof boolean[]) {
+ out.putBooleanArray(key, (boolean[]) val);
+ } else if (val instanceof Byte || val instanceof Short || val instanceof Integer) {
+ out.putInt(key, ((Number) val).intValue());
+ } else if (val instanceof int[]) {
+ out.putIntArray(key, (int[]) val);
+ } else if (val instanceof Float || val instanceof Double || val instanceof Long) {
+ out.putDouble(key, ((Number) val).doubleValue());
+ } else if (val instanceof double[]) {
+ out.putDoubleArray(key, (double[]) val);
+ } else if (val instanceof CharSequence || val instanceof Character) {
+ out.putString(key, val.toString());
+ } else if (val instanceof String[]) {
+ out.putStringArray(key, (String[]) val);
+ } else {
+ throw new UnsupportedOperationException();
+ }
+ }
+ return out;
+ }
+
+ public static GeckoBundle fromBundle(final Bundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+
+ final String[] keys = new String[bundle.size()];
+ final Object[] values = new Object[bundle.size()];
+ int i = 0;
+
+ for (final String key : bundle.keySet()) {
+ final Object value = bundle.get(key);
+ keys[i] = key;
+
+ if (value instanceof Bundle || value == null) {
+ values[i] = fromBundle((Bundle) value);
+ } else if (value instanceof Parcelable[]) {
+ final Parcelable[] array = (Parcelable[]) value;
+ final GeckoBundle[] out = new GeckoBundle[array.length];
+ for (int j = 0; j < array.length; j++) {
+ out[j] = fromBundle((Bundle) array[j]);
+ }
+ values[i] = out;
+ } else if (value instanceof Boolean
+ || value instanceof Integer
+ || value instanceof Double
+ || value instanceof String
+ || value instanceof boolean[]
+ || value instanceof int[]
+ || value instanceof double[]
+ || value instanceof String[]) {
+ values[i] = value;
+ } else if (value instanceof Byte || value instanceof Short) {
+ values[i] = ((Number) value).intValue();
+ } else if (value instanceof Float || value instanceof Long) {
+ values[i] = ((Number) value).doubleValue();
+ } else if (value instanceof CharSequence || value instanceof Character) {
+ values[i] = value.toString();
+ } else {
+ throw new UnsupportedOperationException();
+ }
+
+ i++;
+ }
+ return new GeckoBundle(keys, values);
+ }
+
+ private static Object fromJSONValue(final Object value) throws JSONException {
+ if (value == null || value == JSONObject.NULL) {
+ return null;
+ } else if (value instanceof JSONObject) {
+ return fromJSONObject((JSONObject) value);
+ }
+ if (value instanceof JSONArray) {
+ final JSONArray array = (JSONArray) value;
+ final int len = array.length();
+ if (len == 0) {
+ return EMPTY_BOOLEAN_ARRAY;
+ }
+ Object out = null;
+ for (int i = 0; i < len; i++) {
+ final Object element = fromJSONValue(array.opt(i));
+ if (element == null) {
+ continue;
+ }
+ if (out == null) {
+ Class<?> type = element.getClass();
+ if (type == Boolean.class) {
+ type = boolean.class;
+ } else if (type == Integer.class) {
+ type = int.class;
+ } else if (type == Double.class) {
+ type = double.class;
+ }
+ out = Array.newInstance(type, len);
+ }
+ Array.set(out, i, element);
+ }
+ if (out == null) {
+ // Treat all-null arrays as String arrays.
+ return new String[len];
+ }
+ return out;
+ }
+ if (value instanceof Boolean
+ || value instanceof Integer
+ || value instanceof Double
+ || value instanceof String) {
+ return value;
+ }
+ if (value instanceof Byte || value instanceof Short) {
+ return ((Number) value).intValue();
+ }
+ if (value instanceof Float || value instanceof Long) {
+ return ((Number) value).doubleValue();
+ }
+ return value.toString();
+ }
+
+ public static GeckoBundle fromJSONObject(final JSONObject obj) throws JSONException {
+ if (obj == null || obj == JSONObject.NULL) {
+ return null;
+ }
+
+ final String[] keys = new String[obj.length()];
+ final Object[] values = new Object[obj.length()];
+
+ final Iterator<String> iter = obj.keys();
+ for (int i = 0; iter.hasNext(); i++) {
+ final String key = iter.next();
+ keys[i] = key;
+ values[i] = fromJSONValue(obj.opt(key));
+ }
+ return new GeckoBundle(keys, values);
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel dest, final int flags) {
+ final int len = mMap.size();
+ dest.writeInt(len);
+
+ for (int i = 0; i < len; i++) {
+ dest.writeString(mMap.keyAt(i));
+ dest.writeValue(mMap.valueAt(i));
+ }
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ public void readFromParcel(final Parcel source) {
+ final ClassLoader loader = getClass().getClassLoader();
+ final int len = source.readInt();
+ mMap.clear();
+ mMap.ensureCapacity(len);
+
+ for (int i = 0; i < len; i++) {
+ final String key = source.readString();
+ Object val = source.readValue(loader);
+
+ if (val instanceof Parcelable[]) {
+ final Parcelable[] array = (Parcelable[]) val;
+ val = Arrays.copyOf(array, array.length, GeckoBundle[].class);
+ }
+
+ mMap.put(key, val);
+ }
+ }
+
+ public static final Parcelable.Creator<GeckoBundle> CREATOR =
+ new Parcelable.Creator<GeckoBundle>() {
+ @Override
+ public GeckoBundle createFromParcel(final Parcel source) {
+ final GeckoBundle bundle = new GeckoBundle(0);
+ bundle.readFromParcel(source);
+ return bundle;
+ }
+
+ @Override
+ public GeckoBundle[] newArray(final int size) {
+ return new GeckoBundle[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java
new file mode 100644
index 0000000000..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<MediaCodecInfo> decoderList = new ArrayList<MediaCodecInfo>();
+ 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<MediaCodecInfo> encoderList = new ArrayList<MediaCodecInfo>();
+ 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<String> 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<String> 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<String> 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<Bitmap> result);
+
+ /**
+ * Fetches and decodes an image at the specified location. This method supports SVG, PNG, Bitmap
+ * and other formats supported by Gecko.
+ *
+ * @param uri location of the image. Can be either a remote https:// location, file:/// if the
+ * file is local or a resource://android/ if the file is located inside the APK.
+ * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to
+ * resource://android/assets/test.png.
+ * @return A {@link GeckoResult} to the decoded image.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> decode(final @NonNull String uri) {
+ return decode(uri, 0);
+ }
+
+ /**
+ * Fetches and decodes an image at the specified location and resizes it to the desired length.
+ * This method supports SVG, PNG, Bitmap and other formats supported by Gecko.
+ *
+ * <p>Note: The final size might differ slightly from the requested output.
+ *
+ * @param uri location of the image. Can be either a remote https:// location, file:/// if the
+ * file is local or a resource://android/ if the file is located inside the APK.
+ * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to
+ * resource://android/assets/test.png.
+ * @param desiredLength Longest size for the image in device pixel units. The resulting image
+ * might be slightly different if the image cannot be resized efficiently. If desiredLength is
+ * 0 then the image will be decoded to its natural size.
+ * @return A {@link GeckoResult} to the decoded image.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> decode(final @NonNull String uri, final int desiredLength) {
+ if (uri == null) {
+ throw new IllegalArgumentException("Uri cannot be null");
+ }
+
+ final GeckoResult<Bitmap> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeDecode(uri, desiredLength, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeDecode",
+ String.class,
+ uri,
+ int.class,
+ desiredLength,
+ GeckoResult.class,
+ result);
+ }
+
+ return result;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java
new file mode 100644
index 0000000000..d57147f363
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java
@@ -0,0 +1,334 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.geckoview.GeckoResult;
+
+/**
+ * Represents an Web API image resource as used in web app manifests and media session metadata.
+ *
+ * @see <a href="https://www.w3.org/TR/image-resource">Image Resource</a>
+ */
+@AnyThread
+public class ImageResource {
+ private static final String LOGTAG = "ImageResource";
+ private static final boolean DEBUG = false;
+
+ /** Represents the size of an image resource option. */
+ public static class Size {
+ /** The width in pixels. */
+ public final int width;
+
+ /** The height in pixels. */
+ public final int height;
+
+ /**
+ * Size contructor.
+ *
+ * @param width The width in pixels.
+ * @param height The height in pixels.
+ */
+ public Size(final int width, final int height) {
+ this.width = width;
+ this.height = height;
+ }
+ }
+
+ /** The URI of the image resource. */
+ public final @NonNull String src;
+
+ /** The MIME type of the image resource. */
+ public final @Nullable String type;
+
+ /** A {@link Size} array of supported images sizes. */
+ public final @Nullable Size[] sizes;
+
+ /**
+ * ImageResource constructor.
+ *
+ * @param src The URI string of the image resource.
+ * @param type The MIME type of the image resource.
+ * @param sizes The supported images {@link Size} array.
+ */
+ public ImageResource(
+ final @NonNull String src, final @Nullable String type, final @Nullable Size[] sizes) {
+ this.src = src.toLowerCase(Locale.ROOT);
+ this.type = type != null ? type.toLowerCase(Locale.ROOT) : null;
+ this.sizes = sizes;
+ }
+
+ /**
+ * ImageResource constructor.
+ *
+ * @param src The URI string of the image resource.
+ * @param type The MIME type of the image resource.
+ * @param sizes The supported images sizes string.
+ * @see <a href="https://html.spec.whatwg.org/multipage/semantics.html#dom-link-sizes">Attribute
+ * spec for sizes</a>
+ */
+ public ImageResource(
+ final @NonNull String src, final @Nullable String type, final @Nullable String sizes) {
+ this(src, type, parseSizes(sizes));
+ }
+
+ private static @Nullable Size[] parseSizes(final @Nullable String sizesStr) {
+ if (sizesStr == null || sizesStr.isEmpty()) {
+ return null;
+ }
+
+ final String[] sizesStrs = sizesStr.toLowerCase(Locale.ROOT).split(" ");
+ final List<Size> sizes = new ArrayList<Size>();
+
+ for (final String sizeStr : sizesStrs) {
+ if (sizesStr.equals("any")) {
+ // 0-width size will always be favored.
+ sizes.add(new Size(0, 0));
+ continue;
+ }
+ final String[] widthHeight = sizeStr.split("x");
+ if (widthHeight.length != 2) {
+ // Not spec-compliant size.
+ continue;
+ }
+ try {
+ sizes.add(new Size(Integer.valueOf(widthHeight[0]), Integer.valueOf(widthHeight[1])));
+ } catch (final NumberFormatException e) {
+ Log.e(LOGTAG, "Invalid image resource size", e);
+ }
+ }
+ if (sizes.isEmpty()) {
+ return null;
+ }
+ return sizes.toArray(new Size[0]);
+ }
+
+ public static @NonNull ImageResource fromBundle(final GeckoBundle bundle) {
+ return new ImageResource(
+ bundle.getString("src"), bundle.getString("type"), bundle.getString("sizes"));
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("ImageResource {");
+ builder
+ .append("src=")
+ .append(src)
+ .append("type=")
+ .append(type)
+ .append("sizes=")
+ .append(sizes)
+ .append("}");
+ return builder.toString();
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return ImageDecoder.instance().decode(src, size);
+ }
+
+ /**
+ * Represents a collection of {@link ImageResource} options. Image resources are often used in a
+ * collection to provide multiple image options for various sizes. This data structure can be used
+ * to retrieve the best image resource for any given target image size.
+ */
+ public static class Collection {
+ private static class SizeIndexPair {
+ public final int width;
+ public final int idx;
+
+ public SizeIndexPair(final int width, final int idx) {
+ this.width = width;
+ this.idx = idx;
+ }
+ }
+
+ // The individual image resources, usually each with a unique src.
+ private final List<ImageResource> mImages;
+
+ // A sorted size-index list. The list is sorted based on the supported
+ // sizes of the images in ascending order.
+ private final List<SizeIndexPair> mSizeIndex;
+
+ /* package */ Collection() {
+ mImages = new ArrayList<>();
+ mSizeIndex = new ArrayList<>();
+ }
+
+ /** Builder class for the construction of a {@link Collection}. */
+ public static class Builder {
+ final Collection mCollection;
+
+ public Builder() {
+ mCollection = new Collection();
+ }
+
+ /**
+ * Add an image resource to the collection.
+ *
+ * @param image The {@link ImageResource} to be added.
+ * @return This builder instance.
+ */
+ public @NonNull Builder add(final ImageResource image) {
+ final int index = mCollection.mImages.size();
+
+ if (image.sizes == null) {
+ // Null-sizes are handled the same as `any`.
+ mCollection.mSizeIndex.add(new SizeIndexPair(0, index));
+ } else {
+ for (final Size size : image.sizes) {
+ mCollection.mSizeIndex.add(new SizeIndexPair(size.width, index));
+ }
+ }
+ mCollection.mImages.add(image);
+ return this;
+ }
+
+ /**
+ * Finalize the collection.
+ *
+ * @return The final collection.
+ */
+ public @NonNull Collection build() {
+ Collections.sort(mCollection.mSizeIndex, (a, b) -> Integer.compare(a.width, b.width));
+ return mCollection;
+ }
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("ImageResource.Collection {");
+ builder.append("images=[");
+
+ for (final ImageResource image : mImages) {
+ builder.append(image).append(", ");
+ }
+ builder.append("]}");
+ return builder.toString();
+ }
+
+ /**
+ * Returns the best suited {@link ImageResource} for the given size. This is usually determined
+ * based on the minimal difference between the given size and one of the supported widths of an
+ * image resource.
+ *
+ * @param size The target size for the image in pixels.
+ * @return The best {@link ImageResource} for the given size from this collection.
+ */
+ public @Nullable ImageResource getBest(final int size) {
+ if (mSizeIndex.isEmpty()) {
+ return null;
+ }
+ int bestMatchIdx = mSizeIndex.get(0).idx;
+ int lastDiff = size;
+ for (final SizeIndexPair sizeIndex : mSizeIndex) {
+ final int diff = Math.abs(sizeIndex.width - size);
+ if (lastDiff <= diff) {
+ // With increasing widths, the difference can only grow now.
+ // 0-width means "any", so we're finished at the first
+ // entry.
+ break;
+ }
+ lastDiff = diff;
+ bestMatchIdx = sizeIndex.idx;
+ }
+ return mImages.get(bestMatchIdx);
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ final ImageResource image = getBest(size);
+ if (image == null) {
+ return GeckoResult.fromValue(null);
+ }
+ return image.getBitmap(size);
+ }
+
+ public static Collection fromSizeSrcBundle(final GeckoBundle bundle) {
+ final Builder builder = new Builder();
+
+ for (final String key : bundle.keys()) {
+ final Integer intKey = Integer.valueOf(key);
+ if (intKey == null) {
+ Log.e(LOGTAG, "Non-integer image key: " + intKey);
+
+ if (DEBUG) {
+ throw new RuntimeException("Non-integer image key: " + key);
+ }
+ continue;
+ }
+
+ final String src = getImageValue(bundle.get(key));
+ if (src != null) {
+ // Given the bundle structure, we don't have insight on
+ // individual image resources so we have to create an
+ // instance for each size entry.
+ final ImageResource image =
+ new ImageResource(src, null, new Size[] {new Size(intKey, intKey)});
+ builder.add(image);
+ }
+ }
+ return builder.build();
+ }
+
+ private static String getImageValue(final Object value) {
+ // The image value can either be an object containing images for
+ // each theme...
+ if (value instanceof GeckoBundle) {
+ // We don't support theme_images yet, so let's just return the
+ // default value.
+ final GeckoBundle themeImages = (GeckoBundle) value;
+ final Object defaultImages = themeImages.get("default");
+
+ if (!(defaultImages instanceof String)) {
+ if (DEBUG) {
+ throw new RuntimeException("Unexpected themed_icon value.");
+ }
+ Log.e(LOGTAG, "Unexpected themed_icon value.");
+ return null;
+ }
+
+ return (String) defaultImages;
+ }
+
+ // ... or just a URL.
+ if (value instanceof String) {
+ return (String) value;
+ }
+
+ // We never expect it to be something else, so let's error out here.
+ if (DEBUG) {
+ throw new RuntimeException("Unexpected image value: " + value);
+ }
+
+ Log.e(LOGTAG, "Unexpected image value.");
+ return null;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java
new file mode 100644
index 0000000000..e0a0d924a9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java
@@ -0,0 +1,20 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.view.InputDevice;
+
+public class InputDeviceUtils {
+ public static boolean isPointerTypeDevice(final InputDevice inputDevice) {
+ final int sources = inputDevice.getSources();
+ return (sources
+ & (InputDevice.SOURCE_CLASS_JOYSTICK
+ | InputDevice.SOURCE_CLASS_POINTER
+ | InputDevice.SOURCE_CLASS_POSITION
+ | InputDevice.SOURCE_CLASS_TRACKBALL))
+ != 0;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java
new file mode 100644
index 0000000000..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<Proxy> proxies = ps.select(uri);
+ if (proxies != null && !proxies.isEmpty()) {
+ proxy = proxies.get(0);
+ }
+ }
+
+ return uri.toURL().openConnection(proxy);
+ }
+
+ public ProxySelector() {}
+
+ public Proxy select(final String scheme, final String host) {
+ int port = -1;
+ Proxy proxy = null;
+ String nonProxyHostsKey = null;
+ boolean httpProxyOkay = true;
+ if ("http".equalsIgnoreCase(scheme)) {
+ port = 80;
+ nonProxyHostsKey = "http.nonProxyHosts";
+ proxy = lookupProxy("http.proxyHost", "http.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("https".equalsIgnoreCase(scheme)) {
+ port = 443;
+ nonProxyHostsKey = "https.nonProxyHosts"; // RI doesn't support this
+ proxy = lookupProxy("https.proxyHost", "https.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("ftp".equalsIgnoreCase(scheme)) {
+ port = 80; // not 21 as you might guess
+ nonProxyHostsKey = "ftp.nonProxyHosts";
+ proxy = lookupProxy("ftp.proxyHost", "ftp.proxyPort", Proxy.Type.HTTP, port);
+ } else if ("socket".equalsIgnoreCase(scheme)) {
+ httpProxyOkay = false;
+ } else {
+ return Proxy.NO_PROXY;
+ }
+
+ if (nonProxyHostsKey != null && isNonProxyHost(host, System.getProperty(nonProxyHostsKey))) {
+ return Proxy.NO_PROXY;
+ }
+
+ if (proxy != null) {
+ return proxy;
+ }
+
+ if (httpProxyOkay) {
+ proxy = lookupProxy("proxyHost", "proxyPort", Proxy.Type.HTTP, port);
+ if (proxy != null) {
+ return proxy;
+ }
+ }
+
+ proxy = lookupProxy("socksProxyHost", "socksProxyPort", Proxy.Type.SOCKS, 1080);
+ if (proxy != null) {
+ return proxy;
+ }
+
+ return Proxy.NO_PROXY;
+ }
+
+ /** Returns the proxy identified by the {@code hostKey} system property, or null. */
+ @Nullable
+ private Proxy lookupProxy(
+ final String hostKey, final String portKey, final Proxy.Type type, final int defaultPort) {
+ final String host = System.getProperty(hostKey);
+ if (TextUtils.isEmpty(host)) {
+ return null;
+ }
+
+ final int port = getSystemPropertyInt(portKey, defaultPort);
+ if (port == -1) {
+ // Port can be -1. See bug 1270529.
+ return null;
+ }
+
+ return new Proxy(type, InetSocketAddress.createUnresolved(host, port));
+ }
+
+ private int getSystemPropertyInt(final String key, final int defaultValue) {
+ final String string = System.getProperty(key);
+ if (string != null) {
+ try {
+ return Integer.parseInt(string);
+ } catch (final NumberFormatException ignored) {
+ }
+ }
+ return defaultValue;
+ }
+
+ /**
+ * Returns true if the {@code nonProxyHosts} system property pattern exists and matches {@code
+ * host}.
+ */
+ private boolean isNonProxyHost(final String host, final String nonProxyHosts) {
+ if (host == null || nonProxyHosts == null) {
+ return false;
+ }
+
+ // construct pattern
+ final StringBuilder patternBuilder = new StringBuilder();
+ for (int i = 0; i < nonProxyHosts.length(); i++) {
+ final char c = nonProxyHosts.charAt(i);
+ switch (c) {
+ case '.':
+ patternBuilder.append("\\.");
+ break;
+ case '*':
+ patternBuilder.append(".*");
+ break;
+ default:
+ patternBuilder.append(c);
+ }
+ }
+ // check whether the host is the nonProxyHosts.
+ final String pattern = patternBuilder.toString();
+ return host.matches(pattern);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java
new file mode 100644
index 0000000000..00625800c9
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java
@@ -0,0 +1,145 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import org.mozilla.gecko.annotation.RobocopTarget;
+
+public final class ThreadUtils {
+ private static final String LOGTAG = "ThreadUtils";
+
+ /**
+ * Controls the action taken when a method like {@link
+ * ThreadUtils#assertOnUiThread(AssertBehavior)} detects a problem.
+ */
+ public enum AssertBehavior {
+ NONE,
+ THROW,
+ }
+
+ private static final Thread sUiThread = Looper.getMainLooper().getThread();
+ private static final Handler sUiHandler = new Handler(Looper.getMainLooper());
+
+ // Referenced directly from GeckoAppShell in highly performance-sensitive code (The extra
+ // function call of the getter was harming performance. (Bug 897123))
+ // Once Bug 709230 is resolved we should reconsider this as ProGuard should be able to optimise
+ // this out at compile time.
+ public static Handler sGeckoHandler;
+ public static volatile Thread sGeckoThread;
+
+ public static Thread getUiThread() {
+ return sUiThread;
+ }
+
+ public static Handler getUiHandler() {
+ return sUiHandler;
+ }
+
+ /**
+ * Runs the provided runnable on the UI thread. If this method is called on the UI thread the
+ * runnable will be executed synchronously.
+ *
+ * @param runnable the runnable to be executed.
+ */
+ public static void runOnUiThread(final Runnable runnable) {
+ // We're on the UI thread already, let's just run this
+ if (isOnUiThread()) {
+ runnable.run();
+ return;
+ }
+
+ postToUiThread(runnable);
+ }
+
+ public static void postToUiThread(final Runnable runnable) {
+ sUiHandler.post(runnable);
+ }
+
+ public static void postToUiThreadDelayed(final Runnable runnable, final long delayMillis) {
+ sUiHandler.postDelayed(runnable, delayMillis);
+ }
+
+ public static void removeUiThreadCallbacks(final Runnable runnable) {
+ sUiHandler.removeCallbacks(runnable);
+ }
+
+ public static Handler getBackgroundHandler() {
+ return GeckoBackgroundThread.getHandler();
+ }
+
+ public static void postToBackgroundThread(final Runnable runnable) {
+ GeckoBackgroundThread.post(runnable);
+ }
+
+ public static void assertOnUiThread(final AssertBehavior assertBehavior) {
+ assertOnThread(getUiThread(), assertBehavior);
+ }
+
+ public static void assertOnUiThread() {
+ assertOnThread(getUiThread(), AssertBehavior.THROW);
+ }
+
+ @RobocopTarget
+ public static void assertOnGeckoThread() {
+ assertOnThread(sGeckoThread, AssertBehavior.THROW);
+ }
+
+ public static void assertOnThread(final Thread expectedThread, final AssertBehavior behavior) {
+ assertOnThreadComparison(expectedThread, behavior, true);
+ }
+
+ private static void assertOnThreadComparison(
+ final Thread expectedThread, final AssertBehavior behavior, final boolean expected) {
+ final Thread currentThread = Thread.currentThread();
+ final long currentThreadId = currentThread.getId();
+ final long expectedThreadId = expectedThread.getId();
+
+ if ((currentThreadId == expectedThreadId) == expected) {
+ return;
+ }
+
+ final String message;
+ if (expected) {
+ message =
+ "Expected thread "
+ + expectedThreadId
+ + " (\""
+ + expectedThread.getName()
+ + "\"), but running on thread "
+ + currentThreadId
+ + " (\""
+ + currentThread.getName()
+ + "\")";
+ } else {
+ message =
+ "Expected anything but "
+ + expectedThreadId
+ + " (\""
+ + expectedThread.getName()
+ + "\"), but running there.";
+ }
+
+ final IllegalThreadStateException e = new IllegalThreadStateException(message);
+
+ switch (behavior) {
+ case THROW:
+ throw e;
+ default:
+ Log.e(LOGTAG, "Method called on wrong thread!", e);
+ }
+ }
+
+ public static boolean isOnUiThread() {
+ return isOnThread(getUiThread());
+ }
+
+ @RobocopTarget
+ public static boolean isOnThread(final Thread thread) {
+ return (Thread.currentThread().getId() == thread.getId());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja
new file mode 100644
index 0000000000..f704bbc775
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja
@@ -0,0 +1,38 @@
+/* -*- Mode: Java; c-basic-offset: 2; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+public final class XPCOMError {
+ /** Check if the error code corresponds to a failure */
+ public static boolean failed(long err) {
+ return (err & 0x80000000L) != 0;
+ }
+
+ /** Check if the error code corresponds to a failure */
+ public static boolean succeeded(long err) {
+ return !failed(err);
+ }
+
+ /** Extract the error code part of the error message */
+ public static int getErrorCode(long err) {
+ return (int)(err & 0xffffL);
+ }
+
+ /** Extract the error module part of the error message */
+ public static int getErrorModule(long err) {
+ return (int)(((err >> 16) - NS_ERROR_MODULE_BASE_OFFSET) & 0x1fffL);
+ }
+
+ public static final int NS_ERROR_MODULE_BASE_OFFSET = {{ MODULE_BASE_OFFSET }};
+
+{% for mod, val in modules %}
+ public static final int NS_ERROR_MODULE_{{ mod }} = {{ val }};
+{% endfor %}
+
+{% for error, val in errors %}
+ public static final long {{ error }} = 0x{{ "%X" % val }}L;
+{% endfor %}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java
new file mode 100644
index 0000000000..31eac71a66
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java
@@ -0,0 +1,170 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.util;
+
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+import org.mozilla.geckoview.BuildConfig;
+
+/**
+ * Wrapper for nsIEventTarget, enabling seamless dispatch of java runnables to Gecko event queues.
+ */
+@WrapForJNI
+public final class XPCOMEventTarget extends JNIObject implements IXPCOMEventTarget {
+ @Override
+ public void execute(final Runnable runnable) {
+ dispatchNative(new JNIRunnable(runnable));
+ }
+
+ public static synchronized IXPCOMEventTarget mainThread() {
+ if (mMainThread == null) {
+ mMainThread = new AsyncProxy("main");
+ }
+ return mMainThread;
+ }
+
+ private static IXPCOMEventTarget mMainThread = null;
+
+ public static synchronized IXPCOMEventTarget launcherThread() {
+ if (mLauncherThread == null) {
+ mLauncherThread = new AsyncProxy("launcher");
+ }
+ return mLauncherThread;
+ }
+
+ private static IXPCOMEventTarget mLauncherThread = null;
+
+ /**
+ * Runs the provided runnable on the launcher thread. If this method is called from the launcher
+ * thread itself, the runnable will be executed immediately and synchronously.
+ */
+ public static void runOnLauncherThread(@NonNull final Runnable runnable) {
+ final IXPCOMEventTarget launcherThread = launcherThread();
+ if (launcherThread.isOnCurrentThread()) {
+ // We're already on the launcher thread, just execute the runnable
+ runnable.run();
+ return;
+ }
+
+ launcherThread.execute(runnable);
+ }
+
+ public static void assertOnLauncherThread() {
+ if (BuildConfig.DEBUG_BUILD && !launcherThread().isOnCurrentThread()) {
+ throw new AssertionError("Expected to be running on XPCOM launcher thread");
+ }
+ }
+
+ public static void assertNotOnLauncherThread() {
+ if (BuildConfig.DEBUG_BUILD && launcherThread().isOnCurrentThread()) {
+ throw new AssertionError("Expected to not be running on XPCOM launcher thread");
+ }
+ }
+
+ private static synchronized IXPCOMEventTarget getTarget(final String name) {
+ if (name.equals("launcher")) {
+ return mLauncherThread;
+ } else if (name.equals("main")) {
+ return mMainThread;
+ } else {
+ throw new RuntimeException("Attempt to assign to unknown thread named " + name);
+ }
+ }
+
+ @WrapForJNI
+ private static synchronized void setTarget(final String name, final XPCOMEventTarget target) {
+ if (name.equals("main")) {
+ mMainThread = target;
+ } else if (name.equals("launcher")) {
+ mLauncherThread = target;
+ } else {
+ throw new RuntimeException("Attempt to assign to unknown thread named " + name);
+ }
+
+ // Ensure that we see the right name in the Java debugger. We don't do this for mMainThread
+ // because its name was already set (in this context, "main" is the GeckoThread).
+ if (mMainThread != target) {
+ target.execute(
+ () -> {
+ Thread.currentThread().setName(name);
+ });
+ }
+ }
+
+ @Override
+ public native boolean isOnCurrentThread();
+
+ private native void dispatchNative(final JNIRunnable runnable);
+
+ @WrapForJNI
+ private static synchronized void resolveAndDispatch(final String name, final Runnable runnable) {
+ getTarget(name).execute(runnable);
+ }
+
+ private static native void resolveAndDispatchNative(final String name, final Runnable runnable);
+
+ @Override
+ protected native void disposeNative();
+
+ @WrapForJNI
+ private static final class JNIRunnable {
+ JNIRunnable(final Runnable inner) {
+ mInner = inner;
+ }
+
+ @WrapForJNI
+ void run() {
+ mInner.run();
+ }
+
+ private Runnable mInner;
+ }
+
+ private static final class AsyncProxy implements IXPCOMEventTarget {
+ private String mTargetName;
+
+ public AsyncProxy(final String targetName) {
+ mTargetName = targetName;
+ }
+
+ @Override
+ public void execute(final Runnable runnable) {
+ final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName);
+
+ if (target != null && target instanceof XPCOMEventTarget) {
+ target.execute(runnable);
+ return;
+ }
+
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.JNI_READY,
+ XPCOMEventTarget.class,
+ "resolveAndDispatchNative",
+ String.class,
+ mTargetName,
+ Runnable.class,
+ runnable);
+ }
+
+ @Override
+ public boolean isOnCurrentThread() {
+ final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName);
+
+ // If target is not yet a XPCOMEventTarget then JNI is not
+ // initialized yet. If JNI is not initialized yet, then we cannot
+ // possibly be running on a target with an XPCOMEventTarget.
+ if (target == null || !(target instanceof XPCOMEventTarget)) {
+ return false;
+ }
+
+ // Otherwise we have a real XPCOMEventTarget, so we can delegate
+ // this call to it.
+ return target.isOnCurrentThread();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
new file mode 100644
index 0000000000..f8342cbfa7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java
@@ -0,0 +1,16 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/** This represents a decision to allow or deny a request. */
+@AnyThread
+public enum AllowOrDeny {
+ ALLOW,
+ DENY;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
new file mode 100644
index 0000000000..e8a004df17
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java
@@ -0,0 +1,1445 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion.
+ *
+ * <p>The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates.
+ *
+ * <p>The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and
+ * storing of login entries.
+ *
+ * <p>The user-prompt delegates propagate decisions to the app that could require user choice, e.g.,
+ * saving or updating of login entries or the selection of a login entry out of multiple options.
+ *
+ * <p>Throughout the documentation, we will refer to the filling out of input forms using two terms:
+ * 1. Autofill: automatic filling without user interaction. 2. Autocomplete: semi-automatic filling
+ * that requires user prompting for the selection.
+ *
+ * <h2>Examples</h2>
+ *
+ * <h3>Autocomplete/Fetch API</h3>
+ *
+ * <p>GeckoView loads <code>https://example.com</code> which contains (for the purpose of this
+ * example) elements resembling a login form, e.g.,
+ *
+ * <pre><code>
+ * &lt;form&gt;
+ * &lt;input type=&quot;text&quot; placeholder=&quot;username&quot;&gt;
+ * &lt;input type=&quot;password&quot; placeholder=&quot;password&quot;&gt;
+ * &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
+ * &lt;/form&gt;
+ * </code></pre>
+ *
+ * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to fetch logins for the
+ * given domain.
+ *
+ * <p>Based on the provided login entries, GeckoView will attempt to autofill the login input
+ * fields, if there is only one suitable login entry option.
+ *
+ * <p>In the case of multiple valid login entry options, GeckoView dispatches a <code>
+ * GeckoSession.PromptDelegate.onLoginSelect</code> request, which allows for user-choice
+ * delegation.
+ *
+ * <p>Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login
+ * input fields.
+ *
+ * <h3>Update API</h3>
+ *
+ * <p>When the user submits some login input fields, GeckoView dispatches another <code>
+ * StorageDelegate.onLoginFetch(&quot;example.com&quot;)</code> request to check whether the
+ * submitted login exists or whether it's a new or updated login entry.
+ *
+ * <p>If the submitted login is already contained as-is in the collection returned by <code>
+ * onLoginFetch</code>, then GeckoView dispatches <code>StorageDelegate.onLoginUsed</code> with the
+ * submitted login entry.
+ *
+ * <p>If the submitted login is a new or updated entry, GeckoView dispatches a sequence of requests
+ * to save/update the login entry, see the Save API example.
+ *
+ * <h3>Save API</h3>
+ *
+ * <p>The user enters new or updated (password) login credentials in some login input fields and
+ * submits explicitely (submit action) or by navigation. GeckoView identifies the entered
+ * credentials and dispatches a <code>GeckoSession.PromptDelegate.onLoginSave(session, request)
+ * </code> with the provided credentials.
+ *
+ * <p>The app may dismiss the prompt request via <code>
+ * return GeckoResult.fromValue(prompt.dismiss())</code> which terminates this saving request, or
+ * confirm it via <code>return GeckoResult.fromValue(prompt.confirm(login))</code> where <code>login
+ * </code> either holds the credentials originally provided by the prompt request (<code>
+ * prompt.logins[0]</code>) or a new or modified login entry.
+ *
+ * <p>The login entry returned in a confirmed save prompt is used to request for saving in the
+ * runtime delegate via <code>StorageDelegate.onLoginSave(login)</code>. If the app has already
+ * stored the entry during the prompt request handling, it may ignore this storage saving request.
+ * <br>
+ *
+ * @see GeckoRuntime#setAutocompleteStorageDelegate <br>
+ * @see GeckoSession#setPromptDelegate <br>
+ * @see GeckoSession.PromptDelegate#onLoginSave <br>
+ * @see GeckoSession.PromptDelegate#onLoginSelect
+ */
+public class Autocomplete {
+ private static final String LOGTAG = "Autocomplete";
+ private static final boolean DEBUG = false;
+
+ protected Autocomplete() {}
+
+ /** Holds credit card information for a specific entry. */
+ public static class CreditCard {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String NUMBER_KEY = "number";
+ private static final String EXP_MONTH_KEY = "expMonth";
+ private static final String EXP_YEAR_KEY = "expYear";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The full name as it appears on the credit card. */
+ public final @NonNull String name;
+
+ /** The credit card number. */
+ public final @NonNull String number;
+
+ /** The expiration month. */
+ public final @NonNull String expirationMonth;
+
+ /** The expiration year. */
+ public final @NonNull String expirationYear;
+
+ // For tests only.
+ @AnyThread
+ protected CreditCard() {
+ guid = null;
+ name = "";
+ number = "";
+ expirationMonth = "";
+ expirationYear = "";
+ }
+
+ @AnyThread
+ /* package */ CreditCard(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ number = bundle.getString(NUMBER_KEY, "");
+ expirationMonth = bundle.getString(EXP_MONTH_KEY, "");
+ expirationYear = bundle.getString(EXP_YEAR_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("CreditCard {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", name=")
+ .append(name)
+ .append(", number=")
+ .append(number)
+ .append(", expirationMonth=")
+ .append(expirationMonth)
+ .append(", expirationYear=")
+ .append(expirationYear)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(7);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(NUMBER_KEY, number);
+ if (expirationMonth != null) {
+ bundle.putString(EXP_MONTH_KEY, expirationMonth);
+ }
+ if (expirationYear != null) {
+ bundle.putString(EXP_YEAR_KEY, expirationYear);
+ }
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(7);
+ }
+
+ /**
+ * Finalize the {@link CreditCard} instance.
+ *
+ * @return The {@link CreditCard} instance.
+ */
+ @AnyThread
+ public @NonNull CreditCard build() {
+ return new CreditCard(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this credit card entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the name for this credit card entry.
+ *
+ * @param name The full name as it appears on the credit card.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the number for this credit card entry.
+ *
+ * @param number The credit card number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder number(final @Nullable String number) {
+ mBundle.putString(NUMBER_KEY, number);
+ return this;
+ }
+
+ /**
+ * Set the expiration month for this credit card entry.
+ *
+ * @param expMonth The expiration month string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationMonth(final @Nullable String expMonth) {
+ mBundle.putString(EXP_MONTH_KEY, expMonth);
+ return this;
+ }
+
+ /**
+ * Set the expiration year for this credit card entry.
+ *
+ * @param expYear The expiration year string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder expirationYear(final @Nullable String expYear) {
+ mBundle.putString(EXP_YEAR_KEY, expYear);
+ return this;
+ }
+ }
+ }
+
+ /** Holds address information for a specific entry. */
+ public static class Address {
+ private static final String GUID_KEY = "guid";
+ private static final String NAME_KEY = "name";
+ private static final String GIVEN_NAME_KEY = "givenName";
+ private static final String ADDITIONAL_NAME_KEY = "additionalName";
+ private static final String FAMILY_NAME_KEY = "familyName";
+ private static final String ORGANIZATION_KEY = "organization";
+ private static final String STREET_ADDRESS_KEY = "streetAddress";
+ private static final String ADDRESS_LEVEL1_KEY = "addressLevel1";
+ private static final String ADDRESS_LEVEL2_KEY = "addressLevel2";
+ private static final String ADDRESS_LEVEL3_KEY = "addressLevel3";
+ private static final String POSTAL_CODE_KEY = "postalCode";
+ private static final String COUNTRY_KEY = "country";
+ private static final String TEL_KEY = "tel";
+ private static final String EMAIL_KEY = "email";
+ private static final byte bundleCapacity = 14;
+
+ /** The unique identifier for this address entry. */
+ public final @Nullable String guid;
+
+ /** The full name. */
+ public final @NonNull String name;
+
+ /** The given (first) name. */
+ public final @NonNull String givenName;
+
+ /** An additional name, if available. */
+ public final @NonNull String additionalName;
+
+ /** The family name. */
+ public final @NonNull String familyName;
+
+ /** The name of the company, if applicable. */
+ public final @NonNull String organization;
+
+ /** The (multiline) street address. */
+ public final @NonNull String streetAddress;
+
+ /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel1;
+
+ /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */
+ public final @NonNull String addressLevel2;
+
+ /**
+ * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided.
+ */
+ public final @NonNull String addressLevel3;
+
+ /** The postal code. */
+ public final @NonNull String postalCode;
+
+ /** The country string in ISO 3166. */
+ public final @NonNull String country;
+
+ /** The telephone number string. */
+ public final @NonNull String tel;
+
+ /** The email address. */
+ public final @NonNull String email;
+
+ // For tests only.
+ @AnyThread
+ protected Address() {
+ guid = null;
+ name = "";
+ givenName = "";
+ additionalName = "";
+ familyName = "";
+ organization = "";
+ streetAddress = "";
+ addressLevel1 = "";
+ addressLevel2 = "";
+ addressLevel3 = "";
+ postalCode = "";
+ country = "";
+ tel = "";
+ email = "";
+ }
+
+ @AnyThread
+ /* package */ Address(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ name = bundle.getString(NAME_KEY, "");
+ givenName = bundle.getString(GIVEN_NAME_KEY, "");
+ additionalName = bundle.getString(ADDITIONAL_NAME_KEY, "");
+ familyName = bundle.getString(FAMILY_NAME_KEY, "");
+ organization = bundle.getString(ORGANIZATION_KEY, "");
+ streetAddress = bundle.getString(STREET_ADDRESS_KEY, "");
+ addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, "");
+ addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, "");
+ addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, "");
+ postalCode = bundle.getString(POSTAL_CODE_KEY, "");
+ country = bundle.getString(COUNTRY_KEY, "");
+ tel = bundle.getString(TEL_KEY, "");
+ email = bundle.getString(EMAIL_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Address {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", givenName=")
+ .append(givenName)
+ .append(", additionalName=")
+ .append(additionalName)
+ .append(", familyName=")
+ .append(familyName)
+ .append(", organization=")
+ .append(organization)
+ .append(", streetAddress=")
+ .append(streetAddress)
+ .append(", addressLevel1=")
+ .append(addressLevel1)
+ .append(", addressLevel2=")
+ .append(addressLevel2)
+ .append(", addressLevel3=")
+ .append(addressLevel3)
+ .append(", postalCode=")
+ .append(postalCode)
+ .append(", country=")
+ .append(country)
+ .append(", tel=")
+ .append(tel)
+ .append(", email=")
+ .append(email)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(bundleCapacity);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(NAME_KEY, name);
+ bundle.putString(GIVEN_NAME_KEY, givenName);
+ bundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ bundle.putString(FAMILY_NAME_KEY, familyName);
+ bundle.putString(ORGANIZATION_KEY, organization);
+ bundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ bundle.putString(POSTAL_CODE_KEY, postalCode);
+ bundle.putString(COUNTRY_KEY, country);
+ bundle.putString(TEL_KEY, tel);
+ bundle.putString(EMAIL_KEY, email);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(bundleCapacity);
+ }
+
+ /**
+ * Finalize the {@link Address} instance.
+ *
+ * @return The {@link Address} instance.
+ */
+ @AnyThread
+ public @NonNull Address build() {
+ return new Address(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this address entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the full name for this address entry.
+ *
+ * @param name The full name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder name(final @Nullable String name) {
+ mBundle.putString(NAME_KEY, name);
+ return this;
+ }
+
+ /**
+ * Set the given name for this address entry.
+ *
+ * @param givenName The given name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder givenName(final @Nullable String givenName) {
+ mBundle.putString(GIVEN_NAME_KEY, givenName);
+ return this;
+ }
+
+ /**
+ * Set the additional name for this address entry.
+ *
+ * @param additionalName The additional name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder additionalName(final @Nullable String additionalName) {
+ mBundle.putString(ADDITIONAL_NAME_KEY, additionalName);
+ return this;
+ }
+
+ /**
+ * Set the family name for this address entry.
+ *
+ * @param familyName The family name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder familyName(final @Nullable String familyName) {
+ mBundle.putString(FAMILY_NAME_KEY, familyName);
+ return this;
+ }
+
+ /**
+ * Set the company name for this address entry.
+ *
+ * @param organization The company name string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder organization(final @Nullable String organization) {
+ mBundle.putString(ORGANIZATION_KEY, organization);
+ return this;
+ }
+
+ /**
+ * Set the street address for this address entry.
+ *
+ * @param streetAddress The street address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder streetAddress(final @Nullable String streetAddress) {
+ mBundle.putString(STREET_ADDRESS_KEY, streetAddress);
+ return this;
+ }
+
+ /**
+ * Set the level 1 address for this address entry.
+ *
+ * @param addressLevel1 The level 1 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) {
+ mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1);
+ return this;
+ }
+
+ /**
+ * Set the level 2 address for this address entry.
+ *
+ * @param addressLevel2 The level 2 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) {
+ mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2);
+ return this;
+ }
+
+ /**
+ * Set the level 3 address for this address entry.
+ *
+ * @param addressLevel3 The level 3 address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) {
+ mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3);
+ return this;
+ }
+
+ /**
+ * Set the postal code for this address entry.
+ *
+ * @param postalCode The postal code string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder postalCode(final @Nullable String postalCode) {
+ mBundle.putString(POSTAL_CODE_KEY, postalCode);
+ return this;
+ }
+
+ /**
+ * Set the country code for this address entry.
+ *
+ * @param country The country string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder country(final @Nullable String country) {
+ mBundle.putString(COUNTRY_KEY, country);
+ return this;
+ }
+
+ /**
+ * Set the telephone number for this address entry.
+ *
+ * @param tel The telephone number string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder tel(final @Nullable String tel) {
+ mBundle.putString(TEL_KEY, tel);
+ return this;
+ }
+
+ /**
+ * Set the email address for this address entry.
+ *
+ * @param email The email address string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder email(final @Nullable String email) {
+ mBundle.putString(EMAIL_KEY, email);
+ return this;
+ }
+ }
+ }
+
+ /** Holds login information for a specific entry. */
+ public static class LoginEntry {
+ private static final String GUID_KEY = "guid";
+ private static final String ORIGIN_KEY = "origin";
+ private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin";
+ private static final String HTTP_REALM_KEY = "httpRealm";
+ private static final String USERNAME_KEY = "username";
+ private static final String PASSWORD_KEY = "password";
+
+ /** The unique identifier for this login entry. */
+ public final @Nullable String guid;
+
+ /** The origin this login entry applies to. */
+ public final @NonNull String origin;
+
+ /**
+ * The origin this login entry was submitted to. This only applies to form-based login entries.
+ * It's derived from the action attribute set on the form element.
+ */
+ public final @Nullable String formActionOrigin;
+
+ /**
+ * The HTTP realm this login entry was requested for. This only applies to non-form-based login
+ * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see
+ * RFC2617 for details.
+ */
+ public final @Nullable String httpRealm;
+
+ /** The username for this login entry. */
+ public final @NonNull String username;
+
+ /** The password for this login entry. */
+ public final @NonNull String password;
+
+ // For tests only.
+ @AnyThread
+ protected LoginEntry() {
+ guid = null;
+ origin = "";
+ formActionOrigin = null;
+ httpRealm = null;
+ username = "";
+ password = "";
+ }
+
+ @AnyThread
+ /* package */ LoginEntry(final @NonNull GeckoBundle bundle) {
+ guid = bundle.getString(GUID_KEY);
+ origin = bundle.getString(ORIGIN_KEY, "");
+ formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY);
+ httpRealm = bundle.getString(HTTP_REALM_KEY);
+ username = bundle.getString(USERNAME_KEY, "");
+ password = bundle.getString(PASSWORD_KEY, "");
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("LoginEntry {");
+ builder
+ .append("guid=")
+ .append(guid)
+ .append(", origin=")
+ .append(origin)
+ .append(", formActionOrigin=")
+ .append(formActionOrigin)
+ .append(", httpRealm=")
+ .append(httpRealm)
+ .append(", username=")
+ .append(username)
+ .append(", password=")
+ .append(password)
+ .append("}");
+ return builder.toString();
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(6);
+ bundle.putString(GUID_KEY, guid);
+ bundle.putString(ORIGIN_KEY, origin);
+ bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ bundle.putString(HTTP_REALM_KEY, httpRealm);
+ bundle.putString(USERNAME_KEY, username);
+ bundle.putString(PASSWORD_KEY, password);
+
+ return bundle;
+ }
+
+ public static class Builder {
+ private final GeckoBundle mBundle;
+
+ @AnyThread
+ /* package */ Builder(final @NonNull GeckoBundle bundle) {
+ mBundle = new GeckoBundle(bundle);
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mBundle = new GeckoBundle(6);
+ }
+
+ /**
+ * Finalize the {@link LoginEntry} instance.
+ *
+ * @return The {@link LoginEntry} instance.
+ */
+ @AnyThread
+ public @NonNull LoginEntry build() {
+ return new LoginEntry(mBundle);
+ }
+
+ /**
+ * Set the unique identifier for this login entry.
+ *
+ * @param guid The unique identifier string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder guid(final @Nullable String guid) {
+ mBundle.putString(GUID_KEY, guid);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry applies to.
+ *
+ * @param origin The origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder origin(final @NonNull String origin) {
+ mBundle.putString(ORIGIN_KEY, origin);
+ return this;
+ }
+
+ /**
+ * Set the origin this login entry was submitted to.
+ *
+ * @param formActionOrigin The form action origin string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) {
+ mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin);
+ return this;
+ }
+
+ /**
+ * Set the HTTP realm this login entry was requested for.
+ *
+ * @param httpRealm The HTTP realm string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder httpRealm(final @Nullable String httpRealm) {
+ mBundle.putString(HTTP_REALM_KEY, httpRealm);
+ return this;
+ }
+
+ /**
+ * Set the username for this login entry.
+ *
+ * @param username The username string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder username(final @NonNull String username) {
+ mBundle.putString(USERNAME_KEY, username);
+ return this;
+ }
+
+ /**
+ * Set the password for this login entry.
+ *
+ * @param password The password string.
+ * @return This {@link Builder} instance.
+ */
+ @AnyThread
+ public @NonNull Builder password(final @NonNull String password) {
+ mBundle.putString(PASSWORD_KEY, password);
+ return this;
+ }
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {UsedField.PASSWORD})
+ public @interface LSUsedField {}
+
+ // Sync with UsedField in GeckoViewAutocomplete.jsm.
+ /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */
+ public static class UsedField {
+ /** The password field of a login entry. */
+ public static final int PASSWORD = 1;
+
+ protected UsedField() {}
+ }
+
+ /**
+ * Implement this interface to handle runtime login storage requests. Login storage events include
+ * login entry requests for autofill and autocompletion of login input fields. This delegate is
+ * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}.
+ */
+ public interface StorageDelegate {
+ /**
+ * Request login entries for a given domain. While processing the web document, we have
+ * identified elements resembling login input fields suitable for autofill. We will attempt to
+ * match the provided login information to the identified input fields.
+ *
+ * @param domain The domain string for the requested logins.
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins for the given domain.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch(@NonNull final String domain) {
+ return null;
+ }
+
+ /**
+ * Request login entries for all domains.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing
+ * the existing logins.
+ */
+ @UiThread
+ default @Nullable GeckoResult<LoginEntry[]> onLoginFetch() {
+ return null;
+ }
+
+ /**
+ * Request credit card entries. While processing the web document, we have identified elements
+ * resembling credit card input fields suitable for autofill. We will attempt to match the
+ * provided credit card information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing
+ * the existing credit cards.
+ */
+ @UiThread
+ default @Nullable GeckoResult<CreditCard[]> onCreditCardFetch() {
+ return null;
+ }
+
+ /**
+ * Request address entries. While processing the web document, we have identified elements
+ * resembling address input fields suitable for autofill. We will attempt to match the provided
+ * address information to the identified input fields.
+ *
+ * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the
+ * existing addresses.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Address[]> onAddressFetch() {
+ return null;
+ }
+
+ /**
+ * Request saving or updating of the given login entry. This is triggered by confirming a {@link
+ * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request.
+ *
+ * @param login The {@link LoginEntry} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onLoginSave(@NonNull final LoginEntry login) {}
+
+ /**
+ * Request saving or updating of the given credit card entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request.
+ *
+ * @param creditCard The {@link CreditCard} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onCreditCardSave(@NonNull CreditCard creditCard) {}
+
+ /**
+ * Request saving or updating of the given address entry. This is triggered by confirming a
+ * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request.
+ *
+ * @param address The {@link Address} as confirmed by the prompt request.
+ */
+ @UiThread
+ default void onAddressSave(@NonNull Address address) {}
+
+ /**
+ * Notify that the given login was used to autofill login input fields. This is triggered by
+ * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}.
+ *
+ * @param login The {@link LoginEntry} that was used for the autofilling.
+ * @param usedFields The login entry fields used for autofilling. A combination of {@link
+ * UsedField}.
+ */
+ @UiThread
+ default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {}
+ }
+
+ /**
+ * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and
+ * {@link Autocomplete.SelectOption}.
+ */
+ public abstract static class Option<T> {
+ /* package */ static final String VALUE_KEY = "value";
+ /* package */ static final String HINT_KEY = "hint";
+
+ public final @NonNull T value;
+ public final int hint;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Option(final @NonNull T value, final int hint) {
+ this.value = value;
+ this.hint = hint;
+ }
+
+ @AnyThread
+ /* package */ abstract @NonNull GeckoBundle toBundle();
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSaveOption}. */
+ public abstract static class SaveOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE})
+ public @interface SaveOptionHint {}
+
+ /** Hint types for login saving requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /** Auto-generated password. Notify but do not prompt the user for saving. */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Potentially non-login data. The form data entered may be not login credentials but other
+ * forms of input like credit card numbers. Note that this could be valid login data in same
+ * cases, e.g., some banks may expect credit card numbers in the username field.
+ */
+ public static final int LOW_CONFIDENCE = 1 << 1;
+
+ protected Hint() {}
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+ }
+
+ /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */
+ public abstract static class SelectOption<T> extends Option<T> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Hint.NONE,
+ Hint.GENERATED,
+ Hint.INSECURE_FORM,
+ Hint.DUPLICATE_USERNAME,
+ Hint.MATCHING_ORIGIN
+ })
+ public @interface SelectOptionHint {}
+
+ /** Hint types for selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Auto-generated password. A new password-only login entry containing a secure generated
+ * password.
+ */
+ public static final int GENERATED = 1 << 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+
+ /**
+ * The username is shared with another login entry. There are multiple login entries in the
+ * options that share the same username. You may have to disambiguate the login entry, e.g.,
+ * using the last date of modification and its origin.
+ */
+ public static final int DUPLICATE_USERNAME = 1 << 2;
+
+ /**
+ * The login entry's origin matches the login form origin. The login was saved from the same
+ * origin it is being requested for, rather than for a subdomain.
+ */
+ public static final int MATCHING_ORIGIN = 1 << 3;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ @Override
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("SelectOption {");
+ builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}");
+ return builder.toString();
+ }
+ }
+
+ /** Holds information required to process login saving requests. */
+ public static class LoginSaveOption extends SaveOption<LoginEntry> {
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login save option.
+ *
+ * @param value The {@link LoginEntry} login entry to be saved.
+ */
+ public LoginSaveOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address saving requests. */
+ public static class AddressSaveOption extends SaveOption<Address> {
+ /**
+ * Construct a address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct an address save option.
+ *
+ * @param value The {@link Address} address entry to be saved.
+ */
+ public AddressSaveOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card saving requests. */
+ public static class CreditCardSaveOption extends SaveOption<CreditCard> {
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSaveOption(
+ final @NonNull CreditCard value, final @SaveOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card save option.
+ *
+ * @param value The {@link CreditCard} credit card entry to be saved.
+ */
+ public CreditCardSaveOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process login selection requests. */
+ public static class LoginSelectOption extends SelectOption<LoginEntry> {
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ LoginSelectOption(
+ final @NonNull LoginEntry value, final @SelectOptionHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a login select option.
+ *
+ * @param value The {@link LoginEntry} login entry selection option.
+ */
+ public LoginSelectOption(final @NonNull LoginEntry value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final LoginEntry value = new LoginEntry(bundle.getBundle("value"));
+
+ return new LoginSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process credit card selection requests. */
+ public static class CreditCardSelectOption extends SelectOption<CreditCard> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface CreditCardSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ CreditCardSelectOption(
+ final @NonNull CreditCard value, final @CreditCardSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link CreditCard} credit card entry selection option.
+ */
+ public CreditCardSelectOption(final @NonNull CreditCard value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull CreditCardSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final CreditCard value = new CreditCard(bundle.getBundle("value"));
+
+ return new CreditCardSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /** Holds information required to process address selection requests. */
+ public static class AddressSelectOption extends SelectOption<Address> {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {Hint.NONE, Hint.INSECURE_FORM})
+ public @interface AddressSelectHint {}
+
+ /** Hint types for credit card selection requests. */
+ public static class Hint {
+ public static final int NONE = 0;
+
+ /**
+ * Insecure context. The form or transmission mechanics are considered insecure. This is the
+ * case when the form is served via http or submitted insecurely.
+ */
+ public static final int INSECURE_FORM = 1 << 1;
+ }
+
+ /**
+ * Construct a credit card select option.
+ *
+ * @param value The {@link LoginEntry} credit card entry selection option.
+ * @param hint The {@link Hint} detailing the type of the option.
+ */
+ /* package */ AddressSelectOption(
+ final @NonNull Address value, final @AddressSelectHint int hint) {
+ super(value, hint);
+ }
+
+ /**
+ * Construct a address select option.
+ *
+ * @param value The {@link Address} address entry selection option.
+ */
+ public AddressSelectOption(final @NonNull Address value) {
+ this(value, Hint.NONE);
+ }
+
+ /* package */ static @NonNull AddressSelectOption fromBundle(
+ final @NonNull GeckoBundle bundle) {
+ final int hint = bundle.getInt("hint");
+ final Address value = new Address(bundle.getBundle("value"));
+
+ return new AddressSelectOption(value, hint);
+ }
+
+ @Override
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putBundle(VALUE_KEY, value.toBundle());
+ bundle.putInt(HINT_KEY, hint);
+ return bundle;
+ }
+ }
+
+ /* package */ static final class StorageProxy implements BundleEventListener {
+ private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login";
+ private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard";
+ private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address";
+ private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login";
+ private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard";
+ private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address";
+ private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login";
+
+ private @Nullable StorageDelegate mDelegate;
+
+ public StorageProxy() {}
+
+ private void registerListener() {
+ EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null);
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ private void unregisterListener() {
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(
+ this,
+ FETCH_LOGIN_EVENT,
+ FETCH_CREDIT_CARD_EVENT,
+ FETCH_ADDRESS_EVENT,
+ SAVE_LOGIN_EVENT,
+ SAVE_CREDIT_CARD_EVENT,
+ SAVE_ADDRESS_EVENT,
+ USED_LOGIN_EVENT);
+ }
+
+ public synchronized void setDelegate(final @Nullable StorageDelegate delegate) {
+ if (mDelegate == delegate) {
+ return;
+ }
+ if (mDelegate != null) {
+ unregisterListener();
+ }
+
+ mDelegate = delegate;
+
+ if (mDelegate != null) {
+ registerListener();
+ }
+ }
+
+ public synchronized @Nullable StorageDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ @Override // BundleEventListener
+ public synchronized void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (mDelegate == null) {
+ if (callback != null) {
+ callback.sendError("No StorageDelegate attached");
+ }
+ return;
+ }
+
+ if (FETCH_LOGIN_EVENT.equals(event)) {
+ final String domain = message.getString("domain");
+ final GeckoResult<Autocomplete.LoginEntry[]> result =
+ domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ logins -> {
+ if (logins == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] loginBundles = new GeckoBundle[logins.length];
+ for (int i = 0; i < logins.length; ++i) {
+ loginBundles[i] = logins[i].toBundle();
+ }
+
+ return loginBundles;
+ }));
+ } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.CreditCard[]> result = mDelegate.onCreditCardFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ creditCards -> {
+ if (creditCards == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length];
+ for (int i = 0; i < creditCards.length; ++i) {
+ creditCardBundles[i] = creditCards[i].toBundle();
+ }
+
+ return creditCardBundles;
+ }));
+ } else if (FETCH_ADDRESS_EVENT.equals(event)) {
+ final GeckoResult<Autocomplete.Address[]> result = mDelegate.onAddressFetch();
+
+ if (result == null) {
+ callback.sendSuccess(new GeckoBundle[0]);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ addresses -> {
+ if (addresses == null) {
+ return new GeckoBundle[0];
+ }
+
+ // This is a one-liner with streams (API level 24).
+ final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length];
+ for (int i = 0; i < addresses.length; ++i) {
+ addressBundles[i] = addresses[i].toBundle();
+ }
+
+ return addressBundles;
+ }));
+ } else if (SAVE_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+
+ mDelegate.onLoginSave(login);
+ } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) {
+ final GeckoBundle creditCardBundle = message.getBundle("creditCard");
+ final CreditCard creditCard = new CreditCard(creditCardBundle);
+
+ mDelegate.onCreditCardSave(creditCard);
+ } else if (SAVE_ADDRESS_EVENT.equals(event)) {
+ final GeckoBundle addressBundle = message.getBundle("address");
+ final Address address = new Address(addressBundle);
+
+ mDelegate.onAddressSave(address);
+ } else if (USED_LOGIN_EVENT.equals(event)) {
+ final GeckoBundle loginBundle = message.getBundle("login");
+ final LoginEntry login = new LoginEntry(loginBundle);
+ final int fields = message.getInt("usedFields");
+
+ mDelegate.onLoginUsed(login, fields);
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java
new file mode 100644
index 0000000000..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 <input type="text">} */
+ public static final int TEXT = 0;
+
+ /** Indicates that a node is a number input type. Example: {@code <input type="number">} */
+ public static final int NUMBER = 1;
+
+ /** Indicates that a node is a phone input type. Example: {@code <input type="tel">} */
+ public static final int PHONE = 2;
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public static @Nullable String toString(final @AutofillInputType int type) {
+ final int idx = type + 1;
+ final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"};
+
+ if (idx < 0 || idx >= map.length) {
+ return null;
+ }
+ return map[idx];
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE})
+ public @interface AutofillInputType {}
+
+ /** Represents autofill data associated to a {@link Node}. */
+ public static class NodeData {
+ /** Autofill id for this node. */
+ final int id;
+
+ String value;
+ Node node;
+ EventCallback callback;
+
+ NodeData(final int id, final Node node) {
+ this.id = id;
+ this.node = node;
+ }
+
+ /**
+ * Gets the value for this node.
+ *
+ * @return a String representing the value for this node.
+ */
+ @AnyThread
+ public @Nullable String getValue() {
+ return value;
+ }
+
+ /**
+ * Returns the autofill id for this node.
+ *
+ * @return an int representing the id for this node.
+ */
+ @AnyThread
+ public int getId() {
+ return id;
+ }
+ }
+
+ /** Represents an autofill session. A session holds the autofill nodes and state of a page. */
+ public static final class Session {
+ private static final String LOGTAG = "AutofillSession";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private Node mRoot;
+ private HashMap<String, NodeData> mUuidToNodeData;
+ private SparseArray<Node> mIdToNode;
+ private int mCurrentIndex = 0;
+ private String mId = null;
+
+ // We can't store the Node directly because it might be updated by subsequent NodeAdd calls.
+ private String mFocusedUuid = null;
+
+ /* package */ Session(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ // Dummy session until a real one gets created
+ clear(UUID.randomUUID().toString());
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Rect getDefaultDimensions() {
+ final Rect rect = new Rect();
+ mGeckoSession.getSurfaceBounds(rect);
+ return rect;
+ }
+
+ /* package */ void clear(final String newSessionId) {
+ mId = newSessionId;
+ mFocusedUuid = null;
+ mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId);
+ mIdToNode = new SparseArray<>();
+ mUuidToNodeData = new HashMap<>();
+ addNode(mRoot);
+ }
+
+ /* package */ boolean isEmpty() {
+ // Root data is always there
+ return mUuidToNodeData.size() == 1;
+ }
+
+ /**
+ * Get data for the given node.
+ *
+ * @param node the {@link Node} get data for.
+ * @return the {@link NodeData} for the given node.
+ */
+ @UiThread
+ public @NonNull NodeData dataFor(final @NonNull Node node) {
+ final NodeData data = mUuidToNodeData.get(node.getUuid());
+ Objects.requireNonNull(data);
+ return data;
+ }
+
+ /**
+ * Perform auto-fill using the specified values.
+ *
+ * @param values Map of auto-fill IDs to values.
+ */
+ @UiThread
+ public void autofill(@NonNull final SparseArray<CharSequence> values) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isEmpty()) {
+ return;
+ }
+
+ final HashMap<Node, GeckoBundle> valueBundles = new HashMap<>();
+
+ for (int i = 0; i < values.size(); i++) {
+ final int id = values.keyAt(i);
+ final Node node = getNode(id);
+ if (node == null) {
+ Log.w(LOGTAG, "Could not find node id=" + id);
+ continue;
+ }
+
+ final CharSequence value = values.valueAt(i);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value);
+ }
+
+ if (node == getRoot()) {
+ // We cannot autofill the session root as it does not correspond to a
+ // real element on the page.
+ Log.w(LOGTAG, "Ignoring autofill on session root.");
+ continue;
+ }
+
+ final Node root = node.getRoot();
+ if (!valueBundles.containsKey(root)) {
+ valueBundles.put(root, new GeckoBundle());
+ }
+ valueBundles.get(root).putString(node.getUuid(), String.valueOf(value));
+ }
+
+ for (final Node root : valueBundles.keySet()) {
+ final NodeData data = dataFor(root);
+ Objects.requireNonNull(data);
+ final EventCallback callback = data.callback;
+ callback.sendSuccess(valueBundles.get(root));
+ }
+ }
+
+ /* package */ void addRoot(@NonNull final Node node, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addRoot: " + node);
+ }
+
+ mRoot.addChild(node);
+ addNode(node);
+ dataFor(node).callback = callback;
+ }
+
+ /* package */ void addNode(@NonNull final Node node) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "addNode: " + node);
+ }
+
+ NodeData data = mUuidToNodeData.get(node.getUuid());
+ if (data == null) {
+ final int nodeId = mCurrentIndex++;
+ data = new NodeData(nodeId, node);
+ mUuidToNodeData.put(node.getUuid(), data);
+ } else {
+ data.node = node;
+ }
+
+ mIdToNode.put(data.id, node);
+ for (final Node child : node.getChildren()) {
+ addNode(child);
+ }
+ }
+
+ /**
+ * Returns true if the node is currently visible in the page.
+ *
+ * @param node the {@link Node} instance
+ * @return true if the node is visible, false otherwise.
+ */
+ @UiThread
+ public boolean isVisible(final @NonNull Node node) {
+ if (!Objects.equals(node.mSessionId, mId)) {
+ Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId);
+ return false;
+ }
+ if (mRoot == node) {
+ // The root is always visible
+ return true;
+ }
+ final Node focused = getFocused();
+ if (focused == null) {
+ return false;
+ }
+ final Node focusedRoot = focused.getRoot();
+ final Node focusedParent = focused.getParent();
+
+ final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null;
+ final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null;
+
+ return (focusedParent != null && focusedParent.getUuid().equals(parentUuid))
+ || (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid));
+ }
+
+ /**
+ * Returns the currently focused node.
+ *
+ * @return a reference to the {@link Node} that is currently focused or null if no node is
+ * currently focused.
+ */
+ @UiThread
+ public @Nullable Node getFocused() {
+ return getNode(mFocusedUuid);
+ }
+
+ /* package */ void setFocus(final Node node) {
+ mFocusedUuid = node != null ? node.getUuid() : null;
+ }
+
+ /**
+ * Returns the currently focused node data.
+ *
+ * @return a refernce to {@link NodeData} or null if no node is focused.
+ */
+ @UiThread
+ public @Nullable NodeData getFocusedData() {
+ final Node focused = getFocused();
+ return focused != null ? dataFor(focused) : null;
+ }
+
+ /* package */ @Nullable
+ Node getNode(final String uuid) {
+ if (uuid == null) {
+ return null;
+ }
+ final NodeData nodeData = mUuidToNodeData.get(uuid);
+ if (nodeData == null) {
+ return null;
+ }
+ return nodeData.node;
+ }
+
+ /* package */ Node getNode(final int id) {
+ return mIdToNode.get(id);
+ }
+
+ /**
+ * Get the root node of the session tree. Each session is managed in a tree with a virtual root
+ * node for the document.
+ *
+ * @return The root {@link Node} for this session.
+ */
+ @AnyThread
+ public @NonNull Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */ String getId() {
+ return mId;
+ }
+
+ @Override
+ @UiThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Session {");
+ final Node focused = getFocused();
+ builder
+ .append("id=")
+ .append(mId)
+ .append(", focused=")
+ .append(mFocusedUuid)
+ .append(", focusedRoot=")
+ .append(
+ (focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null)
+ .append(", root=")
+ .append(getRoot())
+ .append("}");
+ return builder.toString();
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ @NonNull final View view, @NonNull final ViewStructure structure, final int flags) {
+ ThreadUtils.assertOnUiThread();
+ fillViewStructure(getRoot(), view, structure, flags);
+ }
+
+ @TargetApi(23)
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void fillViewStructure(
+ final @NonNull Node node,
+ @NonNull final View view,
+ @NonNull final ViewStructure structure,
+ final int flags) {
+ ThreadUtils.assertOnUiThread();
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "fillViewStructure");
+ }
+
+ final NodeData data = dataFor(node);
+ if (data == null) {
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillId(view.getAutofillId(), data.id);
+ structure.setWebDomain(node.getDomain());
+ structure.setAutofillValue(AutofillValue.forText(data.value));
+ }
+
+ structure.setId(data.id, null, null, null);
+ // This dimensions doesn't seem to used for autofill service.
+ structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height());
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ final ViewStructure.HtmlInfo.Builder htmlBuilder =
+ structure.newHtmlInfoBuilder(node.getTag());
+ for (final String key : node.getAttributes().keySet()) {
+ htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key)));
+ }
+
+ structure.setHtmlInfo(htmlBuilder.build());
+ }
+
+ structure.setChildCount(node.getChildren().size());
+ int childCount = 0;
+
+ for (final Node child : node.getChildren()) {
+ final ViewStructure childStructure = structure.newChild(childCount);
+ fillViewStructure(child, view, childStructure, flags);
+ childCount++;
+ }
+
+ switch (node.getTag()) {
+ case "input":
+ case "textarea":
+ structure.setClassName("android.widget.EditText");
+ structure.setEnabled(node.getEnabled());
+ structure.setFocusable(node.getFocusable());
+ structure.setFocused(node.equals(getFocused()));
+ structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE);
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ structure.setAutofillType(View.AUTOFILL_TYPE_TEXT);
+ }
+ break;
+ default:
+ if (childCount > 0) {
+ structure.setClassName("android.view.ViewGroup");
+ } else {
+ structure.setClassName("android.view.View");
+ }
+ break;
+ }
+
+ if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) {
+ return;
+ }
+ // LastPass will fill password to the field where setAutofillHints
+ // is unset and setInputType is set.
+ switch (node.getHint()) {
+ case Hint.EMAIL_ADDRESS:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
+ break;
+ }
+ case Hint.PASSWORD:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD);
+ break;
+ }
+ case Hint.URI:
+ {
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_URI);
+ break;
+ }
+ case Hint.USERNAME:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME});
+ structure.setInputType(
+ android.text.InputType.TYPE_CLASS_TEXT
+ | android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT);
+ break;
+ }
+ case Hint.NONE:
+ {
+ // Nothing to do.
+ break;
+ }
+ }
+
+ switch (node.getInputType()) {
+ case InputType.NUMBER:
+ {
+ structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER);
+ break;
+ }
+ case InputType.PHONE:
+ {
+ structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE});
+ structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE);
+ break;
+ }
+ case InputType.TEXT:
+ case InputType.NONE:
+ // Nothing to do.
+ break;
+ }
+ }
+ }
+
+ /**
+ * Represents an autofill node. A node is an input element and may contain child nodes forming a
+ * tree.
+ */
+ public static final class Node {
+ private final String mUuid;
+ private final Node mRoot;
+ private final Node mParent;
+ private final @NonNull Rect mDimens;
+ private final @NonNull Rect mScreenRect;
+ private final @NonNull Map<String, Node> mChildren;
+ private final @NonNull Map<String, String> mAttributes;
+ private final boolean mEnabled;
+ private final boolean mFocusable;
+ private final @AutofillHint int mHint;
+ private final @AutofillInputType int mInputType;
+ private final @NonNull String mTag;
+ private final @NonNull String mDomain;
+ private final String mSessionId;
+
+ /* package */
+ @NonNull
+ String getUuid() {
+ return mUuid;
+ }
+
+ /* package */
+ @Nullable
+ Node getRoot() {
+ return mRoot;
+ }
+
+ /* package */
+ @Nullable
+ Node getParent() {
+ return mParent;
+ }
+
+ /**
+ * Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their
+ * proper dimensions.
+ *
+ * @return The dimensions of this node.
+ */
+ @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<Node> getChildren() {
+ return mChildren.values();
+ }
+
+ /* package */
+ @NonNull
+ Node addChild(@NonNull final Node child) {
+ mChildren.put(child.getUuid(), child);
+ return this;
+ }
+
+ /**
+ * Get HTML attributes for this node.
+ *
+ * @return The HTML attributes for this node.
+ */
+ @AnyThread
+ public @NonNull Map<String, String> getAttributes() {
+ return mAttributes;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable String getAttribute(@NonNull final String key) {
+ return mAttributes.get(key);
+ }
+
+ /**
+ * Get whether or not this node is enabled.
+ *
+ * @return True if the node is enabled, false otherwise.
+ */
+ @AnyThread
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ /**
+ * Get whether or not this node is focusable.
+ *
+ * @return True if the node is focusable, false otherwise.
+ */
+ @AnyThread
+ public boolean getFocusable() {
+ return mFocusable;
+ }
+
+ /**
+ * Get the hint for the type of data contained in this node.
+ *
+ * @return The input data hint for this node, one of {@link Hint}.
+ */
+ @AnyThread
+ public @AutofillHint int getHint() {
+ return mHint;
+ }
+
+ /**
+ * Get the input type of this node.
+ *
+ * @return The input type of this node, one of {@link InputType}.
+ */
+ @AnyThread
+ public @AutofillInputType int getInputType() {
+ return mInputType;
+ }
+
+ /**
+ * Get the HTML tag of this node.
+ *
+ * @return The HTML tag of this node.
+ */
+ @AnyThread
+ public @NonNull String getTag() {
+ return mTag;
+ }
+
+ /**
+ * Get web domain of this node.
+ *
+ * @return The domain of this node.
+ */
+ @AnyThread
+ public @NonNull String getDomain() {
+ return mDomain;
+ }
+
+ /* package */
+ static Node newDummyRoot(final Rect dimensions, final String sessionId) {
+ return new Node(dimensions, sessionId);
+ }
+
+ /* package */ Node(final Rect dimensions, final String sessionId) {
+ mRoot = null;
+ mParent = null;
+ mUuid = UUID.randomUUID().toString();
+ mDimens = dimensions;
+ mScreenRect = new Rect();
+ mSessionId = sessionId;
+ mAttributes = new ArrayMap<>();
+ mEnabled = false;
+ mFocusable = false;
+ mHint = Hint.NONE;
+ mInputType = InputType.NONE;
+ mTag = "";
+ mDomain = "";
+ mChildren = new HashMap<>();
+ }
+
+ @Override
+ @AnyThread
+ public String toString() {
+ final StringBuilder builder = new StringBuilder("Node {");
+ builder
+ .append("uuid=")
+ .append(mUuid)
+ .append(", sessionId=")
+ .append(mSessionId)
+ .append(", parent=")
+ .append(mParent != null ? mParent.getUuid() : null)
+ .append(", root=")
+ .append(mRoot != null ? mRoot.getUuid() : null)
+ .append(", dims=")
+ .append(getDimensions().toShortString())
+ .append(", screenRect=")
+ .append(getScreenRect().toShortString())
+ .append(", children=[");
+
+ for (final Node child : mChildren.values()) {
+ builder.append(child.getUuid()).append(", ");
+ }
+
+ builder
+ .append("]")
+ .append(", attrs=")
+ .append(mAttributes)
+ .append(", enabled=")
+ .append(mEnabled)
+ .append(", focusable=")
+ .append(mFocusable)
+ .append(", hint=")
+ .append(Hint.toString(mHint))
+ .append(", type=")
+ .append(InputType.toString(mInputType))
+ .append(", tag=")
+ .append(mTag)
+ .append(", domain=")
+ .append(mDomain)
+ .append("}");
+
+ return builder.toString();
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) {
+ this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId);
+ }
+
+ /* package */ Node(
+ @NonNull final GeckoBundle bundle,
+ final Node root,
+ final Node parent,
+ final Rect defaultDimensions,
+ final String sessionId) {
+ final GeckoBundle bounds = bundle.getBundle("bounds");
+
+ mSessionId = sessionId;
+ mUuid = bundle.getString("uuid");
+ mDomain = bundle.getString("origin", "");
+ final Rect dimens =
+ new Rect(
+ bounds.getInt("left"),
+ bounds.getInt("top"),
+ bounds.getInt("right"),
+ bounds.getInt("bottom"));
+ if (dimens.isEmpty()) {
+ // Some nodes like <html> will have null-dimensions,
+ // we need to set them to the virtual documents dimensions.
+ mDimens = defaultDimensions;
+ } else {
+ mDimens = dimens;
+ }
+ mScreenRect = new Rect();
+
+ mParent = parent;
+ // If the root is null, then this object is the root itself
+ mRoot = root != null ? root : this;
+
+ final GeckoBundle[] children = bundle.getBundleArray("children");
+ final Map<String, Node> childrenMap = new HashMap<>(children != null ? children.length : 0);
+
+ if (children != null) {
+ for (final GeckoBundle childBundle : children) {
+ final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId);
+ childrenMap.put(child.getUuid(), child);
+ }
+ }
+
+ mChildren = childrenMap;
+
+ mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT);
+
+ final GeckoBundle attrs = bundle.getBundle("attributes");
+ final Map<String, String> attributes = new HashMap<>();
+
+ for (final String key : attrs.keys()) {
+ attributes.put(key, String.valueOf(attrs.get(key)));
+ }
+
+ mAttributes = attributes;
+
+ mEnabled =
+ enabledFromBundle(
+ mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false));
+ mFocusable = mEnabled;
+
+ final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT);
+ final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT);
+ mInputType = typeFromBundle(type, hint);
+ mHint = hintFromBundle(type, hint);
+ }
+
+ private boolean enabledFromBundle(
+ final String tag, final boolean editable, final boolean disabled) {
+ switch (tag) {
+ case "input":
+ {
+ if (!editable) {
+ // Don't process non-editable inputs (e.g., type="button").
+ return false;
+ }
+ return !disabled;
+ }
+ case "textarea":
+ return !disabled;
+ default:
+ return false;
+ }
+ }
+
+ private @AutofillHint int hintFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "email":
+ return Hint.EMAIL_ADDRESS;
+ case "password":
+ return Hint.PASSWORD;
+ case "url":
+ return Hint.URI;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return Hint.USERNAME;
+ }
+ break;
+ }
+ }
+
+ return Hint.NONE;
+ }
+
+ private @AutofillInputType int typeFromBundle(final String type, final String hint) {
+ switch (type) {
+ case "password":
+ case "url":
+ case "email":
+ return InputType.TEXT;
+ case "number":
+ return InputType.NUMBER;
+ case "tel":
+ return InputType.PHONE;
+ case "text":
+ {
+ if (hint.equals("username")) {
+ return InputType.TEXT;
+ }
+ break;
+ }
+ }
+
+ return InputType.NONE;
+ }
+ }
+
+ public interface Delegate {
+
+ /**
+ * An autofill session has started. Usually triggered by page load.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionStart(@NonNull final GeckoSession session) {}
+
+ /**
+ * An autofill session has been committed. Triggered by form submission or navigation.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node the node that is being committed.
+ * @param data the node data associated to the node being committed.
+ */
+ @UiThread
+ default void onSessionCommit(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * An autofill session has been canceled. Triggered by page unload.
+ *
+ * @param session The {@link GeckoSession} instance.
+ */
+ @UiThread
+ default void onSessionCancel(@NonNull final GeckoSession session) {}
+
+ /**
+ * A node within the autofill session has been added.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was added.
+ * @param data The {@link NodeData} associated to the note that was added.
+ */
+ @UiThread
+ default void onNodeAdd(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has been removed.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was removed.
+ * @param data The {@link NodeData} associated to the note that was removed.
+ */
+ @UiThread
+ default void onNodeRemove(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has been updated.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param node The {@link Node} that was updated.
+ * @param data The {@link NodeData} associated to the note that was updated.
+ */
+ @UiThread
+ default void onNodeUpdate(
+ @NonNull final GeckoSession session,
+ @NonNull final Node node,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has gained focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param focused The {@link Node} that is now focused.
+ * @param data The {@link NodeData} associated to the note that is now focused.
+ */
+ @UiThread
+ default void onNodeFocus(
+ @NonNull final GeckoSession session,
+ @NonNull final Node focused,
+ @NonNull final NodeData data) {}
+
+ /**
+ * A node within the autofill session has lost focus.
+ *
+ * @param session The {@link GeckoSession} instance.
+ * @param prev The {@link Node} that lost focus.
+ * @param data The {@link NodeData} associated to the note that lost focus.
+ */
+ @UiThread
+ default void onNodeBlur(
+ @NonNull final GeckoSession session,
+ @NonNull final Node prev,
+ @NonNull final NodeData data) {}
+ }
+
+ /* package */ static final class Support implements BundleEventListener {
+ private static final String LOGTAG = "AutofillSupport";
+
+ private @NonNull final GeckoSession mGeckoSession;
+ private @NonNull final Session mAutofillSession;
+ private Delegate mDelegate;
+
+ public Support(@NonNull final GeckoSession geckoSession) {
+ mGeckoSession = geckoSession;
+ mAutofillSession = new Session(mGeckoSession);
+ }
+
+ public void registerListeners() {
+ mGeckoSession
+ .getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:StartAutofill",
+ "GeckoView:AddAutofill",
+ "GeckoView:ClearAutofill",
+ "GeckoView:CommitAutofill",
+ "GeckoView:OnAutofillFocus",
+ "GeckoView:UpdateAutofill");
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ if ("GeckoView:AddAutofill".equals(event)) {
+ addNode(message.getBundle("node"), callback);
+ } else if ("GeckoView:StartAutofill".equals(event)) {
+ start(message.getString("sessionId"));
+ } else if ("GeckoView:ClearAutofill".equals(event)) {
+ clear();
+ } else if ("GeckoView:OnAutofillFocus".equals(event)) {
+ onFocusChanged(message.getBundle("node"));
+ } else if ("GeckoView:CommitAutofill".equals(event)) {
+ commit(message.getBundle("node"));
+ } else if ("GeckoView:UpdateAutofill".equals(event)) {
+ update(message.getBundle("node"));
+ }
+ }
+
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ mDelegate = delegate;
+ }
+
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDelegate;
+ }
+
+ @UiThread
+ public @NonNull Session getAutofillSession() {
+ ThreadUtils.assertOnUiThread();
+
+ return mAutofillSession;
+ }
+
+ /* package */ void addNode(
+ @NonNull final GeckoBundle message, @NonNull final EventCallback callback) {
+ final Session session = getAutofillSession();
+ final Node node = new Node(message, session.getDefaultDimensions(), session.getId());
+
+ session.addRoot(node, callback);
+ addValues(message);
+
+ if (mDelegate != null) {
+ mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ private void addValues(final GeckoBundle message) {
+ final String uuid = message.getString("uuid");
+ if (uuid == null) {
+ return;
+ }
+
+ final String value = message.getString("value");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ Objects.requireNonNull(node);
+ final NodeData data = getAutofillSession().dataFor(node);
+ Objects.requireNonNull(data);
+ data.value = value;
+
+ final GeckoBundle[] children = message.getBundleArray("children");
+ if (children != null) {
+ for (final GeckoBundle child : children) {
+ addValues(child);
+ }
+ }
+ }
+
+ /* package */ void start(@Nullable final String sessionId) {
+ // Make sure we start with a clean session
+ getAutofillSession().clear(sessionId);
+ if (mDelegate != null) {
+ mDelegate.onSessionStart(mGeckoSession);
+ }
+ }
+
+ /* package */ void commit(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+ final Node node = getAutofillSession().getNode(uuid);
+ if (node == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "commit(" + uuid + ")");
+ }
+
+ if (mDelegate != null) {
+ mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void update(@Nullable final GeckoBundle message) {
+ if (getAutofillSession().isEmpty() || message == null) {
+ return;
+ }
+
+ final String uuid = message.getString("uuid");
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "update(" + uuid + ")");
+ }
+
+ final Node node = getAutofillSession().getNode(uuid);
+ final String value = message.getString("value", "");
+
+ if (node == null) {
+ Log.d(LOGTAG, "could not find node " + uuid);
+ return;
+ }
+
+ if (DEBUG) {
+ final NodeData data = getAutofillSession().dataFor(node);
+ Log.d(
+ LOGTAG,
+ "updating node " + uuid + " value from " + data != null
+ ? data.value
+ : null + " to " + value);
+ }
+
+ getAutofillSession().dataFor(node).value = value;
+
+ if (mDelegate != null) {
+ mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node));
+ }
+ }
+
+ /* package */ void clear() {
+ if (getAutofillSession().isEmpty()) {
+ return;
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "clear()");
+ }
+
+ getAutofillSession().clear(null);
+ if (mDelegate != null) {
+ mDelegate.onSessionCancel(mGeckoSession);
+ }
+ }
+
+ /* package */ void onFocusChanged(@Nullable final GeckoBundle message) {
+ final Session session = getAutofillSession();
+ if (session.isEmpty()) {
+ return;
+ }
+
+ final Node prev = getAutofillSession().getFocused();
+ final String prevUuid = prev != null ? prev.getUuid() : null;
+ final String uuid = message != null ? message.getString("uuid") : null;
+
+ final Node focused;
+ if (uuid == null) {
+ focused = null;
+ } else {
+ focused = session.getNode(uuid);
+ if (focused == null) {
+ Log.w(LOGTAG, "Cannot find node uuid=" + uuid);
+ return;
+ }
+ if (message != null) {
+ final RectF screenRectF = message.getRectF("screenRect");
+ focused.setScreenRect(screenRectF);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')');
+ }
+
+ if (Objects.equals(uuid, prevUuid)) {
+ // Nothing changed, nothing to do.
+ return;
+ }
+
+ session.setFocus(focused);
+
+ if (mDelegate != null) {
+ if (prev != null) {
+ mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev));
+ }
+ if (uuid != null) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+
+ @UiThread
+ public void onActiveChanged(final boolean active) {
+ ThreadUtils.assertOnUiThread();
+
+ final Node focused = getAutofillSession().getFocused();
+
+ if (focused == null) {
+ return;
+ }
+
+ if (mDelegate != null) {
+ if (active) {
+ mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ } else {
+ mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused));
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
new file mode 100644
index 0000000000..d135194afa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java
@@ -0,0 +1,20 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * This class exposes the Base64 URL encode/decode functions from Gecko. They are different from
+ * android.util.Base64 in that they always use URL encoding, no padding, and are constant time. The
+ * last bit is important when dealing with values that might be secret as we do with Web Push.
+ */
+/* package */ class Base64Utils {
+ @WrapForJNI
+ public static native byte[] decode(final String data);
+
+ @WrapForJNI
+ public static native String encode(final byte[] data);
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java
new file mode 100644
index 0000000000..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.
+ *
+ * <p>To provide custom actions, extend this class and override the following methods,
+ *
+ * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This
+ * array must include all actions, available or not, and must not change over the class lifetime.
+ *
+ * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently
+ * available.
+ *
+ * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action.
+ *
+ * <p>4) Override {@link #performAction} to perform a custom action when used.
+ */
+@UiThread
+public class BasicSelectionActionDelegate
+ implements ActionMode.Callback, GeckoSession.SelectionActionDelegate {
+ private static final String LOGTAG = "BasicSelectionAction";
+
+ protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT;
+
+ private static final String[] FLOATING_TOOLBAR_ACTIONS =
+ new String[] {
+ ACTION_CUT,
+ ACTION_COPY,
+ ACTION_PASTE,
+ ACTION_SELECT_ALL,
+ ACTION_PASTE_AS_PLAIN_TEXT,
+ ACTION_PROCESS_TEXT
+ };
+ private static final String[] FIXED_TOOLBAR_ACTIONS =
+ new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE};
+
+ // This is limitation of intent text.
+ private static final int MAX_INTENT_TEXT_LENGTH = 100000;
+
+ protected final @NonNull Activity mActivity;
+ protected final boolean mUseFloatingToolbar;
+
+ private boolean mExternalActionsEnabled;
+
+ protected @Nullable ActionMode mActionMode;
+ protected @Nullable GeckoSession mSession;
+ protected @Nullable Selection mSelection;
+ protected boolean mRepopulatedMenu;
+
+ private @Nullable ActionMode mActionModeForClipboardPermission;
+
+ @TargetApi(Build.VERSION_CODES.M)
+ private class Callback2Wrapper extends ActionMode.Callback2 {
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu);
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode);
+ }
+
+ @Override
+ public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
+ super.onGetContentRect(mode, view, outRect);
+ BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect);
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(final @NonNull Activity activity) {
+ this(activity, Build.VERSION.SDK_INT >= 23);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BasicSelectionActionDelegate(
+ final @NonNull Activity activity, final boolean useFloatingToolbar) {
+ mActivity = activity;
+ mUseFloatingToolbar = useFloatingToolbar;
+ mExternalActionsEnabled = true;
+ }
+
+ /**
+ * Set whether to include text actions from other apps in the floating toolbar.
+ *
+ * @param enable True if external actions should be enabled.
+ */
+ public void enableExternalActions(final boolean enable) {
+ ThreadUtils.assertOnUiThread();
+ mExternalActionsEnabled = enable;
+
+ if (mActionMode != null) {
+ mActionMode.invalidate();
+ }
+ }
+
+ /**
+ * Get whether text actions from other apps are enabled.
+ *
+ * @return True if external actions are enabled.
+ */
+ public boolean areExternalActionsEnabled() {
+ return mExternalActionsEnabled;
+ }
+
+ /**
+ * Return list of all actions in proper order, regardless of their availability at present.
+ * Override to add to or remove from the default set.
+ *
+ * @return Array of action IDs in proper order.
+ */
+ protected @NonNull String[] getAllActions() {
+ return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS;
+ }
+
+ /**
+ * Return whether an action is presently available. Override to indicate availability for custom
+ * actions.
+ *
+ * @param id Action ID.
+ * @return True if the action is presently available.
+ */
+ protected boolean isActionAvailable(final @NonNull String id) {
+ if (mSelection == null) {
+ return false;
+ }
+
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) {
+ return false;
+ }
+
+ if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) {
+ return !getProcessTextExportedActivities().isEmpty();
+ }
+
+ return mSelection.isActionAvailable(id);
+ }
+
+ /**
+ * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text
+ * is selected.
+ *
+ * @return list of exported activities
+ */
+ private @NonNull List<ResolveInfo> getProcessTextExportedActivities() {
+ final PackageManager pm = mActivity.getPackageManager();
+ final List<ResolveInfo> resolvedList =
+ pm.queryIntentActivityOptions(
+ null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY);
+ final ArrayList<ResolveInfo> exportedList = new ArrayList<>();
+ for (final ResolveInfo info : resolvedList) {
+ if (info.activityInfo.exported) {
+ exportedList.add(info);
+ }
+ }
+
+ return exportedList;
+ }
+
+ /**
+ * Provides access to whether there are text selection actions available. Override to indicate
+ * availability for custom actions.
+ *
+ * @return True if there are text selection actions available.
+ */
+ public boolean isActionAvailable() {
+ if (mSelection == null) {
+ return false;
+ }
+
+ return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty();
+ }
+
+ /**
+ * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom
+ * action.
+ *
+ * @param id Action ID.
+ * @param item New menu item to prepare.
+ */
+ protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) {
+ switch (id) {
+ case ACTION_CUT:
+ item.setTitle(android.R.string.cut);
+ break;
+ case ACTION_COPY:
+ item.setTitle(android.R.string.copy);
+ break;
+ case ACTION_PASTE:
+ item.setTitle(android.R.string.paste);
+ break;
+ case ACTION_PASTE_AS_PLAIN_TEXT:
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
+ throw new IllegalStateException("Unexpected version for action");
+ }
+ item.setTitle(android.R.string.paste_as_plain_text);
+ break;
+ case ACTION_SELECT_ALL:
+ item.setTitle(android.R.string.selectAll);
+ break;
+ case ACTION_PROCESS_TEXT:
+ throw new IllegalStateException("Unexpected action");
+ }
+ }
+
+ /**
+ * Perform the specified action. Override to perform custom actions.
+ *
+ * @param id Action ID.
+ * @param item Nenu item for the action.
+ * @return True if the action was performed.
+ */
+ protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) {
+ if (ACTION_PROCESS_TEXT.equals(id)) {
+ try {
+ mActivity.startActivity(item.getIntent());
+ } catch (final ActivityNotFoundException e) {
+ Log.e(LOGTAG, "Cannot perform action", e);
+ return false;
+ }
+ return true;
+ }
+
+ if (mSelection == null) {
+ return false;
+ }
+ mSelection.execute(id);
+
+ // Android behavior is to clear selection on copy.
+ if (ACTION_COPY.equals(id)) {
+ if (mUseFloatingToolbar) {
+ clearSelection();
+ } else {
+ mActionMode.finish();
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get the current selection object. This object should not be stored as it does not update when
+ * the selection becomes invalid. Stale actions are ignored.
+ *
+ * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current
+ * action menu. <code>null</code> if no action menu is active.
+ */
+ public @Nullable Selection getSelection() {
+ return mSelection;
+ }
+
+ /** Clear the current selection, if possible. */
+ public void clearSelection() {
+ if (mSelection == null) {
+ return;
+ }
+
+ if (isActionAvailable(ACTION_COLLAPSE_TO_END)) {
+ mSelection.collapseToEnd();
+ } else if (isActionAvailable(ACTION_UNSELECT)) {
+ mSelection.unselect();
+ } else {
+ mSelection.hide();
+ }
+ }
+
+ private String getSelectedText(final int maxLength) {
+ if (mSelection == null) {
+ return "";
+ }
+
+ if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) {
+ return mSelection.text;
+ }
+
+ return mSelection.text.substring(0, maxLength);
+ }
+
+ private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) {
+ final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT);
+ if (resolveInfo != null) {
+ intent.setComponent(
+ new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name));
+ }
+ intent.addCategory(Intent.CATEGORY_DEFAULT);
+ intent.setType("text/plain");
+ // If using large text, anything intent may throw RemoteException.
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH));
+ // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137).
+ intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true);
+ return intent;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ for (final String actionId : allActions) {
+ if (isActionAvailable(actionId)) {
+ if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) {
+ // Android bug where onPrepareActionMode is not called initially.
+ onPrepareActionMode(actionMode, menu);
+ }
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ ThreadUtils.assertOnUiThread();
+ final String[] allActions = getAllActions();
+ boolean changed = false;
+
+ // Whether we are repopulating an existing menu.
+ mRepopulatedMenu = menu.size() != 0;
+
+ // For each action, see if it's available at present, and if necessary,
+ // add to or remove from menu.
+ for (int i = 0; i < allActions.length; i++) {
+ final String actionId = allActions[i];
+ final int menuId = i + Menu.FIRST;
+
+ if (ACTION_PROCESS_TEXT.equals(actionId)) {
+ if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) {
+ final List<ResolveInfo> exportedPackageInfo = getProcessTextExportedActivities();
+ if (!exportedPackageInfo.isEmpty()) {
+ for (final ResolveInfo info : exportedPackageInfo) {
+ final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info);
+ if (isMenuItemAdded) {
+ changed = true;
+ }
+ }
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeGroup(menuId);
+ changed = true;
+ }
+ continue;
+ }
+
+ if (isActionAvailable(actionId)) {
+ if (menu.findItem(menuId) == null) {
+ prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ ""));
+ changed = true;
+ }
+ } else if (menu.findItem(menuId) != null) {
+ menu.removeItem(menuId);
+ changed = true;
+ }
+ }
+ return changed;
+ }
+
+ private boolean addProcessTextMenuItem(
+ final Menu menu, final int menuId, final ResolveInfo info) {
+ boolean isMenuItemAdded = false;
+ try {
+ menu.addIntentOptions(
+ menuId,
+ menuId,
+ menuId,
+ mActivity.getComponentName(),
+ /* specifiec */ null,
+ getProcessTextIntent(info),
+ /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */
+ null);
+ isMenuItemAdded = true;
+ } catch (final RuntimeException e) {
+ if (e.getCause() instanceof TransactionTooLargeException) {
+ // Binder size error. MAX_INTENT_TEXT_LENGTH is still large?
+ Log.e(LOGTAG, "Cannot add intent option", e);
+ } else {
+ throw e;
+ }
+ }
+ return isMenuItemAdded;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ ThreadUtils.assertOnUiThread();
+ MenuItem realMenuItem = null;
+ if (mRepopulatedMenu) {
+ // When we repopulate an existing menu, Android can sometimes give us an old,
+ // deleted MenuItem. Find the current MenuItem that corresponds to the old one.
+ final Menu menu = actionMode.getMenu();
+ final int size = menu.size();
+ for (int i = 0; i < size; i++) {
+ final MenuItem item = menu.getItem(i);
+ if (item == menuItem
+ || (item.getItemId() == menuItem.getItemId()
+ && item.getTitle().equals(menuItem.getTitle()))) {
+ realMenuItem = item;
+ break;
+ }
+ }
+ } else {
+ realMenuItem = menuItem;
+ }
+
+ if (realMenuItem == null) {
+ return false;
+ }
+ final String[] allActions = getAllActions();
+ return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem);
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ ThreadUtils.assertOnUiThread();
+ if (!mUseFloatingToolbar) {
+ clearSelection();
+ }
+ mSession = null;
+ mSelection = null;
+ mActionMode = null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void onGetContentRect(
+ final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) {
+ ThreadUtils.assertOnUiThread();
+ if (mSelection == null || mSelection.screenRect == null) {
+ return;
+ }
+
+ // 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<AllowOrDeny> mResult;
+
+ public ClipboardPermissionCallback(final GeckoResult<AllowOrDeny> result) {
+ mResult = result;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
+ actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ mResult.complete(AllowOrDeny.ALLOW);
+ mResult = null;
+ actionMode.finish();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ if (mResult != null) {
+ mResult.complete(AllowOrDeny.DENY);
+ }
+ BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
+ }
+ }
+
+ /** Callback class of clipboard permission for Android M+ */
+ @TargetApi(Build.VERSION_CODES.M)
+ private class ClipboardPermissionCallbackM extends ActionMode.Callback2 {
+ private @Nullable GeckoResult<AllowOrDeny> mResult;
+ private final @NonNull GeckoSession mSession;
+ private final @Nullable Point mPoint;
+
+ public ClipboardPermissionCallbackM(
+ final @NonNull GeckoSession session,
+ final @Nullable Point screenPoint,
+ final @NonNull GeckoResult<AllowOrDeny> result) {
+ mSession = session;
+ mPoint = screenPoint;
+ mResult = result;
+ }
+
+ @Override
+ public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) {
+ return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission(
+ actionMode, menu);
+ }
+
+ @Override
+ public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) {
+ return false;
+ }
+
+ @Override
+ public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) {
+ mResult.complete(AllowOrDeny.ALLOW);
+ mResult = null;
+ actionMode.finish();
+ return true;
+ }
+
+ @Override
+ public void onDestroyActionMode(final ActionMode actionMode) {
+ if (mResult != null) {
+ mResult.complete(AllowOrDeny.DENY);
+ }
+ BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode);
+ }
+
+ @Override
+ public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) {
+ super.onGetContentRect(mode, view, outRect);
+
+ if (mPoint == null) {
+ return;
+ }
+
+ outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1);
+ }
+ }
+
+ /**
+ * Show action mode bar to request clipboard permission
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param permission An {@link ClipboardPermission} describing the permission being requested.
+ * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the
+ * permission request for this site.
+ */
+ @TargetApi(Build.VERSION_CODES.M)
+ @Override
+ public GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(
+ final GeckoSession session, final ClipboardPermission permission) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoResult<AllowOrDeny> result = new GeckoResult<>();
+
+ if (mActionMode != null) {
+ mActionMode.finish();
+ mActionMode = null;
+ }
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ mActionModeForClipboardPermission = null;
+ }
+
+ if (mUseFloatingToolbar) {
+ mActionModeForClipboardPermission =
+ mActivity.startActionMode(
+ new ClipboardPermissionCallbackM(session, permission.screenPoint, result),
+ ActionMode.TYPE_FLOATING);
+ } else {
+ mActionModeForClipboardPermission =
+ mActivity.startActionMode(new ClipboardPermissionCallback(result));
+ }
+
+ return result;
+ }
+
+ /**
+ * Dismiss action mode for requesting clipboard permission popup or model.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @Override
+ public void onDismissClipboardPermissionRequest(final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mActionModeForClipboardPermission != null) {
+ mActionModeForClipboardPermission.finish();
+ mActionModeForClipboardPermission = null;
+ }
+ }
+
+ /* package */ boolean onCreateActionModeForClipboardPermission(
+ final ActionMode actionMode, final Menu menu) {
+ final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ "");
+ item.setTitle(android.R.string.paste);
+ return true;
+ }
+
+ /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) {
+ mActionModeForClipboardPermission = null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
new file mode 100644
index 0000000000..9162566666
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java
@@ -0,0 +1,15 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import org.mozilla.gecko.util.EventCallback;
+
+/* package */ abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback {
+ @Override
+ public void sendError(final Object response) {
+ completeExceptionally(
+ response != null ? new Exception(response.toString()) : new UnknownError());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
new file mode 100644
index 0000000000..77bca329c4
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java
@@ -0,0 +1,133 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Color;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import java.util.ArrayList;
+import java.util.List;
+import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.ThreadUtils;
+
+@UiThread
+public final class CompositorController {
+ private final GeckoSession.Compositor mCompositor;
+
+ private List<Runnable> mDrawCallbacks;
+ private int mDefaultClearColor = Color.WHITE;
+ private Runnable mFirstPaintCallback;
+
+ /* package */ CompositorController(final GeckoSession session) {
+ mCompositor = session.mCompositor;
+ }
+
+ /* package */ void onCompositorReady() {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ mCompositor.enableLayerUpdateNotifications(mDrawCallbacks != null && !mDrawCallbacks.isEmpty());
+ }
+
+ /* package */ void onCompositorDetached() {
+ if (mDrawCallbacks != null) {
+ mDrawCallbacks.clear();
+ }
+ }
+
+ /* package */ void notifyDrawCallbacks() {
+ if (mDrawCallbacks != null) {
+ for (final Runnable callback : mDrawCallbacks) {
+ callback.run();
+ }
+ }
+ }
+
+ /**
+ * Add a callback to run when drawing (layer update) occurs.
+ *
+ * @param callback Callback to add.
+ */
+ @RobocopTarget
+ public void addDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ mDrawCallbacks = new ArrayList<Runnable>(2);
+ }
+
+ if (mDrawCallbacks.add(callback) && mDrawCallbacks.size() == 1 && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(true);
+ }
+ }
+
+ /**
+ * Remove a previous draw callback.
+ *
+ * @param callback Callback to remove.
+ */
+ @RobocopTarget
+ public void removeDrawCallback(final @NonNull Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDrawCallbacks == null) {
+ return;
+ }
+
+ if (mDrawCallbacks.remove(callback) && mDrawCallbacks.isEmpty() && mCompositor.isReady()) {
+ mCompositor.enableLayerUpdateNotifications(false);
+ }
+ }
+
+ /**
+ * Get the current clear color when drawing.
+ *
+ * @return Curent clear color.
+ */
+ public int getClearColor() {
+ ThreadUtils.assertOnUiThread();
+ return mDefaultClearColor;
+ }
+
+ /**
+ * Set the clear color when drawing. Default is Color.WHITE.
+ *
+ * @param color Clear color.
+ */
+ public void setClearColor(final int color) {
+ ThreadUtils.assertOnUiThread();
+
+ mDefaultClearColor = color;
+ if (mCompositor.isReady()) {
+ mCompositor.setDefaultClearColor(mDefaultClearColor);
+ }
+ }
+
+ /**
+ * Get the current first paint callback.
+ *
+ * @return Current first paint callback or null if not set.
+ */
+ public @Nullable Runnable getFirstPaintCallback() {
+ ThreadUtils.assertOnUiThread();
+ return mFirstPaintCallback;
+ }
+
+ /**
+ * Set a callback to run when a document is first drawn.
+ *
+ * @param callback First paint callback.
+ */
+ public void setFirstPaintCallback(final @Nullable Runnable callback) {
+ ThreadUtils.assertOnUiThread();
+ mFirstPaintCallback = callback;
+ }
+
+ /* package */ void onFirstPaint() {
+ if (mFirstPaintCallback != null) {
+ mFirstPaintCallback.run();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java
new file mode 100644
index 0000000000..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<String, SafeBrowsingProvider> mSafeBrowsingProviders = new HashMap<>();
+
+ private static final SafeBrowsingProvider[] DEFAULT_PROVIDERS = {
+ ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER,
+ ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER
+ };
+
+ @AnyThread
+ public static class Builder extends RuntimeSettings.Builder<Settings> {
+ @Override
+ protected @NonNull Settings newSettings(final @Nullable Settings settings) {
+ return new Settings(settings);
+ }
+
+ /**
+ * Set custom safe browsing providers.
+ *
+ * @param providers one or more custom providers.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ getSettings().setSafeBrowsingProviders(providers);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for phishing threats.
+ *
+ * @param safeBrowsingPhishingTable one or more lists for safe browsing phishing.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingPhishingTable(
+ final @NonNull String[] safeBrowsingPhishingTable) {
+ getSettings().setSafeBrowsingPhishingTable(safeBrowsingPhishingTable);
+ return this;
+ }
+
+ /**
+ * Set the safe browsing table for malware threats.
+ *
+ * @param safeBrowsingMalwareTable one or more lists for safe browsing malware.
+ * @return This Builder instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Builder safeBrowsingMalwareTable(
+ final @NonNull String[] safeBrowsingMalwareTable) {
+ getSettings().setSafeBrowsingMalwareTable(safeBrowsingMalwareTable);
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.AntiTracking} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder antiTracking(final @CBAntiTracking int cat) {
+ getSettings().setAntiTracking(cat);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the
+ * {@link ContentBlocking.SafeBrowsing} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder safeBrowsing(final @CBSafeBrowsing int cat) {
+ getSettings().setSafeBrowsing(cat);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehavior(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehavior(behavior);
+ return this;
+ }
+
+ /**
+ * Set cookie storage behavior in private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ getSettings().setCookieBehaviorPrivateMode(behavior);
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use. Only takes effect if cookie behavior is set
+ * to {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder enhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ getSettings().setEnhancedTrackingProtectionLevel(level);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled. This will block resources
+ * from loading if they are on the social tracking protection list, rather than just blocking
+ * cookies as with normal social tracking protection.
+ *
+ * @param enabled A boolean indicating whether or not strict social tracking protection should
+ * be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder strictSocialTrackingProtection(final boolean enabled) {
+ getSettings().setStrictSocialTrackingProtection(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not to automatically purge tracking cookies. This will purge cookies from
+ * tracking sites that do not have recent user interaction provided that the cookie behavior
+ * is set to either {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether or not cookie purging should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder cookiePurging(final boolean enabled) {
+ getSettings().setCookiePurging(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingMode(final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerMode(mode);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode for private browsing.
+ *
+ * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}.
+ * @return The Builder instance.
+ */
+ public @NonNull Builder cookieBannerHandlingModePrivateBrowsing(
+ final @CBCookieBannerMode int mode) {
+ getSettings().setCookieBannerModePrivateBrowsing(mode);
+ return this;
+ }
+
+ /**
+ * 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<String> mAt =
+ new Pref<String>(
+ "urlclassifier.trackingTable", ContentBlocking.catToAtPref(AntiTracking.DEFAULT));
+ /* package */ final Pref<Boolean> mCm =
+ new Pref<Boolean>("privacy.trackingprotection.cryptomining.enabled", false);
+ /* package */ final Pref<String> mCmList =
+ new Pref<String>(
+ "urlclassifier.features.cryptomining.blacklistTables",
+ ContentBlocking.catToCmListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mFp =
+ new Pref<Boolean>("privacy.trackingprotection.fingerprinting.enabled", false);
+ /* package */ final Pref<String> mFpList =
+ new Pref<String>(
+ "urlclassifier.features.fingerprinting.blacklistTables",
+ ContentBlocking.catToFpListPref(AntiTracking.NONE));
+ /* package */ final Pref<Boolean> mSt =
+ new Pref<Boolean>("privacy.socialtracking.block_cookies.enabled", false);
+ /* package */ final Pref<Boolean> mStStrict =
+ new Pref<Boolean>("privacy.trackingprotection.socialtracking.enabled", false);
+ /* package */ final Pref<String> mStList =
+ new Pref<String>(
+ "urlclassifier.features.socialtracking.annotate.blacklistTables",
+ ContentBlocking.catToStListPref(AntiTracking.NONE));
+
+ /* package */ final Pref<Boolean> mSbMalware =
+ new Pref<Boolean>("browser.safebrowsing.malware.enabled", true);
+ /* package */ final Pref<Boolean> mSbPhishing =
+ new Pref<Boolean>("browser.safebrowsing.phishing.enabled", true);
+ /* package */ final Pref<Integer> mCookieBehavior =
+ new Pref<Integer>("network.cookie.cookieBehavior", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Integer> mCookieBehaviorPrivateMode =
+ new Pref<Integer>(
+ "network.cookie.cookieBehavior.pbmode", CookieBehavior.ACCEPT_NON_TRACKERS);
+ /* package */ final Pref<Boolean> mCookiePurging =
+ new Pref<Boolean>("privacy.purge_trackers.enabled", false);
+
+ /* package */ final Pref<Boolean> mEtpEnabled =
+ new Pref<Boolean>("privacy.trackingprotection.annotate_channels", false);
+ /* package */ final Pref<Boolean> mEtpStrict =
+ new Pref<Boolean>("privacy.annotate_channels.strict_list.enabled", false);
+
+ /* package */ final Pref<Integer> mCbhMode =
+ new Pref<Integer>(
+ "cookiebanners.service.mode", CookieBannerMode.COOKIE_BANNER_MODE_DISABLED);
+ /* package */ final Pref<Integer> mCbhModePrivateBrowsing =
+ new Pref<Integer>(
+ "cookiebanners.service.mode.privateBrowsing",
+ CookieBannerMode.COOKIE_BANNER_MODE_REJECT);
+
+ /* package */ final Pref<Boolean> mChbDetectOnlyMode =
+ new Pref<Boolean>("cookiebanners.service.detectOnly", false);
+
+ /* package */ final Pref<String> mSafeBrowsingMalwareTable =
+ new Pref<>(
+ "urlclassifier.malwareTable",
+ ContentBlocking.listsToPref(
+ "goog-malware-proto",
+ "goog-unwanted-proto",
+ "moztest-harmful-simple",
+ "moztest-malware-simple",
+ "moztest-unwanted-simple"));
+ /* package */ final Pref<String> mSafeBrowsingPhishingTable =
+ new Pref<>(
+ "urlclassifier.phishTable",
+ ContentBlocking.listsToPref(
+ // In official builds, we are allowed to use Google's private phishing
+ // list (see bug 1288840).
+ BuildConfig.MOZILLA_OFFICIAL ? "goog-phish-proto" : "googpub-phish-proto",
+ "moztest-phish-simple"));
+
+ /** Construct default settings. */
+ /* package */ Settings() {
+ this(null /* settings */);
+ }
+
+ /**
+ * Copy-construct settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(final @Nullable Settings settings) {
+ this(null /* parent */, settings);
+ }
+
+ /**
+ * Copy-construct nested settings.
+ *
+ * @param parent The parent settings used for nesting.
+ * @param settings Copy from this settings.
+ */
+ /* package */ Settings(
+ final @Nullable RuntimeSettings parent, final @Nullable Settings settings) {
+ super(parent);
+
+ if (settings != null) {
+ updatePrefs(settings);
+ } else {
+ // Set default browsing providers
+ setSafeBrowsingProviders(DEFAULT_PROVIDERS);
+ }
+ }
+
+ @Override
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ super.updatePrefs(settings);
+
+ final ContentBlocking.Settings source = (ContentBlocking.Settings) settings;
+ for (final SafeBrowsingProvider provider : source.mSafeBrowsingProviders.values()) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+ }
+
+ /**
+ * Get the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * @return an unmodifiable collection of {@link SafeBrowsingProvider}
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Collection<SafeBrowsingProvider> getSafeBrowsingProviders() {
+ return Collections.unmodifiableCollection(mSafeBrowsingProviders.values());
+ }
+
+ /**
+ * Sets the collection of {@link SafeBrowsingProvider} for this runtime.
+ *
+ * <p>By default the collection is composed of {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER}.
+ *
+ * @param providers {@link SafeBrowsingProvider} instances for this runtime.
+ * @return the {@link Settings} instance.
+ * @see SafeBrowsingProvider
+ */
+ public @NonNull Settings setSafeBrowsingProviders(
+ final @NonNull SafeBrowsingProvider... providers) {
+ mSafeBrowsingProviders.clear();
+
+ for (final SafeBrowsingProvider provider : providers) {
+ mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider));
+ }
+
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Phishing. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Phishing feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingPhishingTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingPhishingTable.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Phishing.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Phishing feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingPhishingTable(final @NonNull String... table) {
+ mSafeBrowsingPhishingTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Get the table for SafeBrowsing Malware. The identifiers present in this table must match one
+ * of the identifiers present in {@link SafeBrowsingProvider#getLists}.
+ *
+ * @return an array of identifiers for SafeBrowsing's Malware feature
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull String[] getSafeBrowsingMalwareTable() {
+ return ContentBlocking.prefToLists(mSafeBrowsingMalwareTable.get());
+ }
+
+ /**
+ * Sets the table for SafeBrowsing Malware.
+ *
+ * @param table an array of identifiers for SafeBrowsing's Malware feature.
+ * @return this {@link Settings} instance.
+ * @see SafeBrowsingProvider.Builder#lists
+ */
+ public @NonNull Settings setSafeBrowsingMalwareTable(final @NonNull String... table) {
+ mSafeBrowsingMalwareTable.commit(ContentBlocking.listsToPref(table));
+ return this;
+ }
+
+ /**
+ * Set anti-tracking categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.AntiTracking} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setAntiTracking(final @CBAntiTracking int cat) {
+ mAt.commit(ContentBlocking.catToAtPref(cat));
+
+ mCm.commit(ContentBlocking.catToCmPref(cat));
+ mCmList.commit(ContentBlocking.catToCmListPref(cat));
+
+ mFp.commit(ContentBlocking.catToFpPref(cat));
+ mFpList.commit(ContentBlocking.catToFpListPref(cat));
+
+ mSt.commit(ContentBlocking.catToStPref(cat));
+ mStList.commit(ContentBlocking.catToStListPref(cat));
+ return this;
+ }
+
+ /**
+ * Set the ETP behavior level.
+ *
+ * @param level The level of ETP blocking to use; must be one of {@link
+ * ContentBlocking.EtpLevel} flags. Only takes effect if the cookie behavior is {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setEnhancedTrackingProtectionLevel(final @CBEtpLevel int level) {
+ mEtpEnabled.commit(
+ level == ContentBlocking.EtpLevel.DEFAULT || level == ContentBlocking.EtpLevel.STRICT);
+ mEtpStrict.commit(level == ContentBlocking.EtpLevel.STRICT);
+ return this;
+ }
+
+ /**
+ * Set whether or not strict social tracking protection is enabled (ie, whether to block content
+ * or just cookies). Will only block if social tracking protection lists are supplied to {@link
+ * #setAntiTracking}.
+ *
+ * @param enabled A boolean indicating whether or not to enable strict social tracking
+ * protection.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setStrictSocialTrackingProtection(final boolean enabled) {
+ mStStrict.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set safe browsing categories.
+ *
+ * @param cat The categories of resources that should be blocked. Use one or more of the {@link
+ * ContentBlocking.SafeBrowsing} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setSafeBrowsing(final @CBSafeBrowsing int cat) {
+ mSbMalware.commit(ContentBlocking.catToSbMalware(cat));
+ mSbPhishing.commit(ContentBlocking.catToSbPhishing(cat));
+ return this;
+ }
+
+ /**
+ * Get the set anti-tracking categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBAntiTracking int getAntiTrackingCategories() {
+ return ContentBlocking.atListToAtCat(mAt.get())
+ | ContentBlocking.cmListToAtCat(mCmList.get())
+ | ContentBlocking.fpListToAtCat(mFpList.get())
+ | ContentBlocking.stListToAtCat(mStList.get());
+ }
+
+ /**
+ * Get the set ETP behavior level.
+ *
+ * @return The current ETP level; one of {@link ContentBlocking.EtpLevel}.
+ */
+ public @CBEtpLevel int getEnhancedTrackingProtectionLevel() {
+ if (mEtpStrict.get()) {
+ return ContentBlocking.EtpLevel.STRICT;
+ } else if (mEtpEnabled.get()) {
+ return ContentBlocking.EtpLevel.DEFAULT;
+ }
+ return ContentBlocking.EtpLevel.NONE;
+ }
+
+ /**
+ * Get whether or not strict social tracking protection is enabled.
+ *
+ * @return A boolean indicating whether or not strict social tracking protection is enabled.
+ */
+ public boolean getStrictSocialTrackingProtection() {
+ return mStStrict.get();
+ }
+
+ /**
+ * Get the set safe browsing categories.
+ *
+ * @return The categories of resources to be blocked.
+ */
+ public @CBSafeBrowsing int getSafeBrowsingCategories() {
+ return ContentBlocking.sbMalwareToSbCat(mSbMalware.get())
+ | ContentBlocking.sbPhishingToSbCat(mSbPhishing.get());
+ }
+
+ /**
+ * Get the assigned cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehavior() {
+ return mCookieBehavior.get();
+ }
+
+ /**
+ * Set cookie storage behavior.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehavior(final @CBCookieBehavior int behavior) {
+ mCookieBehavior.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get the assigned private mode cookie storage behavior.
+ *
+ * @return The assigned behavior, as one of {@link CookieBehavior} flags.
+ */
+ @SuppressLint("WrongConstant")
+ public @CBCookieBehavior int getCookieBehaviorPrivateMode() {
+ return mCookieBehaviorPrivateMode.get();
+ }
+
+ /**
+ * Set cookie storage behavior for private browsing mode.
+ *
+ * @param behavior The storage behavior that should be applied. Use one of the {@link
+ * CookieBehavior} flags.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) {
+ mCookieBehaviorPrivateMode.commit(behavior);
+ return this;
+ }
+
+ /**
+ * Get whether or not cookie purging is enabled.
+ *
+ * @return A boolean indicating whether or not cookie purging is enabled.
+ */
+ public boolean getCookiePurging() {
+ return mCookiePurging.get();
+ }
+
+ /**
+ * Enable or disable cookie purging. This will automatically purge cookies from tracking sites
+ * that have no recent user interaction, provided the cookie behavior is set to {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link
+ * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}.
+ *
+ * @param enabled A boolean indicating whether to enable cookie purging.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookiePurging(final boolean enabled) {
+ mCookiePurging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Set the Cookie Banner Handling Mode to the new provided {@link CBCookieBannerMode} value.
+ *
+ * @param mode Integer indicating the new mode.
+ * @return This Settings instance.
+ */
+ public @NonNull Settings setCookieBannerMode(final @CBCookieBannerMode int mode) {
+ mCbhMode.commit(mode);
+ return this;
+ }
+
+ /**
+ * 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<Settings> CREATOR =
+ new Parcelable.Creator<Settings>() {
+ @Override
+ public Settings createFromParcel(final Parcel in) {
+ final Settings settings = new Settings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public Settings[] newArray(final int size) {
+ return new Settings[size];
+ }
+ };
+ }
+
+ /**
+ * Holds configuration for a SafeBrowsing provider. <br>
+ * <br>
+ * This class can be used to modify existing configuration for SafeBrowsing providers or to add a
+ * custom SafeBrowsing provider to the app. <br>
+ * <br>
+ * Default configuration for Google's SafeBrowsing servers can be found at {@link
+ * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER} and {@link
+ * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER}. <br>
+ * <br>
+ * This class is immutable, once constructed its values cannot be changed. <br>
+ * <br>
+ * You can, however, use the {@link #from} method to build upon an existing configuration. For
+ * example to override the Google's server configuration, you can do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider override = SafeBrowsingProvider
+ * .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER)
+ * .getHashUrl("http://my-custom-server.com/...")
+ * .updateUrl("http://my-custom-server.com/...")
+ * .build();
+ *
+ * runtime.getContentBlocking().setSafeBrowsingProviders(override);
+ * </code></pre>
+ *
+ * This will override the configuration. <br>
+ * <br>
+ * You can also add a custom SafeBrowsing provider using the {@link #withName} method. For
+ * example, to add a custom provider that provides the list <code>testprovider-phish-digest256
+ * </code> do the following: <br>
+ *
+ * <pre><code>
+ * SafeBrowsingProvider custom = SafeBrowsingProvider
+ * .withName("custom-provider")
+ * .version("2.2")
+ * .lists("testprovider-phish-digest256")
+ * .updateUrl("http://my-custom-server2.com/...")
+ * .getHashUrl("http://my-custom-server2.com/...")
+ * .build();
+ * </code></pre>
+ *
+ * And then add the custom provider (adding optionally existing providers): <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingProviders(
+ * custom,
+ * // Add this if you want to keep the existing configuration too.
+ * ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER,
+ * ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER);
+ * </code></pre>
+ *
+ * And set the list in the phishing configuration <br>
+ *
+ * <pre><code>
+ * runtime.getContentBlocking().setSafeBrowsingPhishingTable(
+ * "testprovider-phish-digest256",
+ * // Existing configuration
+ * "goog-phish-proto");
+ * </code></pre>
+ *
+ * Note that any list present in the phishing or malware tables need to appear in one safe
+ * browsing provider's {@link #getLists} property.
+ *
+ * <p>See also <a href="https://developers.google.com/safe-browsing/v4">safe-browsing/v4</a>.
+ */
+ @AnyThread
+ public static class SafeBrowsingProvider extends RuntimeSettings {
+ private static final String ROOT = "browser.safebrowsing.provider.";
+
+ private final String mName;
+
+ /* package */ final Pref<String> mVersion;
+ /* package */ final Pref<String> mLists;
+ /* package */ final Pref<String> mUpdateUrl;
+ /* package */ final Pref<String> mGetHashUrl;
+ /* package */ final Pref<String> mReportUrl;
+ /* package */ final Pref<String> mReportPhishingMistakeUrl;
+ /* package */ final Pref<String> mReportMalwareMistakeUrl;
+ /* package */ final Pref<String> mAdvisoryUrl;
+ /* package */ final Pref<String> mAdvisoryName;
+ /* package */ final Pref<String> mDataSharingUrl;
+ /* package */ final Pref<Boolean> mDataSharingEnabled;
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} for a provider with the given name.
+ *
+ * <p>Note: the <code>mozilla</code> name is reserved for internal use, and this method will
+ * throw if you attempt to build a provider with that name.
+ *
+ * @param name The name of the provider.
+ * @return a {@link Builder} instance that can be used to build a provider.
+ * @throws IllegalArgumentException if this method is called with <code>name="mozilla"</code>
+ */
+ @NonNull
+ public static Builder withName(final @NonNull String name) {
+ if ("mozilla".equals(name)) {
+ throw new IllegalArgumentException("The 'mozilla' name is reserved for internal use.");
+ }
+ return new Builder(name);
+ }
+
+ /**
+ * Creates a {@link SafeBrowsingProvider.Builder} based on the given provider.
+ *
+ * <p>All properties not otherwise specified will be copied from the provider given in input.
+ *
+ * @param provider The source provider for this builder.
+ * @return a {@link Builder} instance that can be used to create a configuration based on the
+ * builder in input.
+ */
+ @NonNull
+ public static Builder from(final @NonNull SafeBrowsingProvider provider) {
+ return new Builder(provider);
+ }
+
+ @AnyThread
+ public static class Builder {
+ final SafeBrowsingProvider mProvider;
+
+ private Builder(final String name) {
+ mProvider = new SafeBrowsingProvider(name);
+ }
+
+ private Builder(final SafeBrowsingProvider source) {
+ mProvider = new SafeBrowsingProvider(source);
+ }
+
+ /**
+ * Sets the SafeBrowsing protocol session for this provider.
+ *
+ * @param version the version strong, e.g. "2.2" or "4".
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder version(final @NonNull String version) {
+ mProvider.mVersion.set(version);
+ return this;
+ }
+
+ /**
+ * Sets the lists provided by this provider.
+ *
+ * @param lists one or more lists for this provider, e.g. "goog-malware-proto",
+ * "goog-unwanted-proto"
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder lists(final @NonNull String... lists) {
+ mProvider.mLists.set(ContentBlocking.listsToPref(lists));
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @param updateUrl the update url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder updateUrl(final @NonNull String updateUrl) {
+ mProvider.mUpdateUrl.set(updateUrl);
+ return this;
+ }
+
+ /**
+ * Sets the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @param getHashUrl the gethash url endpoint for this provider
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder getHashUrl(final @NonNull String getHashUrl) {
+ mProvider.mGetHashUrl.set(getHashUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @param reportUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportUrl(final @NonNull String reportUrl) {
+ mProvider.mReportUrl.set(reportUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @param reportPhishingMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportPhishingMistakeUrl(
+ final @NonNull String reportPhishingMistakeUrl) {
+ mProvider.mReportPhishingMistakeUrl.set(reportPhishingMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @param reportMalwareMistakeUrl the url endpoint to report a url to this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder reportMalwareMistakeUrl(
+ final @NonNull String reportMalwareMistakeUrl) {
+ mProvider.mReportMalwareMistakeUrl.set(reportMalwareMistakeUrl);
+ return this;
+ }
+
+ /**
+ * Set the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @param advisoryUrl the adivisory page url for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryUrl(final @NonNull String advisoryUrl) {
+ mProvider.mAdvisoryUrl.set(advisoryUrl);
+ return this;
+ }
+
+ /**
+ * Set the advisory name for this provider.
+ *
+ * @param advisoryName the adivisory name for this provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder advisoryName(final @NonNull String advisoryName) {
+ mProvider.mAdvisoryName.set(advisoryName);
+ return this;
+ }
+
+ /**
+ * Set url to share threat data to the provider, if enabled by {@link #dataSharingEnabled}.
+ *
+ * @param dataSharingUrl the url endpoint
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingUrl(final @NonNull String dataSharingUrl) {
+ mProvider.mDataSharingUrl.set(dataSharingUrl);
+ return this;
+ }
+
+ /**
+ * Set whether to share threat data with the provider, off by default.
+ *
+ * @param dataSharingEnabled <code>true</code> if the browser should share threat data with
+ * the provider.
+ * @return this {@link Builder} instance.
+ */
+ public @NonNull Builder dataSharingEnabled(final boolean dataSharingEnabled) {
+ mProvider.mDataSharingEnabled.set(dataSharingEnabled);
+ return this;
+ }
+
+ /**
+ * Build the {@link SafeBrowsingProvider} based on this {@link Builder} instance.
+ *
+ * @return thie {@link SafeBrowsingProvider} instance.
+ */
+ public @NonNull SafeBrowsingProvider build() {
+ return new SafeBrowsingProvider(mProvider);
+ }
+ }
+
+ /* package */ SafeBrowsingProvider(final SafeBrowsingProvider source) {
+ this(/* name */ null, /* parent */ null, source);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ this(/* name */ null, parent, source);
+ }
+
+ /* package */ SafeBrowsingProvider(final String name) {
+ this(name, /* parent */ null, /* source */ null);
+ }
+
+ /* package */ SafeBrowsingProvider(
+ final String name, final RuntimeSettings parent, final SafeBrowsingProvider source) {
+ super(parent);
+
+ if (name != null) {
+ mName = name;
+ } else if (source != null) {
+ mName = source.mName;
+ } else {
+ throw new IllegalArgumentException("Either name or source must be non-null");
+ }
+
+ mVersion = new Pref<>(ROOT + mName + ".pver", null);
+ mLists = new Pref<>(ROOT + mName + ".lists", null);
+ mUpdateUrl = new Pref<>(ROOT + mName + ".updateURL", null);
+ mGetHashUrl = new Pref<>(ROOT + mName + ".gethashURL", null);
+ mReportUrl = new Pref<>(ROOT + mName + ".reportURL", null);
+ mReportPhishingMistakeUrl = new Pref<>(ROOT + mName + ".reportPhishMistakeURL", null);
+ mReportMalwareMistakeUrl = new Pref<>(ROOT + mName + ".reportMalwareMistakeURL", null);
+ mAdvisoryUrl = new Pref<>(ROOT + mName + ".advisoryURL", null);
+ mAdvisoryName = new Pref<>(ROOT + mName + ".advisoryName", null);
+ mDataSharingUrl = new Pref<>(ROOT + mName + ".dataSharingURL", null);
+ mDataSharingEnabled = new Pref<>(ROOT + mName + ".dataSharing.enabled", false);
+
+ if (source != null) {
+ updatePrefs(source);
+ }
+ }
+
+ /**
+ * Get the name of this provider.
+ *
+ * @return a string containing the name.
+ */
+ public @NonNull String getName() {
+ return mName;
+ }
+
+ /**
+ * Get the version for this provider.
+ *
+ * @return a string representing the version, e.g. "2.2" or "4".
+ */
+ public @Nullable String getVersion() {
+ return mVersion.get();
+ }
+
+ /**
+ * Get the lists provided by this provider.
+ *
+ * @return an array of string identifiers for the lists
+ */
+ public @NonNull String[] getLists() {
+ return ContentBlocking.prefToLists(mLists.get());
+ }
+
+ /**
+ * Get the url that will be used to update the threat list for this provider.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch">
+ * v4/threadListUpdates/fetch </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getUpdateUrl() {
+ return mUpdateUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to get the full hashes that match a partial hash.
+ *
+ * <p>See also <a
+ * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find">
+ * v4/fullHashes/find </a>.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getGetHashUrl() {
+ return mGetHashUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url to the SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportUrl() {
+ return mReportUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Phishing to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportPhishingMistakeUrl() {
+ return mReportPhishingMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to report a url mistakenly reported as Malware to the
+ * SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getReportMalwareMistakeUrl() {
+ return mReportMalwareMistakeUrl.get();
+ }
+
+ /**
+ * Get the url that will be used to give a general advisory about this SafeBrowsing provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryUrl() {
+ return mAdvisoryUrl.get();
+ }
+
+ /**
+ * Get the advisory name for this provider.
+ *
+ * @return a string containing the URL.
+ */
+ public @Nullable String getAdvisoryName() {
+ return mAdvisoryName.get();
+ }
+
+ /**
+ * Get the url to share threat data to the provider, if enabled by {@link
+ * #getDataSharingEnabled}.
+ *
+ * @return this {@link Builder} instance.
+ */
+ public @Nullable String getDataSharingUrl() {
+ return mDataSharingUrl.get();
+ }
+
+ /**
+ * Get whether to share threat data with the provider.
+ *
+ * @return <code>true</code> if the browser should whare threat data with the provider, <code>
+ * false</code> otherwise.
+ */
+ public @Nullable Boolean getDataSharingEnabled() {
+ return mDataSharingEnabled.get();
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeValue(mName);
+ super.writeToParcel(out, flags);
+ }
+
+ /** Creator instance for this class. */
+ public static final Parcelable.Creator<SafeBrowsingProvider> CREATOR =
+ new Parcelable.Creator<SafeBrowsingProvider>() {
+ @Override
+ public SafeBrowsingProvider createFromParcel(final Parcel source) {
+ final String name = (String) source.readValue(getClass().getClassLoader());
+ final SafeBrowsingProvider settings = new SafeBrowsingProvider(name);
+ settings.readFromParcel(source);
+ return settings;
+ }
+
+ @Override
+ public SafeBrowsingProvider[] newArray(final int size) {
+ return new SafeBrowsingProvider[size];
+ }
+ };
+ }
+
+ private static String listsToPref(final String... lists) {
+ final StringBuilder prefBuilder = new StringBuilder();
+
+ for (final String list : lists) {
+ if (list.contains(",")) {
+ // We use ',' as the separator, so the list name cannot contain it.
+ // Should never happen.
+ throw new IllegalArgumentException("List name cannot contain ',' character.");
+ }
+
+ prefBuilder.append(list);
+ prefBuilder.append(",");
+ }
+
+ // Remove trailing ","
+ if (lists.length > 0) {
+ prefBuilder.setLength(prefBuilder.length() - 1);
+ }
+
+ return prefBuilder.toString();
+ }
+
+ private static String[] prefToLists(final String pref) {
+ return pref != null ? pref.split(",") : new String[] {};
+ }
+
+ public static class AntiTracking {
+ public static final int NONE = 0;
+
+ /** Block advertisement trackers. */
+ public static final int AD = 1 << 1;
+
+ /** Block analytics trackers. */
+ public static final int ANALYTIC = 1 << 2;
+
+ /**
+ * Block social trackers. Note: This is not the same as "Social Tracking Protection", which is
+ * controlled by {@link #STP}.
+ */
+ public static final int SOCIAL = 1 << 3;
+
+ /** Block content trackers. May cause issues with some web sites. */
+ public static final int CONTENT = 1 << 4;
+
+ /** Block Gecko test trackers (used for tests). */
+ public static final int TEST = 1 << 5;
+
+ /** Block cryptocurrency miners. */
+ public static final int CRYPTOMINING = 1 << 6;
+
+ /** Block fingerprinting trackers. */
+ public static final int FINGERPRINTING = 1 << 7;
+
+ /** Block trackers on the Social Tracking Protection list. */
+ public static final int STP = 1 << 8;
+
+ /** Block ad, analytic, social and test trackers. */
+ public static final int DEFAULT = AD | ANALYTIC | SOCIAL | TEST;
+
+ /** Block all known trackers. May cause issues with some web sites. */
+ public static final int STRICT = DEFAULT | CONTENT | CRYPTOMINING | FINGERPRINTING;
+
+ protected AntiTracking() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ AntiTracking.AD,
+ AntiTracking.ANALYTIC,
+ AntiTracking.SOCIAL,
+ AntiTracking.CONTENT,
+ AntiTracking.TEST,
+ AntiTracking.CRYPTOMINING,
+ AntiTracking.FINGERPRINTING,
+ AntiTracking.DEFAULT,
+ AntiTracking.STRICT,
+ AntiTracking.STP,
+ AntiTracking.NONE
+ })
+ public @interface CBAntiTracking {}
+
+ public static class SafeBrowsing {
+ public static final int NONE = 0;
+
+ /** Block malware sites. */
+ public static final int MALWARE = 1 << 10;
+
+ /** Block unwanted sites. */
+ public static final int UNWANTED = 1 << 11;
+
+ /** Block harmful sites. */
+ public static final int HARMFUL = 1 << 12;
+
+ /** Block phishing sites. */
+ public static final int PHISHING = 1 << 13;
+
+ /** Block all unsafe sites. */
+ public static final int DEFAULT = MALWARE | UNWANTED | HARMFUL | PHISHING;
+
+ protected SafeBrowsing() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SafeBrowsing.MALWARE, SafeBrowsing.UNWANTED,
+ SafeBrowsing.HARMFUL, SafeBrowsing.PHISHING,
+ SafeBrowsing.DEFAULT, SafeBrowsing.NONE
+ })
+ public @interface CBSafeBrowsing {}
+
+ // Sync values with nsICookieService.idl.
+ public static class CookieBehavior {
+ /** Accept first-party and third-party cookies and site data. */
+ public static final int ACCEPT_ALL = 0;
+
+ /**
+ * Accept only first-party cookies and site data to block cookies which are not associated with
+ * the domain of the visited site.
+ */
+ public static final int ACCEPT_FIRST_PARTY = 1;
+
+ /** Do not store any cookies and site data. */
+ public static final int ACCEPT_NONE = 2;
+
+ /**
+ * Accept first-party and third-party cookies and site data only from sites previously visited
+ * in a first-party context.
+ */
+ public static final int ACCEPT_VISITED = 3;
+
+ /**
+ * Accept only first-party and non-tracking third-party cookies and site data to block cookies
+ * which are not associated with the domain of the visited site set by known trackers.
+ */
+ public static final int ACCEPT_NON_TRACKERS = 4;
+
+ /**
+ * Enable dynamic first party isolation (dFPI); this will block third-party tracking cookies in
+ * accordance with the ETP level and isolate non-tracking third-party cookies.
+ */
+ public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5;
+
+ protected CookieBehavior() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CookieBehavior.ACCEPT_ALL, CookieBehavior.ACCEPT_FIRST_PARTY,
+ CookieBehavior.ACCEPT_NONE, CookieBehavior.ACCEPT_VISITED,
+ CookieBehavior.ACCEPT_NON_TRACKERS
+ })
+ public @interface CBCookieBehavior {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT})
+ public @interface CBEtpLevel {}
+
+ /** Possible settings for ETP. */
+ public static class EtpLevel {
+ /** Do not enable ETP at all. */
+ public static final int NONE = 0;
+
+ /** Enable ETP for ads, analytic, and social tracking lists. */
+ public static final int DEFAULT = 1;
+
+ /**
+ * Enable ETP for all of the default lists as well as the content list. May break many sites!
+ */
+ public static final int STRICT = 2;
+ }
+
+ /** Holds content block event details. */
+ public static class BlockEvent {
+ /** The URI of the blocked resource. */
+ public final @NonNull String uri;
+
+ private final @CBAntiTracking int mAntiTrackingCat;
+ private final @CBSafeBrowsing int mSafeBrowsingCat;
+ private final @CBCookieBehavior int mCookieBehaviorCat;
+ private final boolean mIsBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public BlockEvent(
+ @NonNull final String uri,
+ final @CBAntiTracking int atCat,
+ final @CBSafeBrowsing int sbCat,
+ final @CBCookieBehavior int cbCat,
+ final boolean isBlocking) {
+ this.uri = uri;
+ this.mAntiTrackingCat = atCat;
+ this.mSafeBrowsingCat = sbCat;
+ this.mCookieBehaviorCat = cbCat;
+ this.mIsBlocking = isBlocking;
+ }
+
+ /**
+ * The anti-tracking category types of the blocked resource.
+ *
+ * @return One or more of the {@link AntiTracking} flags.
+ */
+ @UiThread
+ public @CBAntiTracking int getAntiTrackingCategory() {
+ return mAntiTrackingCat;
+ }
+
+ /**
+ * The safe browsing category types of the blocked resource.
+ *
+ * @return One or more of the {@link SafeBrowsing} flags.
+ */
+ @UiThread
+ public @CBSafeBrowsing int getSafeBrowsingCategory() {
+ return mSafeBrowsingCat;
+ }
+
+ /**
+ * The cookie types of the blocked resource.
+ *
+ * @return One or more of the {@link CookieBehavior} flags.
+ */
+ @UiThread
+ public @CBCookieBehavior int getCookieBehaviorCategory() {
+ return mCookieBehaviorCat;
+ }
+
+ /* package */ static BlockEvent fromBundle(@NonNull final GeckoBundle bundle) {
+ final String uri = bundle.getString("uri");
+ final String blockedList = bundle.getString("blockedList");
+ final String loadedList = TextUtils.join(",", bundle.getStringArray("loadedLists"));
+ final long error = bundle.getLong("error", 0L);
+ final long category = bundle.getLong("category", 0L);
+
+ final String matchedList = blockedList != null ? blockedList : loadedList;
+
+ // Note: Even if loadedList is non-empty it does not necessarily
+ // mean that the event is not a blocking event.
+ final boolean blocking =
+ (blockedList != null || error != 0L || ContentBlocking.isBlockingGeckoCbCat(category));
+
+ return new BlockEvent(
+ uri,
+ ContentBlocking.atListToAtCat(matchedList)
+ | ContentBlocking.cmListToAtCat(matchedList)
+ | ContentBlocking.fpListToAtCat(matchedList)
+ | ContentBlocking.stListToAtCat(matchedList),
+ ContentBlocking.errorToSbCat(error),
+ ContentBlocking.geckoCatToCbCat(category),
+ blocking);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public boolean isBlocking() {
+ return mIsBlocking;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content blocking events. */
+ public interface Delegate {
+ /**
+ * A content element has been blocked from loading. Set blocked element categories via {@link
+ * GeckoRuntimeSettings} and enable content blocking via {@link GeckoSessionSettings}.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentBlocked(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+
+ /**
+ * A content element that could be blocked has been loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param event The {@link BlockEvent} details.
+ */
+ @UiThread
+ default void onContentLoaded(
+ @NonNull final GeckoSession session, @NonNull final BlockEvent event) {}
+ }
+
+ private static final String TEST = "moztest-track-simple";
+ private static final String AD = "ads-track-digest256";
+ private static final String ANALYTIC = "analytics-track-digest256";
+ private static final String SOCIAL = "social-track-digest256";
+ private static final String CONTENT = "content-track-digest256";
+ private static final String CRYPTOMINING = "base-cryptomining-track-digest256";
+ private static final String FINGERPRINTING = "base-fingerprinting-track-digest256";
+ private static final String STP =
+ "social-tracking-protection-facebook-digest256,social-tracking-protection-linkedin-digest256,social-tracking-protection-twitter-digest256";
+
+ /* package */ static @CBSafeBrowsing int sbMalwareToSbCat(final boolean enabled) {
+ return enabled
+ ? (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)
+ : SafeBrowsing.NONE;
+ }
+
+ /* package */ static @CBSafeBrowsing int sbPhishingToSbCat(final boolean enabled) {
+ return enabled ? SafeBrowsing.PHISHING : SafeBrowsing.NONE;
+ }
+
+ /* package */ static boolean catToSbMalware(@CBAntiTracking final int cat) {
+ return (cat & (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)) != 0;
+ }
+
+ /* package */ static boolean catToSbPhishing(@CBAntiTracking final int cat) {
+ return (cat & SafeBrowsing.PHISHING) != 0;
+ }
+
+ /* package */ static String catToAtPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.TEST) != 0) {
+ builder.append(TEST).append(',');
+ }
+ if ((cat & AntiTracking.AD) != 0) {
+ builder.append(AD).append(',');
+ }
+ if ((cat & AntiTracking.ANALYTIC) != 0) {
+ builder.append(ANALYTIC).append(',');
+ }
+ if ((cat & AntiTracking.SOCIAL) != 0) {
+ builder.append(SOCIAL).append(',');
+ }
+ if ((cat & AntiTracking.CONTENT) != 0) {
+ builder.append(CONTENT).append(',');
+ }
+ if (builder.length() == 0) {
+ return "";
+ }
+ // Trim final ','.
+ return builder.substring(0, builder.length() - 1);
+ }
+
+ /* package */ static boolean catToCmPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.CRYPTOMINING) != 0;
+ }
+
+ /* package */ static String catToCmListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.CRYPTOMINING) != 0) {
+ builder.append(CRYPTOMINING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static boolean catToFpPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.FINGERPRINTING) != 0;
+ }
+
+ /* package */ static String catToFpListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.FINGERPRINTING) != 0) {
+ builder.append(FINGERPRINTING);
+ }
+ return builder.toString();
+ }
+
+ /* package */ static @CBAntiTracking int fpListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(FINGERPRINTING) != -1) {
+ cat |= AntiTracking.FINGERPRINTING;
+ }
+ return cat;
+ }
+
+ /* package */ static boolean catToStPref(@CBAntiTracking final int cat) {
+ return (cat & AntiTracking.STP) != 0;
+ }
+
+ /* package */ static String catToStListPref(@CBAntiTracking final int cat) {
+ final StringBuilder builder = new StringBuilder();
+
+ if ((cat & AntiTracking.STP) != 0) {
+ builder.append(STP).append(",");
+ }
+ if (builder.length() == 0) {
+ return "";
+ }
+ // Trim final ','.
+ return builder.substring(0, builder.length() - 1);
+ }
+
+ /* package */ static @CBAntiTracking int atListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(TEST) != -1) {
+ cat |= AntiTracking.TEST;
+ }
+ if (list.indexOf(AD) != -1) {
+ cat |= AntiTracking.AD;
+ }
+ if (list.indexOf(ANALYTIC) != -1) {
+ cat |= AntiTracking.ANALYTIC;
+ }
+ if (list.indexOf(SOCIAL) != -1) {
+ cat |= AntiTracking.SOCIAL;
+ }
+ if (list.indexOf(CONTENT) != -1) {
+ cat |= AntiTracking.CONTENT;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int cmListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(CRYPTOMINING) != -1) {
+ cat |= AntiTracking.CRYPTOMINING;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBAntiTracking int stListToAtCat(final String list) {
+ int cat = AntiTracking.NONE;
+ if (list == null) {
+ return cat;
+ }
+ if (list.indexOf(STP) != -1) {
+ cat |= AntiTracking.STP;
+ }
+ return cat;
+ }
+
+ /* package */ static @CBSafeBrowsing int errorToSbCat(final long error) {
+ // Match flags with XPCOM ErrorList.h.
+ if (error == 0x805D001FL) {
+ return SafeBrowsing.PHISHING;
+ }
+ if (error == 0x805D001EL) {
+ return SafeBrowsing.MALWARE;
+ }
+ if (error == 0x805D0023L) {
+ return SafeBrowsing.UNWANTED;
+ }
+ if (error == 0x805D0026L) {
+ return SafeBrowsing.HARMFUL;
+ }
+ return SafeBrowsing.NONE;
+ }
+
+ // Match flags with nsIWebProgressListener.idl.
+ private static final long STATE_COOKIES_LOADED = 0x8000L;
+ private static final long STATE_COOKIES_LOADED_TRACKER = 0x40000L;
+ private static final long STATE_COOKIES_LOADED_SOCIALTRACKER = 0x80000L;
+ private static final long STATE_COOKIES_BLOCKED_TRACKER = 0x20000000L;
+ private static final long STATE_COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000L;
+ private static final long STATE_COOKIES_BLOCKED_ALL = 0x40000000L;
+ private static final long STATE_COOKIES_BLOCKED_FOREIGN = 0x80L;
+
+ /* package */ static boolean isBlockingGeckoCbCat(final long geckoCat) {
+ return (geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_BLOCKED_ALL
+ | STATE_COOKIES_BLOCKED_FOREIGN))
+ != 0;
+ }
+
+ /* package */ static @CBCookieBehavior int geckoCatToCbCat(final long geckoCat) {
+ if ((geckoCat & STATE_COOKIES_LOADED) != 0) {
+ // We don't know which setting would actually block this cookie, so
+ // we return the most strict value.
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_FOREIGN) != 0) {
+ return CookieBehavior.ACCEPT_FIRST_PARTY;
+ }
+ // If we receive STATE_COOKIES_LOADED_{SOCIAL,}TRACKER we know that this
+ // setting would block this cookie.
+ if ((geckoCat
+ & (STATE_COOKIES_BLOCKED_TRACKER
+ | STATE_COOKIES_BLOCKED_SOCIALTRACKER
+ | STATE_COOKIES_LOADED_TRACKER
+ | STATE_COOKIES_LOADED_SOCIALTRACKER))
+ != 0) {
+ return CookieBehavior.ACCEPT_NON_TRACKERS;
+ }
+ if ((geckoCat & STATE_COOKIES_BLOCKED_ALL) != 0) {
+ return CookieBehavior.ACCEPT_NONE;
+ }
+ // TODO: There are more reasons why cookies may be blocked.
+ return CookieBehavior.ACCEPT_ALL;
+ }
+
+ // Cookie Banner Handling feature.
+
+ public static class CookieBannerMode {
+ /** Do not enable handling cookie banners. */
+ public static final int COOKIE_BANNER_MODE_DISABLED = 0;
+
+ /** Only handle banners where selecting "reject all" is possible. */
+ public static final int COOKIE_BANNER_MODE_REJECT = 1;
+
+ /** Reject cookies when possible otherwise accept the cookies. */
+ public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2;
+
+ 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> blockingData;
+
+ /* package */ LogEntry(final @NonNull GeckoBundle bundle) {
+ origin = bundle.getString("origin");
+ final GeckoBundle[] data = bundle.getBundleArray("blockData");
+ final ArrayList<BlockingData> dataArray = new ArrayList<BlockingData>(data.length);
+ for (final GeckoBundle b : data) {
+ dataArray.add(new BlockingData(b));
+ }
+ blockingData = Collections.unmodifiableList(dataArray);
+ }
+
+ protected LogEntry() {
+ origin = null;
+ blockingData = null;
+ }
+ }
+
+ private List<LogEntry> logFromBundle(final GeckoBundle value) {
+ final GeckoBundle[] bundles = value.getBundleArray("log");
+ final ArrayList<LogEntry> logArray = new ArrayList<>(bundles.length);
+ for (final GeckoBundle b : bundles) {
+ logArray.add(new LogEntry(b));
+ }
+ return Collections.unmodifiableList(logArray);
+ }
+
+ /**
+ * Get a log of all content blocking information for the site currently loaded by the supplied
+ * {@link GeckoSession}.
+ *
+ * @param session A {@link GeckoSession} for which you want the content blocking log.
+ * @return A {@link GeckoResult} that resolves to the list of content blocking log entries.
+ */
+ @UiThread
+ public @NonNull GeckoResult<List<LogEntry>> getLog(final @NonNull GeckoSession session) {
+ return session
+ .getEventDispatcher()
+ .queryBundle("ContentBlocking:RequestLog")
+ .map(this::logFromBundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
new file mode 100644
index 0000000000..691686e230
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java
@@ -0,0 +1,385 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.content.Context;
+import android.content.Intent;
+import android.os.Build;
+import android.os.Bundle;
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.net.URLDecoder;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.zip.GZIPOutputStream;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.util.ProxySelector;
+
+/**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> crash
+ * report server.
+ */
+public class CrashReporter {
+ private static final String LOGTAG = "GeckoCrashReporter";
+ private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump";
+ private static final String PAGE_URL_KEY = "URL";
+ private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash";
+ private static final String NOTES_KEY = "Notes";
+ private static final String SERVER_URL_KEY = "ServerURL";
+ private static final String STACK_TRACES_KEY = "StackTraces";
+ private static final String PRODUCT_NAME_KEY = "ProductName";
+ private static final String PRODUCT_ID_KEY = "ProductID";
+ private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}";
+ private static final List<String> IGNORE_KEYS =
+ Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY);
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intent The Intent sent to the {@link GeckoRuntime} crash handler
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ return sendCrashReport(context, intent.getExtras(), appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current Context
+ * @param intentExtras The Bundle of extras attached to the Intent received by a crash handler.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final Bundle intentExtras,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH));
+ final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH));
+
+ return sendCrashReport(context, dumpFile, extrasFile, appName);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server. <br>
+ * The {@code appName} needs to be whitelisted for the server to accept the crash. <a
+ * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would
+ * like to get your app added to the whitelist.
+ *
+ * @param context The current {@link Context}
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extrasFile A {@link File} referring to the extras file.
+ * @param appName A human-readable app name.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final Context context,
+ @NonNull final File minidumpFile,
+ @NonNull final File extrasFile,
+ @NonNull final String appName)
+ throws IOException, URISyntaxException {
+ final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName);
+
+ final String url = annotations.optString(SERVER_URL_KEY, null);
+ if (url == null) {
+ return GeckoResult.fromException(new Exception("No server url present"));
+ }
+
+ for (final String key : IGNORE_KEYS) {
+ annotations.remove(key);
+ }
+
+ return sendCrashReport(url, minidumpFile, annotations);
+ }
+
+ /**
+ * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a>
+ * crash report server.
+ *
+ * @param serverURL The URL used to submit the crash report.
+ * @param minidumpFile A {@link File} referring to the minidump.
+ * @param extras A {@link JSONObject} holding the parsed JSON from the extra file.
+ * @throws IOException This can be thrown if there was a networking error while sending the
+ * report.
+ * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was
+ * invalid.
+ * @return A GeckoResult containing the crash ID as a String.
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ @AnyThread
+ public static @NonNull GeckoResult<String> sendCrashReport(
+ @NonNull final String serverURL,
+ @NonNull final File minidumpFile,
+ @NonNull final JSONObject extras)
+ throws IOException, URISyntaxException {
+ Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath());
+
+ HttpURLConnection conn = null;
+ try {
+ final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8"));
+ final URI uri =
+ new URI(
+ url.getProtocol(),
+ url.getUserInfo(),
+ url.getHost(),
+ url.getPort(),
+ url.getPath(),
+ url.getQuery(),
+ url.getRef());
+ conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri);
+ conn.setRequestMethod("POST");
+ final String boundary = generateBoundary();
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+ conn.setRequestProperty("Content-Encoding", "gzip");
+
+ final OutputStream os = new GZIPOutputStream(conn.getOutputStream());
+ sendAnnotations(os, boundary, extras);
+ sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile);
+ os.write(("\r\n--" + boundary + "--\r\n").getBytes());
+ os.flush();
+ os.close();
+
+ BufferedReader br = null;
+ try {
+ br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
+ final HashMap<String, String> responseMap = readStringsFromReader(br);
+
+ if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
+ final String crashid = responseMap.get("CrashID");
+ if (crashid != null) {
+ Log.i(LOGTAG, "Successfully sent crash report: " + crashid);
+ return GeckoResult.fromValue(crashid);
+ } else {
+ Log.i(LOGTAG, "Server rejected crash report");
+ }
+ } else {
+ Log.w(
+ LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode());
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ try {
+ if (br != null) {
+ br.close();
+ }
+ } catch (final IOException e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ }
+ }
+ } catch (final Exception e) {
+ return GeckoResult.fromException(new Exception("Failed to submit crash report", e));
+ } finally {
+ if (conn != null) {
+ conn.disconnect();
+ }
+ }
+ return GeckoResult.fromException(new Exception("Failed to submit crash report"));
+ }
+
+ private static String computeMinidumpHash(@NonNull final File minidump) throws IOException {
+ MessageDigest md = null;
+ final FileInputStream stream = new FileInputStream(minidump);
+ try {
+ md = MessageDigest.getInstance("SHA-256");
+
+ final byte[] buffer = new byte[4096];
+ int readBytes;
+
+ while ((readBytes = stream.read(buffer)) != -1) {
+ md.update(buffer, 0, readBytes);
+ }
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IOException(e);
+ } finally {
+ stream.close();
+ }
+
+ final byte[] digest = md.digest();
+ final StringBuilder hash = new StringBuilder(64);
+
+ for (int i = 0; i < digest.length; i++) {
+ hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4));
+ hash.append(Integer.toHexString(digest[i] & 0x0f));
+ }
+
+ return hash.toString();
+ }
+
+ private static HashMap<String, String> readStringsFromReader(final BufferedReader reader)
+ throws IOException {
+ String line;
+ final HashMap<String, String> map = new HashMap<>();
+ while ((line = reader.readLine()) != null) {
+ int equalsPos = -1;
+ if ((equalsPos = line.indexOf('=')) != -1) {
+ final String key = line.substring(0, equalsPos);
+ final String val = unescape(line.substring(equalsPos + 1));
+ map.put(key, val);
+ }
+ }
+ return map;
+ }
+
+ private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException {
+ final byte[] buffer = new byte[4096];
+ final FileInputStream inputStream = new FileInputStream(filePath);
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ int bytesRead = 0;
+
+ while ((bytesRead = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, bytesRead);
+ }
+
+ final String contents = new String(outputStream.toByteArray(), "UTF-8");
+ return new JSONObject(contents);
+ }
+
+ private static JSONObject getCrashAnnotations(
+ @NonNull final Context context,
+ @NonNull final File minidump,
+ @NonNull final File extra,
+ @NonNull final String appName)
+ throws IOException {
+ try {
+ final JSONObject annotations = readExtraFile(extra.getPath());
+
+ // Compute the minidump hash and generate the stack traces
+ try {
+ final String hash = computeMinidumpHash(minidump);
+ annotations.put(MINIDUMP_SHA256_HASH_KEY, hash);
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "exception while computing the minidump hash: ", e);
+ }
+
+ annotations.put(PRODUCT_NAME_KEY, appName);
+ annotations.put(PRODUCT_ID_KEY, PRODUCT_ID);
+ annotations.put("Android_Manufacturer", Build.MANUFACTURER);
+ annotations.put("Android_Model", Build.MODEL);
+ annotations.put("Android_Board", Build.BOARD);
+ annotations.put("Android_Brand", Build.BRAND);
+ annotations.put("Android_Device", Build.DEVICE);
+ annotations.put("Android_Display", Build.DISPLAY);
+ annotations.put("Android_Fingerprint", Build.FINGERPRINT);
+ annotations.put("Android_CPU_ABI", Build.CPU_ABI);
+ annotations.put("Android_PackageName", context.getPackageName());
+ try {
+ annotations.put("Android_CPU_ABI2", Build.CPU_ABI2);
+ annotations.put("Android_Hardware", Build.HARDWARE);
+ } catch (final Exception ex) {
+ Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex);
+ }
+ annotations.put(
+ "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")");
+
+ return annotations;
+ } catch (final JSONException e) {
+ throw new IOException(e);
+ }
+ }
+
+ private static String generateBoundary() {
+ // Generate some random numbers to fill out the boundary
+ final int r0 = (int) (Integer.MAX_VALUE * Math.random());
+ final int r1 = (int) (Integer.MAX_VALUE * Math.random());
+ return String.format("---------------------------%08X%08X", r0, r1);
+ }
+
+ private static void sendAnnotations(
+ final OutputStream os, final String boundary, final JSONObject extras) throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\"extra\"; "
+ + "filename=\"extra.json\"\r\n"
+ + "Content-Type: application/json\r\n"
+ + "\r\n")
+ .getBytes());
+ os.write(extras.toString().getBytes("UTF-8"));
+ os.write('\n');
+ }
+
+ private static void sendFile(
+ final OutputStream os, final String boundary, final String name, final File file)
+ throws IOException {
+ os.write(
+ ("--"
+ + boundary
+ + "\r\n"
+ + "Content-Disposition: form-data; name=\""
+ + name
+ + "\"; "
+ + "filename=\""
+ + file.getName()
+ + "\"\r\n"
+ + "Content-Type: application/octet-stream\r\n"
+ + "\r\n")
+ .getBytes());
+ final FileChannel fc = new FileInputStream(file).getChannel();
+ fc.transferTo(0, fc.size(), Channels.newChannel(os));
+ fc.close();
+ }
+
+ private static String unescape(final String string) {
+ return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
new file mode 100644
index 0000000000..fe6b723983
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java
@@ -0,0 +1,36 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import static java.lang.annotation.ElementType.CONSTRUCTOR;
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.LOCAL_VARIABLE;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PACKAGE;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Additional metadata about a deprecation notice. */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE})
+public @interface DeprecationSchedule {
+ /**
+ * @return Major version when we expect to remove the deprecated member attached to this
+ * annotation.
+ */
+ int version();
+
+ /**
+ * @return Identifier for a deprecation notice. All notices with the same identifier will be
+ * removed at the same time.
+ */
+ String id();
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java
new file mode 100644
index 0000000000..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.
+ *
+ * <p>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.
+ *
+ * <p>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.
+ *
+ * <p>This must be called when rendering in to a {@link android.view.SurfaceView} on SDK level
+ * 29 or above. On earlier SDK levels, or when rendering in to something other than a
+ * SurfaceView, this call can be omitted or the value can be null.
+ *
+ * @param surfaceControl The SurfaceControl associated with the new Surface's SurfaceView, or
+ * null.
+ * @return The builder object
+ */
+ @UiThread
+ public @NonNull Builder surfaceControl(final @Nullable SurfaceControl surfaceControl) {
+ mSurfaceControl = surfaceControl;
+ return this;
+ }
+
+ /**
+ * Sets a NewSurfaceProvider from which Gecko can request a new Surface.
+ *
+ * <p>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.
+ *
+ * <p>Required call. The display's Surface has been created or changed. Must be called on the
+ * application main thread. GeckoSession may block this call to ensure the Surface is valid while
+ * resuming drawing.
+ *
+ * <p>If rendering in to a {@link android.view.SurfaceView} on SDK level 29 or above, please
+ * ensure that the SurfaceControl field of the {@link SurfaceInfo} object is set.
+ *
+ * @param surfaceInfo Information about the new Surface.
+ */
+ @UiThread
+ public void surfaceChanged(@NonNull final SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceChanged(surfaceInfo);
+ }
+ }
+
+ /**
+ * Removes the current surface registered with the compositor.
+ *
+ * <p>Required call. The display's Surface has been destroyed. Must be called on the application
+ * main thread. GeckoSession may block this call to ensure the Surface is valid while pausing
+ * drawing.
+ */
+ @UiThread
+ public void surfaceDestroyed() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSurfaceDestroyed();
+ }
+ }
+
+ /**
+ * Update the position of the surface on the screen.
+ *
+ * <p>Optional call. The display's coordinates on the screen has changed. Must be called on the
+ * application main thread.
+ *
+ * @param left The X coordinate of the display on the screen, in screen pixels.
+ * @param top The Y coordinate of the display on the screen, in screen pixels.
+ */
+ @UiThread
+ public void screenOriginChanged(final int left, final int top) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onScreenOriginChanged(left, top);
+ }
+ }
+
+ /**
+ * Update the safe area insets of the surface on the screen.
+ *
+ * @param left left margin of safe area
+ * @param top top margin of safe area
+ * @param right right margin of safe area
+ * @param bottom bottom margin of safe area
+ */
+ @UiThread
+ public void safeAreaInsetsChanged(
+ final int top, final int right, final int bottom, final int left) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession.getDisplay() == this) {
+ mSession.onSafeAreaInsetsChanged(top, right, bottom, left);
+ }
+ }
+
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If the toolbar is dynamic, this function needs to be called with the maximum possible
+ * toolbar height so that Gecko can make the ICB static even during the dynamic toolbar height is
+ * being changed.
+ *
+ * @param height The maximum height of the dynamic toolbar(s).
+ */
+ @UiThread
+ public void setDynamicToolbarMaxHeight(final int height) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setDynamicToolbarMaxHeight(height);
+ }
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the display. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ @UiThread
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession != null) {
+ mSession.setFixedBottomOffset(clippingHeight);
+ }
+ }
+
+ /**
+ * Return whether the display should be pinned on the screen.
+ *
+ * <p>When pinned, the display should not be moved on the screen due to animation, scrolling, etc.
+ * A common reason for the display being pinned is when the user is dragging a selection caret
+ * inside the display; normal user interaction would be disrupted in that case if the display was
+ * moved on screen.
+ *
+ * @return True if display should be pinned on the screen.
+ */
+ @UiThread
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mSession.getDisplay() == this && mSession.shouldPinOnScreen();
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>Returned {@link Bitmap} will have the same dimensions as the {@link Surface} the {@link
+ * GeckoDisplay} is currently using.
+ *
+ * <p>If the {@link GeckoSession#isCompositorReady} is false the {@link GeckoResult} will complete
+ * with an {@link IllegalStateException}.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return screenshot().capture();
+ }
+
+ /** Builder to construct screenshot requests. */
+ public static final class ScreenshotBuilder {
+ private static final int NONE = 0;
+ private static final int SCALE = 1;
+ private static final int ASPECT = 2;
+ private static final int FULL = 3;
+ private static final int RECYCLE = 4;
+
+ private final GeckoSession mSession;
+ private int mOffsetX;
+ private int mOffsetY;
+ private int mSrcWidth;
+ private int mSrcHeight;
+ private int mOutWidth;
+ private int mOutHeight;
+ private int mAspectPreservingWidth;
+ private float mScale;
+ private Bitmap mRecycle;
+ private int mSizeType;
+
+ /* package */ ScreenshotBuilder(final GeckoSession session) {
+ this.mSizeType = NONE;
+ this.mSession = session;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param x Left most pixel of the source region.
+ * @param y Top most pixel of the source region.
+ * @param width Width of the source region in screen pixels
+ * @param height Height of the source region in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(
+ final int x, final int y, final int width, final int height) {
+ mOffsetX = x;
+ mOffsetY = y;
+ mSrcWidth = width;
+ mSrcHeight = height;
+ return this;
+ }
+
+ /**
+ * The screenshot will be of a region instead of the entire screen
+ *
+ * @param source Region of the screen to capture in screen pixels
+ * @return The builder
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder source(final @NonNull Rect source) {
+ mOffsetX = source.left;
+ mOffsetY = source.top;
+ mSrcWidth = source.width();
+ mSrcHeight = source.height();
+ return this;
+ }
+
+ private void checkAndSetSizeType(final int sizeType) {
+ if (mSizeType != NONE) {
+ throw new IllegalStateException("Size has already been set.");
+ }
+ mSizeType = sizeType;
+ }
+
+ /**
+ * The width of the bitmap to create when taking the screenshot. The height will be calculated
+ * to match the aspect ratio of the source as closely as possible. The source screenshot will be
+ * scaled into the resulting Bitmap.
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder aspectPreservingSize(final int width) {
+ checkAndSetSizeType(ASPECT);
+ mAspectPreservingWidth = width;
+ return this;
+ }
+
+ /**
+ * The scale of the bitmap relative to the source. The height and width of the output bitmap
+ * will be within one pixel of this multiple of the source dimensions. The source screenshot
+ * will be scaled into the resulting Bitmap.
+ *
+ * @param scale of the result Bitmap relative to the source.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder scale(final float scale) {
+ checkAndSetSizeType(SCALE);
+ mScale = scale;
+ return this;
+ }
+
+ /**
+ * Size of the bitmap to create when taking the screenshot. The source screenshot will be scaled
+ * into the resulting Bitmap
+ *
+ * @param width of the result Bitmap in screen pixels.
+ * @param height of the result Bitmap in screen pixels.
+ * @return The builder
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder size(final int width, final int height) {
+ checkAndSetSizeType(FULL);
+ mOutWidth = width;
+ mOutHeight = height;
+ return this;
+ }
+
+ /**
+ * Instead of creating a new Bitmap for the result, the builder will use the passed Bitmap.
+ *
+ * @param bitmap The Bitmap to use in the result.
+ * @return The builder.
+ * @throws IllegalStateException if the size has already been set in some other way.
+ */
+ @AnyThread
+ public @NonNull ScreenshotBuilder bitmap(final @Nullable Bitmap bitmap) {
+ checkAndSetSizeType(RECYCLE);
+ mRecycle = bitmap;
+ return this;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the requested portion of the web page currently being rendered
+ * using any parameters specified with the builder.
+ *
+ * <p>This function must be called on the UI thread.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the requested portion of the visible web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capture() {
+ ThreadUtils.assertOnUiThread();
+ if (!mSession.isCompositorReady()) {
+ throw new IllegalStateException("Compositor must be ready before pixels can be captured");
+ }
+
+ final GeckoResult<Bitmap> result = new GeckoResult<>();
+ final Bitmap target;
+ final Rect rect = new Rect();
+
+ if (mSrcWidth == 0 || mSrcHeight == 0) {
+ // Source is unset or invalid, use defaults.
+ mSession.getSurfaceBounds(rect);
+ mSrcWidth = rect.width();
+ mSrcHeight = rect.height();
+ }
+
+ switch (mSizeType) {
+ case NONE:
+ mOutWidth = mSrcWidth;
+ mOutHeight = mSrcHeight;
+ break;
+ case SCALE:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = (int) (rect.width() * mScale);
+ mOutHeight = (int) (rect.height() * mScale);
+ break;
+ case ASPECT:
+ mSession.getSurfaceBounds(rect);
+ mOutWidth = mAspectPreservingWidth;
+ mOutHeight = (int) (rect.height() * (mAspectPreservingWidth / (double) rect.width()));
+ break;
+ case RECYCLE:
+ mOutWidth = mRecycle.getWidth();
+ mOutHeight = mRecycle.getHeight();
+ break;
+ // case FULL does not need to be handled, as width and height are already set.
+ }
+
+ if (mRecycle == null) {
+ try {
+ target = Bitmap.createBitmap(mOutWidth, mOutHeight, Bitmap.Config.ARGB_8888);
+ } catch (final Throwable e) {
+ if (e instanceof NullPointerException || e instanceof OutOfMemoryError) {
+ return GeckoResult.fromException(
+ new OutOfMemoryError("Not enough memory to allocate for bitmap"));
+ }
+ return GeckoResult.fromException(new Throwable("Failed to create bitmap", e));
+ }
+ } else {
+ target = mRecycle;
+ }
+
+ mSession.mCompositor.requestScreenPixels(
+ result, target, mOffsetX, mOffsetY, mSrcWidth, mSrcHeight, mOutWidth, mOutHeight);
+
+ return result;
+ }
+ }
+
+ /**
+ * Creates a new screenshot builder.
+ *
+ * @return The new {@link ScreenshotBuilder}
+ */
+ @UiThread
+ public @NonNull ScreenshotBuilder screenshot() {
+ return new ScreenshotBuilder(mSession);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
new file mode 100644
index 0000000000..2d24dcbe93
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java
@@ -0,0 +1,2616 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.RectF;
+import android.os.Build;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.RemoteException;
+import android.os.SystemClock;
+import android.text.Editable;
+import android.text.InputFilter;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.method.KeyListener;
+import android.text.method.TextKeyListener;
+import android.text.style.CharacterStyle;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyCharacterMap;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.ref.WeakReference;
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.mozilla.gecko.GeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableChild;
+import org.mozilla.gecko.IGeckoEditableParent;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.util.ThreadUtils.AssertBehavior;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType;
+import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState;
+
+/**
+ * GeckoEditable implements only some functions of Editable The field mText contains the actual
+ * underlying SpannableStringBuilder/Editable that contains our text.
+ */
+/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub
+ implements InvocationHandler, Editable, SessionTextInput.EditableClient {
+
+ private static final boolean DEBUG = false;
+ private static final String LOGTAG = "GeckoEditable";
+
+ // Filters to implement Editable's filtering functionality
+ private InputFilter[] mFilters;
+
+ /**
+ * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing
+ * objects around via JNI seems to confuse the GC into thinking we have a native GC root.
+ */
+ /* package */ final WeakReference<GeckoSession> mSession;
+
+ private final AsyncText mText;
+ private final Editable mProxy;
+ private final ConcurrentLinkedQueue<Action> mActions;
+ private KeyCharacterMap mKeyMap;
+
+ // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables
+ // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to
+ // The two can be different when switching from one handler to another
+ private Handler mIcRunHandler;
+ private Handler mIcPostHandler;
+
+ // Parent process child used as a default for key events.
+ /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread.
+ // Parent or content process child that has the focus.
+ /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread.
+ /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread.
+ /* package */ SessionTextInput.EditableListener mListener;
+
+ /* package */ boolean mInBatchMode; // Used by IC thread
+ /* package */ boolean mNeedSync; // Used by IC thread
+ // Gecko side needs an updated composition from Java;
+ private boolean mNeedUpdateComposition; // Used by IC thread
+ private boolean mSuppressKeyUp; // Used by IC thread
+
+ @IMEState
+ private int mIMEState = // Used by IC thread.
+ SessionTextInput.EditableListener.IME_STATE_DISABLED;
+
+ private String mIMETypeHint = ""; // Used by IC/UI thread.
+ private String mIMEModeHint = ""; // Used by IC thread.
+ private String mIMEActionHint = ""; // Used by IC thread.
+ private String mIMEAutocapitalize = ""; // Used by IC thread.
+ @IMEContextFlags private int mIMEFlags; // Used by IC thread.
+
+ private boolean mIgnoreSelectionChange; // Used by Gecko thread
+ // Combined offsets from the previous batch of onTextChange calls; valid
+ // between the onTextChange calls and the next onSelectionChange call.
+ private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread
+ private int mLastTextChangeOldEnd = -1; // Used by Gecko thread
+ private int mLastTextChangeNewEnd = -1; // Used by Gecko thread
+ private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread
+
+ // Prevent showSoftInput and hideSoftInput from being called multiple times in a row,
+ // including reentrant calls on some devices. Used by UI/IC thread.
+ /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger();
+
+ private static final int IME_RANGE_CARETPOSITION = 1;
+ private static final int IME_RANGE_RAWINPUT = 2;
+ private static final int IME_RANGE_SELECTEDRAWTEXT = 3;
+ private static final int IME_RANGE_CONVERTEDTEXT = 4;
+ private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5;
+
+ private static final int IME_RANGE_LINE_NONE = 0;
+ private static final int IME_RANGE_LINE_SOLID = 1;
+ private static final int IME_RANGE_LINE_DOTTED = 2;
+ private static final int IME_RANGE_LINE_DASHED = 3;
+ private static final int IME_RANGE_LINE_DOUBLE = 4;
+ private static final int IME_RANGE_LINE_WAVY = 5;
+
+ private static final int IME_RANGE_UNDERLINE = 1;
+ private static final int IME_RANGE_FORECOLOR = 2;
+ private static final int IME_RANGE_BACKCOLOR = 4;
+ private static final int IME_RANGE_LINECOLOR = 8;
+
+ private void onKeyEvent(
+ final IGeckoEditableChild child,
+ final KeyEvent event,
+ final int action,
+ final int savedMetaState,
+ final boolean isSynthesizedImeKey)
+ throws RemoteException {
+ // Use a separate action argument so we can override the key's original action,
+ // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate
+ // a new key event just to change its action field.
+ //
+ // Normally we expect event.getMetaState() to reflect the current meta-state; however,
+ // some software-generated key events may not have event.getMetaState() set, e.g. key
+ // events from Swype. Therefore, it's necessary to combine the key's meta-states
+ // with the meta-states that we keep separately in KeyListener
+ final int metaState = event.getMetaState() | savedMetaState;
+ final int unmodifiedMetaState =
+ metaState & ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK);
+
+ final int unicodeChar = event.getUnicodeChar(metaState);
+ final int unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState);
+ final int domPrintableKeyValue =
+ unicodeChar >= ' '
+ ? unicodeChar
+ : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0;
+
+ // If a modifier (e.g. meta key) caused a different character to be entered, we
+ // drop that modifier from the metastate for the generated keypress event.
+ final int keyPressMetaState =
+ (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar)
+ ? unmodifiedMetaState
+ : metaState;
+
+ // For synthesized keys, ignore modifier metastates from the synthesized event,
+ // because the synthesized modifier metastates don't reflect the actual state of
+ // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is
+ // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key
+ // is not actually pressed in this case.
+ final int keyUpDownMetaState =
+ isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState;
+
+ child.onKeyEvent(
+ action,
+ event.getKeyCode(),
+ event.getScanCode(),
+ keyUpDownMetaState,
+ keyPressMetaState,
+ event.getEventTime(),
+ domPrintableKeyValue,
+ event.getRepeatCount(),
+ event.getFlags(),
+ isSynthesizedImeKey,
+ event);
+ }
+
+ /**
+ * Class that encapsulates asynchronous text editing. There are two copies of the text, a current
+ * copy and a shadow copy. Both can be modified independently through the current*** and shadow***
+ * methods, respectively. The current copy can only be modified on the Gecko side and reflects the
+ * authoritative version of the text. The shadow copy can only be modified on the IC side and
+ * reflects what we think the current text is. Periodically, the shadow copy can be synced to the
+ * current copy through syncShadowText, so the shadow copy once again refers to the same text as
+ * the current copy.
+ */
+ private final class AsyncText {
+ // The current text is the update-to-date version of the text, and is only updated
+ // on the Gecko side.
+ private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder();
+ // Track changes on the current side for syncing purposes.
+ // Start of the changed range in current text since last sync.
+ private int mCurrentStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in current text since last sync.
+ private int mCurrentOldEnd;
+ // End of the changed range (after the change) in current text since last sync.
+ private int mCurrentNewEnd;
+ // Track selection changes separately.
+ private boolean mCurrentSelectionChanged;
+
+ // The shadow text is what we think the current text is on the Java side, and is
+ // periodically synced with the current text.
+ private final SpannableStringBuilder mShadowText = new SpannableStringBuilder();
+ // Track changes on the shadow side for syncing purposes.
+ // Start of the changed range in shadow text since last sync.
+ private int mShadowStart = Integer.MAX_VALUE;
+ // End of the changed range (before the change) in shadow text since last sync.
+ private int mShadowOldEnd;
+ // End of the changed range (after the change) in shadow text since last sync.
+ private int mShadowNewEnd;
+
+ private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mCurrentStart = Math.min(mCurrentStart, start);
+ mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd);
+ mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd);
+ }
+
+ public synchronized void currentReplace(
+ final int start, final int end, final CharSequence newText) {
+ // On Gecko or binder thread.
+ mCurrentText.replace(start, end, newText);
+ addCurrentChangeLocked(start, end, start + newText.length());
+ }
+
+ public synchronized void currentSetSelection(final int start, final int end) {
+ // On Gecko or binder thread.
+ Selection.setSelection(mCurrentText, start, end);
+ mCurrentSelectionChanged = true;
+ }
+
+ public synchronized void currentSetSpan(
+ final Object obj, final int start, final int end, final int flags) {
+ // On Gecko or binder thread.
+ mCurrentText.setSpan(obj, start, end, flags);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ public synchronized void currentRemoveSpan(final Object obj) {
+ // On Gecko or binder thread.
+ if (obj == null) {
+ mCurrentText.clearSpans();
+ addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length());
+ return;
+ }
+ final int start = mCurrentText.getSpanStart(obj);
+ final int end = mCurrentText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mCurrentText.removeSpan(obj);
+ addCurrentChangeLocked(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the current*** methods.
+ public Spanned getCurrentText() {
+ // On Gecko or binder thread.
+ return mCurrentText;
+ }
+
+ private void addShadowChange(final int start, final int oldEnd, final int newEnd) {
+ // Merge the new change into any existing change.
+ mShadowStart = Math.min(mShadowStart, start);
+ mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd);
+ mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd);
+ }
+
+ public void shadowReplace(final int start, final int end, final CharSequence newText) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.replace(start, end, newText);
+ addShadowChange(start, end, start + newText.length());
+ }
+
+ public void shadowSetSpan(final Object obj, final int start, final int end, final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ mShadowText.setSpan(obj, start, end, flags);
+ addShadowChange(start, end, end);
+ }
+
+ public void shadowRemoveSpan(final Object obj) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ if (obj == null) {
+ mShadowText.clearSpans();
+ addShadowChange(0, mShadowText.length(), mShadowText.length());
+ return;
+ }
+ final int start = mShadowText.getSpanStart(obj);
+ final int end = mShadowText.getSpanEnd(obj);
+ if (start < 0 || end < 0) {
+ return;
+ }
+ mShadowText.removeSpan(obj);
+ addShadowChange(start, end, end);
+ }
+
+ // Return Spanned instead of Editable because the returned object is supposed to
+ // be read-only. Editing should be done through one of the shadow*** methods.
+ public Spanned getShadowText() {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ return mShadowText;
+ }
+
+ /**
+ * Check whether we are currently discarding the composition. It means that shadow text has
+ * composition, but current text has no composition. So syncShadowText will discard composition.
+ *
+ * @return true if discarding composition
+ */
+ private boolean isDiscardingComposition() {
+ if (!isComposing(mShadowText)) {
+ return false;
+ }
+
+ return !isComposing(mCurrentText);
+ }
+
+ public synchronized void syncShadowText(final SessionTextInput.EditableListener listener) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) {
+ // Still check selection changes.
+ if (!mCurrentSelectionChanged) {
+ return;
+ }
+ final int start = Selection.getSelectionStart(mCurrentText);
+ final int end = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, start, end);
+ mCurrentSelectionChanged = false;
+
+ if (listener != null) {
+ listener.onSelectionChange();
+ }
+ return;
+ }
+
+ if (isDiscardingComposition()) {
+ if (listener != null) {
+ listener.onDiscardComposition();
+ }
+ }
+
+ // Copy the portion of the current text that has changed over to the shadow
+ // text, with consideration for any concurrent changes in the shadow text.
+ final int start = Math.min(mShadowStart, mCurrentStart);
+ final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd);
+ final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd);
+
+ // Remove existing spans that may no longer be in the new text.
+ Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class);
+ for (final Object span : spans) {
+ mShadowText.removeSpan(span);
+ }
+
+ mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd);
+
+ // The replace() call may not have copied all affected spans, so we re-copy all the
+ // spans manually just in case. Expand bounds by 1 so we get all the spans.
+ spans =
+ mCurrentText.getSpans(
+ Math.max(start - 1, 0),
+ Math.min(currentEnd + 1, mCurrentText.length()),
+ Object.class);
+ for (final Object span : spans) {
+ if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) {
+ continue;
+ }
+ mShadowText.setSpan(
+ span,
+ mCurrentText.getSpanStart(span),
+ mCurrentText.getSpanEnd(span),
+ mCurrentText.getSpanFlags(span));
+ }
+
+ // SpannableStringBuilder has some internal logic to fix up selections, but we
+ // don't want that, so we always fix up the selection a second time.
+ final int selStart = Selection.getSelectionStart(mCurrentText);
+ final int selEnd = Selection.getSelectionEnd(mCurrentText);
+ Selection.setSelection(mShadowText, selStart, selEnd);
+
+ if (DEBUG && !checkEqualText(mShadowText, mCurrentText)) {
+ // Sanity check.
+ throw new IllegalStateException(
+ "Failed to sync: "
+ + mShadowStart
+ + '-'
+ + mShadowOldEnd
+ + '-'
+ + mShadowNewEnd
+ + '/'
+ + mCurrentStart
+ + '-'
+ + mCurrentOldEnd
+ + '-'
+ + mCurrentNewEnd);
+ }
+
+ if (listener != null) {
+ // Call onTextChange after selection fix-up but before we call
+ // onSelectionChange.
+ listener.onTextChange();
+
+ if (mCurrentSelectionChanged
+ || (mCurrentOldEnd != mCurrentNewEnd
+ && (selStart >= mCurrentStart || selEnd >= mCurrentStart))) {
+ listener.onSelectionChange();
+ }
+ }
+
+ // These values ensure the first change is properly added.
+ mCurrentStart = mShadowStart = Integer.MAX_VALUE;
+ mCurrentOldEnd = mShadowOldEnd = 0;
+ mCurrentNewEnd = mShadowNewEnd = 0;
+ mCurrentSelectionChanged = false;
+ }
+ }
+
+ private static boolean checkEqualText(final Spanned s1, final Spanned s2) {
+ if (!s1.toString().equals(s2.toString())) {
+ return false;
+ }
+
+ final Object[] o1s = s1.getSpans(0, s1.length(), Object.class);
+ final Object[] o2s = s2.getSpans(0, s2.length(), Object.class);
+
+ if (o1s.length != o2s.length) {
+ return false;
+ }
+
+ o1loop:
+ for (final Object o1 : o1s) {
+ for (final Object o2 : o2s) {
+ if (o1 != o2) {
+ continue;
+ }
+ if (s1.getSpanStart(o1) != s2.getSpanStart(o2)
+ || s1.getSpanEnd(o1) != s2.getSpanEnd(o2)
+ || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) {
+ return false;
+ }
+ continue o1loop;
+ }
+ // o1 not found in o2s.
+ return false;
+ }
+ return true;
+ }
+
+ /* An action that alters the Editable
+
+ Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko
+ thread, the action stays on top of mActions queue. After the Gecko event is processed and
+ replied, the action is removed from the queue
+ */
+ private static final class Action {
+ // For input events (keypress, etc.); use with onImeSynchronize
+ static final int TYPE_EVENT = 0;
+ // For Editable.replace() call; use with onImeReplaceText
+ static final int TYPE_REPLACE_TEXT = 1;
+ // For Editable.setSpan() call; use with onImeSynchronize
+ static final int TYPE_SET_SPAN = 2;
+ // For Editable.removeSpan() call; use with onImeSynchronize
+ static final int TYPE_REMOVE_SPAN = 3;
+ // For switching handler; use with onImeSynchronize
+ static final int TYPE_SET_HANDLER = 4;
+
+ final int mType;
+ int mStart;
+ int mEnd;
+ CharSequence mSequence;
+ Object mSpanObject;
+ int mSpanFlags;
+ Handler mHandler;
+
+ Action(final int type) {
+ mType = type;
+ }
+
+ static Action newReplaceText(final CharSequence text, final int start, final int end) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid replace text offsets");
+ }
+
+ final Action action = new Action(TYPE_REPLACE_TEXT);
+ action.mSequence = text;
+ action.mStart = start;
+ action.mEnd = end;
+ return action;
+ }
+
+ static Action newSetSpan(final Object object, final int start, final int end, final int flags) {
+ if (start < 0 || start > end) {
+ Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end);
+ throw new IllegalArgumentException("invalid span offsets");
+ }
+ final Action action = new Action(TYPE_SET_SPAN);
+ action.mSpanObject = object;
+ action.mStart = start;
+ action.mEnd = end;
+ action.mSpanFlags = flags;
+ return action;
+ }
+
+ static Action newRemoveSpan(final Object object) {
+ final Action action = new Action(TYPE_REMOVE_SPAN);
+ action.mSpanObject = object;
+ return action;
+ }
+
+ static Action newSetHandler(final Handler handler) {
+ final Action action = new Action(TYPE_SET_HANDLER);
+ action.mHandler = handler;
+ return action;
+ }
+ }
+
+ private void icOfferAction(final Action action) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "offer: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ mText.shadowSetSpan(
+ action.mSpanObject, action.mStart,
+ action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject);
+ mText.shadowRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_REPLACE_TEXT:
+ mText.shadowReplace(action.mStart, action.mEnd, action.mSequence);
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+
+ // Always perform actions on the shadow text side above, so we still act as a
+ // valid Editable object, but don't send the actions to Gecko below if we haven't
+ // been focused or initialized, or we've been destroyed.
+ if (mFocusedChild == null || mListener == null) {
+ return;
+ }
+
+ mActions.offer(action);
+
+ try {
+ icPerformAction(action);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ // Undo the offer.
+ mActions.remove(action);
+ }
+ }
+
+ private void icPerformAction(final Action action) throws RemoteException {
+ switch (action.mType) {
+ case Action.TYPE_EVENT:
+ case Action.TYPE_SET_HANDLER:
+ mFocusedChild.onImeSynchronize();
+ break;
+
+ case Action.TYPE_SET_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0
+ || action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END);
+
+ action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd);
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REMOVE_SPAN:
+ {
+ final boolean needUpdate =
+ (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0
+ && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0;
+
+ mNeedUpdateComposition |= needUpdate;
+ if (needUpdate) {
+ icMaybeSendComposition(
+ mText.getShadowText(),
+ SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT);
+ }
+
+ mFocusedChild.onImeSynchronize();
+ break;
+ }
+ case Action.TYPE_REPLACE_TEXT:
+ // Always sync text after a replace action, so that if the Gecko
+ // text is not changed, we will revert the shadow text to before.
+ mNeedSync = true;
+
+ // Because we get composition styling here essentially for free,
+ // we don't need to check if we're in batch mode.
+ if (icMaybeSendComposition(action.mSequence, SEND_COMPOSITION_USE_ENTIRE_TEXT)) {
+ mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString());
+ break;
+ }
+
+ // Since we don't have a composition, we can try sending key events.
+ sendCharKeyEvents(action);
+
+ // onImeReplaceText will set the selection range. But we don't
+ // know whether event state manager is processing text and
+ // selection. So current shadow may not be synchronized with
+ // Gecko's text and selection. So we have to avoid unnecessary
+ // selection update.
+ final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText());
+ final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText());
+ int actionStart = action.mStart;
+ int actionEnd = action.mEnd;
+ // If action range is collapsed and selection of shadow text is
+ // collapsed, we may try to dispatch keypress on current caret
+ // position. Action range is previous range before dispatching
+ // keypress, and shadow range is new range after dispatching
+ // it.
+ if (action.mStart == action.mEnd
+ && selStartOnShadow == selEndOnShadow
+ && action.mStart == selStartOnShadow + action.mSequence.toString().length()) {
+ // Replacing range is same value as current shadow's selection.
+ // So it is unnecessary to update the selection on Gecko.
+ actionStart = -1;
+ actionEnd = -1;
+ }
+ mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString());
+ break;
+
+ default:
+ throw new IllegalStateException("Action not processed");
+ }
+ }
+
+ private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) {
+ try {
+ if (mKeyMap == null) {
+ mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD);
+ }
+ } catch (final Exception e) {
+ // KeyCharacterMap.UnavailableException is not found on Gingerbread;
+ // besides, it seems like HC and ICS will throw something other than
+ // KeyCharacterMap.UnavailableException; so use a generic Exception here
+ return null;
+ }
+ final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray());
+ if (keyEvents == null || keyEvents.length == 0) {
+ return null;
+ }
+ return keyEvents;
+ }
+
+ private void sendCharKeyEvents(final Action action) throws RemoteException {
+ if (action.mSequence.length() != 1
+ || (action.mSequence instanceof Spannable
+ && ((Spannable) action.mSequence).nextSpanTransition(-1, Integer.MAX_VALUE, null)
+ < Integer.MAX_VALUE)) {
+ // Spans are not preserved when we use key events,
+ // so we need the sequence to not have any spans
+ return;
+ }
+ final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence);
+ if (keyEvents == null) {
+ return;
+ }
+ for (final KeyEvent event : keyEvents) {
+ if (KeyEvent.isModifierKey(event.getKeyCode())) {
+ continue;
+ }
+ if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) {
+ continue;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "sending: " + event);
+ }
+ onKeyEvent(
+ mFocusedChild,
+ event,
+ event.getAction(),
+ /* metaState */ 0, /* isSynthesizedImeKey */
+ true);
+ }
+ }
+
+ public GeckoEditable(@NonNull final GeckoSession session) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mSession = new WeakReference<>(session);
+ mText = new AsyncText();
+ mActions = new ConcurrentLinkedQueue<Action>();
+
+ final Class<?>[] PROXY_INTERFACES = {Editable.class};
+ mProxy =
+ (Editable) Proxy.newProxyInstance(Editable.class.getClassLoader(), PROXY_INTERFACES, this);
+
+ mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler();
+ }
+
+ @Override // IGeckoEditableParent
+ public void setDefaultChild(final IGeckoEditableChild child) {
+ if (DEBUG) {
+ // On Gecko or binder thread.
+ Log.d(LOGTAG, "setDefaultEditableChild " + child);
+ }
+ mDefaultChild = child;
+ }
+
+ public void setListener(final SessionTextInput.EditableListener newListener) {
+ if (DEBUG) {
+ // Called by SessionTextInput.
+ ThreadUtils.assertOnUiThread();
+ Log.d(LOGTAG, "setListener " + newListener);
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "onViewChange (set listener)");
+ }
+
+ mListener = newListener;
+ }
+ });
+ }
+
+ private boolean onIcThread() {
+ return mIcRunHandler.getLooper() == Looper.myLooper();
+ }
+
+ private void assertOnIcThread() {
+ ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW);
+ }
+
+ private Object getField(final Object obj, final String field, final Object def) {
+ try {
+ return obj.getClass().getField(field).get(obj);
+ } catch (final Exception e) {
+ return def;
+ }
+ }
+
+ // Flags for icMaybeSendComposition
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SEND_COMPOSITION_USE_ENTIRE_TEXT,
+ SEND_COMPOSITION_NOTIFY_GECKO,
+ SEND_COMPOSITION_KEEP_CURRENT
+ })
+ public @interface CompositionFlags {}
+
+ // If text has composing spans, treat the entire text as a Gecko composition,
+ // instead of just the spanned part.
+ private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0;
+ // Notify Gecko of the new composition ranges;
+ // otherwise, the caller is responsible for notifying Gecko.
+ private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1;
+ // Keep the current composition when updating;
+ // composition is not updated if there is no current composition.
+ private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2;
+
+ /**
+ * Send composition ranges to Gecko if the text has composing spans.
+ *
+ * @param sequence Text with possible composing spans
+ * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition.
+ * @return Whether there was a composition
+ */
+ private boolean icMaybeSendComposition(
+ final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException {
+ final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0;
+ final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0;
+ final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0;
+ final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0;
+
+ if (!keepCurrent) {
+ // If keepCurrent is true, the composition may not actually be updated;
+ // so we may still need to update the composition in the future.
+ mNeedUpdateComposition = false;
+ }
+
+ int selStart = Selection.getSelectionStart(sequence);
+ int selEnd = Selection.getSelectionEnd(sequence);
+
+ if (sequence instanceof Spanned) {
+ final Spanned text = (Spanned) sequence;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ boolean found = false;
+ int composingStart = useEntireText ? 0 : Integer.MAX_VALUE;
+ int composingEnd = useEntireText ? text.length() : 0;
+
+ // Find existence and range of any composing spans (spans with the
+ // SPAN_COMPOSING flag set).
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) {
+ continue;
+ }
+ found = true;
+ if (useEntireText) {
+ break;
+ }
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+
+ if (useEntireText && (selStart < 0 || selEnd < 0)) {
+ selStart = composingEnd;
+ selEnd = composingEnd;
+ }
+
+ if (found) {
+ if (selStart < composingStart || selEnd > composingEnd) {
+ // GBoard will set caret position that is out of composing
+ // range. Unfortunately, Gecko doesn't support this caret
+ // position. So we shouldn't set composing range data now.
+ // But this is temporary composing range, then GBoard will
+ // set valid range soon.
+ if (DEBUG) {
+ final StringBuilder sb =
+ new StringBuilder("icSendComposition(): invalid caret position. ");
+ sb.append("composing = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd);
+ Log.d(LOGTAG, sb.toString());
+ }
+ } else {
+ icSendComposition(text, selStart, selEnd, composingStart, composingEnd);
+ if (notifyGecko) {
+ mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags);
+ }
+ return true;
+ }
+ }
+ }
+
+ if (notifyGecko) {
+ // Set the selection by using a composition without ranges.
+ final Spanned currentText = mText.getCurrentText();
+ if (Selection.getSelectionStart(currentText) != selStart
+ || Selection.getSelectionEnd(currentText) != selEnd) {
+ // Gecko's selection is different of requested selection, so
+ // we have to set selection of Gecko side.
+ // If selection is same, it is unnecessary to update it.
+ // This may be race with Gecko's updating selection via
+ // JavaScript or keyboard event. But we don't know whether
+ // Gecko is during updating selection.
+ mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags);
+ }
+ }
+
+ if (DEBUG) {
+ Log.d(LOGTAG, "icSendComposition(): no composition");
+ }
+ return false;
+ }
+
+ private void icSendComposition(
+ final Spanned text,
+ final int selStart,
+ final int selEnd,
+ final int composingStart,
+ final int composingEnd)
+ throws RemoteException {
+ if (DEBUG) {
+ assertOnIcThread();
+ final StringBuilder sb = new StringBuilder("icSendComposition(");
+ sb.append("\"")
+ .append(text)
+ .append("\"")
+ .append(", range = ")
+ .append(composingStart)
+ .append("-")
+ .append(composingEnd)
+ .append(", selection = ")
+ .append(selStart)
+ .append("-")
+ .append(selEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (selEnd >= composingStart && selEnd <= composingEnd) {
+ mFocusedChild.onImeAddCompositionRange(
+ selEnd - composingStart,
+ selEnd - composingStart,
+ IME_RANGE_CARETPOSITION,
+ 0,
+ 0,
+ false,
+ 0,
+ 0,
+ 0);
+ }
+
+ int rangeStart = composingStart;
+ final TextPaint tp = new TextPaint();
+ final TextPaint emptyTp = new TextPaint();
+ // set initial foreground color to 0, because we check for tp.getColor() == 0
+ // below to decide whether to pass a foreground color to Gecko
+ emptyTp.setColor(0);
+ do {
+ final int rangeType;
+ int rangeStyles = 0;
+ int rangeLineStyle = IME_RANGE_LINE_NONE;
+ boolean rangeBoldLine = false;
+ int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0;
+ int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class);
+
+ if (selStart > rangeStart && selStart < rangeEnd) {
+ rangeEnd = selStart;
+ } else if (selEnd > rangeStart && selEnd < rangeEnd) {
+ rangeEnd = selEnd;
+ }
+ final CharacterStyle[] styleSpans = text.getSpans(rangeStart, rangeEnd, CharacterStyle.class);
+
+ if (DEBUG) {
+ Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + rangeStart + "-" + rangeEnd);
+ }
+
+ if (styleSpans.length == 0) {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDRAWTEXT
+ : IME_RANGE_RAWINPUT;
+ } else {
+ rangeType =
+ (selStart == rangeStart && selEnd == rangeEnd)
+ ? IME_RANGE_SELECTEDCONVERTEDTEXT
+ : IME_RANGE_CONVERTEDTEXT;
+ tp.set(emptyTp);
+ for (final CharacterStyle span : styleSpans) {
+ span.updateDrawState(tp);
+ }
+ int tpUnderlineColor = 0;
+ float tpUnderlineThickness = 0.0f;
+
+ // These TextPaint fields only exist on Android ICS+ and are not in the SDK.
+ tpUnderlineColor = (Integer) getField(tp, "underlineColor", 0);
+ tpUnderlineThickness = (Float) getField(tp, "underlineThickness", 0.0f);
+ if (tpUnderlineColor != 0) {
+ rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR;
+ rangeLineColor = tpUnderlineColor;
+ // Approximately translate underline thickness to what Gecko understands
+ if (tpUnderlineThickness <= 0.5f) {
+ rangeLineStyle = IME_RANGE_LINE_DOTTED;
+ } else {
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ if (tpUnderlineThickness >= 2.0f) {
+ rangeBoldLine = true;
+ }
+ }
+ } else if (tp.isUnderlineText()) {
+ rangeStyles |= IME_RANGE_UNDERLINE;
+ rangeLineStyle = IME_RANGE_LINE_SOLID;
+ }
+ if (tp.getColor() != 0) {
+ rangeStyles |= IME_RANGE_FORECOLOR;
+ rangeForeColor = tp.getColor();
+ }
+ if (tp.bgColor != 0) {
+ rangeStyles |= IME_RANGE_BACKCOLOR;
+ rangeBackColor = tp.bgColor;
+ }
+ }
+ mFocusedChild.onImeAddCompositionRange(
+ rangeStart - composingStart,
+ rangeEnd - composingStart,
+ rangeType,
+ rangeStyles,
+ rangeLineStyle,
+ rangeBoldLine,
+ rangeForeColor,
+ rangeBackColor,
+ rangeLineColor);
+ rangeStart = rangeEnd;
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ " added "
+ + rangeType
+ + " : "
+ + Integer.toHexString(rangeStyles)
+ + " : "
+ + Integer.toHexString(rangeForeColor)
+ + " : "
+ + Integer.toHexString(rangeBackColor));
+ }
+ } while (rangeStart < composingEnd);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void sendKeyEvent(
+ final @Nullable View view, final int action, final @NonNull KeyEvent event) {
+ final Editable editable = mProxy;
+ final KeyListener keyListener = TextKeyListener.getInstance();
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+
+ // We only let TextKeyListener do UI things on the UI thread.
+ final View v = ThreadUtils.isOnUiThread() ? view : null;
+ final int keyCode = translatedEvent.getKeyCode();
+ final boolean handled;
+
+ if (shouldSkipKeyListener(keyCode, translatedEvent)) {
+ handled = false;
+ } else if (action == KeyEvent.ACTION_DOWN) {
+ setSuppressKeyUp(true);
+ handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent);
+ } else if (action == KeyEvent.ACTION_UP) {
+ handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent);
+ } else {
+ handled = keyListener.onKeyOther(v, editable, translatedEvent);
+ }
+
+ if (!handled) {
+ sendKeyEvent(translatedEvent, action, TextKeyListener.getMetaState(editable));
+ }
+
+ if (action == KeyEvent.ACTION_DOWN) {
+ if (!handled) {
+ // Usually, the down key listener call above adjusts meta states for us.
+ // However, if the call didn't handle the event, we have to manually
+ // adjust meta states so the meta states remain consistent.
+ TextKeyListener.adjustMetaAfterKeypress(editable);
+ }
+ setSuppressKeyUp(false);
+ }
+ }
+
+ private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final int metaState) {
+ if (DEBUG) {
+ assertOnIcThread();
+ Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")");
+ }
+ /*
+ We are actually sending two events to Gecko here,
+ 1. Event from the event parameter (key event)
+ 2. Sync event from the icOfferAction call
+ The first event is a normal event that does not reply back to us,
+ the second sync event will have a reply, during which we see that there is a pending
+ event-type action, and update the shadow text accordingly.
+ */
+ try {
+ if (mFocusedChild == null) {
+ if (mDefaultChild == null) {
+ Log.w(LOGTAG, "Discarding key event");
+ return;
+ }
+ // Not focused; send simple key event to chrome window.
+ onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ return;
+ }
+
+ // Most IMEs handle arrow key, then set caret position. But GBoard
+ // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right
+ // even if having IME composition.
+ // Since Gecko doesn't dispatch keypress during IME composition due to
+ // DOM UI events spec, we have to emulate arrow key's behaviour.
+ boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN;
+ if (isComposing(mText.getShadowText())
+ && action == KeyEvent.ACTION_DOWN
+ && event.hasNoModifiers()) {
+ final int selStart = Selection.getSelectionStart(mText.getShadowText());
+ final int selEnd = Selection.getSelectionEnd(mText.getShadowText());
+ if (selStart == selEnd) {
+ // If dispatching arrow left/right key into composition,
+ // we update IME caret.
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_DPAD_LEFT:
+ if (getComposingStart(mText.getShadowText()) < selStart) {
+ Selection.setSelection(getEditable(), selStart - 1, selStart - 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selStart == 0) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT:
+ if (getComposingEnd(mText.getShadowText()) > selEnd) {
+ Selection.setSelection(getEditable(), selStart + 1, selStart + 1);
+ mNeedUpdateComposition = true;
+ commitCompositionBeforeKeyEvent = false;
+ } else if (selEnd == mText.getShadowText().length()) {
+ // Keep current composition
+ commitCompositionBeforeKeyEvent = false;
+ }
+ break;
+ }
+ }
+ }
+
+ // Focused; key event may go to chrome window or to content window.
+ if (mNeedUpdateComposition) {
+ icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO);
+ }
+
+ if (commitCompositionBeforeKeyEvent) {
+ mFocusedChild.onImeRequestCommit();
+ }
+ onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false);
+ icOfferAction(new Action(Action.TYPE_EVENT));
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) {
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ return true;
+ }
+
+ // Preserve enter and tab keys for the browser
+ if (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_TAB) {
+ return true;
+ }
+ // BaseKeyListener returns false even if it handled these keys for us,
+ // so we skip the key listener entirely and handle these ourselves
+ if (keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL) {
+ return true;
+ }
+ return false;
+ }
+
+ private static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final KeyEvent event) {
+ // The cross and circle button mappings may be swapped in the different regions so
+ // determine if they are swapped so the proper key codes can be mapped to the keys
+ final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped();
+
+ int translatedKeyCode = keyCode;
+ // If a Sony Xperia, remap the cross and circle buttons to buttons
+ // A and B for the gamepad API
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_BACK:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B);
+ break;
+
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ translatedKeyCode =
+ (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A);
+ break;
+
+ default:
+ return event;
+ }
+
+ return new KeyEvent(event.getAction(), translatedKeyCode);
+ }
+
+ private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611;
+
+ private static boolean isSonyXperiaGamepadKeyEvent(final KeyEvent event) {
+ return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID
+ && "Sony Ericsson".equals(Build.MANUFACTURER)
+ && ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL)));
+ }
+
+ private static boolean areSonyXperiaGamepadKeysSwapped() {
+ // The cross and circle buttons on Sony Xperia phones are swapped
+ // in different regions
+ // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/
+ final char DEFAULT_O_BUTTON_LABEL = 0x25CB;
+
+ boolean swapped = false;
+ final int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int i = 0; deviceIds != null && i < deviceIds.length; i++) {
+ final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]);
+ if (keyCharacterMap != null
+ && DEFAULT_O_BUTTON_LABEL
+ == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) {
+ swapped = true;
+ break;
+ }
+ }
+ return swapped;
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ if (isSonyXperiaGamepadKeyEvent(event)) {
+ return translateSonyXperiaGamepadKeys(keyCode, event);
+ }
+ return event;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Editable getEditable() {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "getEditable() called on non-IC thread");
+ }
+ return null;
+ }
+ if (mListener == null) {
+ // We haven't initialized or we've been destroyed.
+ return null;
+ }
+ return mProxy;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void setBatchMode(final boolean inBatchMode) {
+ if (!onIcThread()) {
+ // Android may be holding an old InputConnection; ignore
+ if (DEBUG) {
+ Log.i(LOGTAG, "setBatchMode() called on non-IC thread");
+ }
+ return;
+ }
+
+ mInBatchMode = inBatchMode;
+
+ if (!inBatchMode && mFocusedChild != null) {
+ // We may not commit composition on Gecko even if Java side has
+ // no composition. So we have to sync composition state with Gecko
+ // when batch edit is done.
+ //
+ // i.e. Although finishComposingText removes composing span, we
+ // don't commit current composition yet.
+ final Editable editable = getEditable();
+ if (editable != null && !isComposing(editable)) {
+ try {
+ mFocusedChild.onImeRequestCommit();
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+ // Committing composition doesn't change text, so we can sync shadow text.
+ }
+
+ if (!inBatchMode && mNeedSync) {
+ icSyncShadowText();
+ }
+ }
+
+ /* package */ void icSyncShadowText() {
+ if (mListener == null) {
+ // Not yet attached or already destroyed.
+ return;
+ }
+
+ if (mInBatchMode || !mActions.isEmpty()) {
+ mNeedSync = true;
+ return;
+ }
+
+ mNeedSync = false;
+ mText.syncShadowText(mListener);
+ }
+
+ private void setSuppressKeyUp(final boolean suppress) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+ // Suppress key up event generated as a result of
+ // translating characters to key events
+ mSuppressKeyUp = suppress;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public Handler setInputConnectionHandler(final Handler handler) {
+ if (handler == mIcRunHandler) {
+ return mIcRunHandler;
+ }
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // There are three threads at this point: Gecko thread, old IC thread, and new IC
+ // thread, and we want to safely switch from old IC thread to new IC thread.
+ // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that
+ // the Gecko thread is stopped at a known point. At the same time, the old IC
+ // thread blocks on the action; this ensures that the old IC thread is stopped at
+ // a known point. Finally, inside the Gecko thread, we post a Runnable to the old
+ // IC thread; this Runnable switches from old IC thread to new IC thread. We
+ // switch IC thread on the old IC thread to ensure any pending Runnables on the
+ // old IC thread are processed before we switch over. Inside the Gecko thread, we
+ // also post a Runnable to the new IC thread; this Runnable blocks until the
+ // switch is complete; this ensures that the new IC thread won't accept
+ // InputConnection calls until after the switch.
+
+ handler.post(
+ new Runnable() { // Make the new IC thread wait.
+ @Override
+ public void run() {
+ synchronized (handler) {
+ while (mIcRunHandler != handler) {
+ try {
+ handler.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ }
+ }
+ });
+
+ icOfferAction(Action.newSetHandler(handler));
+ return handler;
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void postToInputConnection(final Runnable runnable) {
+ mIcPostHandler.post(runnable);
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void requestCursorUpdates(@CursorMonitorMode final int requestMode) {
+ try {
+ if (mFocusedChild != null) {
+ mFocusedChild.onImeRequestCursorUpdates(requestMode);
+ }
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call failed", e);
+ }
+ }
+
+ @Override // SessionTextInput.EditableClient
+ public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) {
+ if (mFocusedChild == null) {
+ return;
+ }
+
+ try {
+ mFocusedChild.onImeInsertImage(data, mimeType);
+ } catch (final RemoteException e) {
+ Log.e(LOGTAG, "Remote call to insert image failed", e);
+ }
+ }
+
+ private void geckoSetIcHandler(final Handler newHandler) {
+ // On Gecko or binder thread.
+ mIcPostHandler.post(
+ new Runnable() { // posting to old IC thread
+ @Override
+ public void run() {
+ synchronized (newHandler) {
+ mIcRunHandler = newHandler;
+ newHandler.notify();
+ }
+ }
+ });
+
+ // At this point, all future Runnables should be posted to the new IC thread, but
+ // we don't switch mIcRunHandler yet because there may be pending Runnables on the
+ // old IC thread still waiting to run.
+ mIcPostHandler = newHandler;
+ }
+
+ private void geckoActionReply(final Action action) {
+ // On Gecko or binder thread.
+ if (action == null) {
+ Log.w(LOGTAG, "Mismatched reply");
+ return;
+ }
+ if (DEBUG) {
+ Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")");
+ }
+ switch (action.mType) {
+ case Action.TYPE_REPLACE_TEXT:
+ {
+ final Spanned currentText = mText.getCurrentText();
+ final int actionNewEnd = action.mStart + action.mSequence.length();
+ if (mLastTextChangeStart > mLastTextChangeNewEnd
+ || mLastTextChangeNewEnd > currentText.length()
+ || action.mStart < mLastTextChangeStart
+ || actionNewEnd > mLastTextChangeNewEnd) {
+ // Replace-text action doesn't match our text change.
+ break;
+ }
+
+ int indexInText =
+ TextUtils.indexOf(
+ currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd);
+ if (indexInText < 0 && action.mStart != mLastTextChangeStart) {
+ final String changedText =
+ TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd);
+ indexInText = changedText.lastIndexOf(action.mSequence.toString());
+ if (indexInText >= 0) {
+ indexInText += mLastTextChangeStart;
+ }
+ }
+ if (indexInText < 0) {
+ // Replace-text action doesn't match our current text.
+ break;
+ }
+
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // Replace-text action matches our current text; copy the new spans to the
+ // current text.
+ mText.currentReplace(
+ indexInText, indexInText + action.mSequence.length(), action.mSequence);
+ // Make sure selection is preserved.
+ mText.currentSetSelection(selStart, selEnd);
+
+ // The text change is caused by the replace-text event. If the text change
+ // replaced the previous selection, we need to rely on Gecko for an updated
+ // selection, so don't ignore selection change. However, if the text change
+ // did not replace the previous selection, we can ignore the Gecko selection
+ // in favor of the Java selection.
+ mIgnoreSelectionChange = !mLastTextChangeReplacedSelection;
+ break;
+ }
+
+ case Action.TYPE_SET_SPAN:
+ final int len = mText.getCurrentText().length();
+ if (action.mStart > len
+ || action.mEnd > len
+ || !TextUtils.substring(mText.getCurrentText(), action.mStart, action.mEnd)
+ .equals(action.mSequence)) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "discarding stale set span call");
+ }
+ break;
+ }
+ if ((action.mSpanObject == Selection.SELECTION_START
+ || action.mSpanObject == Selection.SELECTION_END)
+ && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart
+ || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) {
+ // Use the Java selection if, between text-change notification and replace-text
+ // processing, we specifically set the selection to outside the replaced range.
+ mLastTextChangeReplacedSelection = false;
+ }
+ mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags);
+ break;
+
+ case Action.TYPE_REMOVE_SPAN:
+ mText.currentRemoveSpan(action.mSpanObject);
+ break;
+
+ case Action.TYPE_SET_HANDLER:
+ geckoSetIcHandler(action.mHandler);
+ break;
+ }
+ }
+
+ private synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) {
+ // Verify that we're getting an IME notification from the currently focused child.
+ if (mFocusedToken == token || (mFocusedToken == null && allowNull)) {
+ return true;
+ }
+ Log.w(LOGTAG, "Invalid token");
+ return false;
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply()
+ if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ Log.d(
+ LOGTAG,
+ "notifyIME("
+ + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type)
+ + ")");
+ }
+ }
+
+ final IBinder token = child.asBinder();
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) {
+ synchronized (this) {
+ if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) {
+ // Focused child already exists and is alive.
+ Log.w(LOGTAG, "Already focused");
+ return;
+ }
+ mFocusedToken = token;
+ return;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) {
+ // Always from parent process.
+ ThreadUtils.assertOnGeckoThread();
+ } else if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) {
+ synchronized (this) {
+ onTextChange(token, "", 0, Integer.MAX_VALUE, false);
+ mActions.clear();
+ mFocusedToken = null;
+ }
+ } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ geckoActionReply(mActions.poll());
+ if (!mActions.isEmpty()) {
+ // Only post to IC thread below when the queue is empty.
+ return;
+ }
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIME(child, type);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIME(
+ final IGeckoEditableChild child, @IMENotificationType final int type) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) {
+ if (mNeedSync) {
+ icSyncShadowText();
+ }
+ return;
+ }
+
+ switch (type) {
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS:
+ if (mFocusedChild != null) {
+ // Already focused, so blur first.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false);
+ }
+
+ mFocusedChild = child;
+ mNeedSync = false;
+ mText.syncShadowText(/* listener */ null);
+
+ // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it
+ // comes _after_ notifyIME. In that case, the state is disabled here, and
+ // notifyIMEContext is responsible for calling restartInput.
+ if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN;
+ } else {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR:
+ if (mFocusedChild != null) {
+ mFocusedChild = null;
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true);
+ }
+ break;
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB:
+ toggleSoftInput(/* force */ true, mIMEState);
+ return; // Don't notify listener.
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ {
+ // Gecko already committed its composition. However, Android keyboards
+ // have trouble dealing with us removing the composition manually on the
+ // Java side. Therefore, we keep the composition intact on the Java side.
+ // The text content should still be in-sync on both sides.
+ //
+ // Nevertheless, if we somehow lost the composition, we must force the
+ // keyboard to reset.
+ if (isComposing(mText.getShadowText())) {
+ // Still have composition; no need to reset.
+ return; // Don't notify listener.
+ }
+ // No longer have composition; perform reset.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ return; // Don't notify listener.
+ }
+
+ case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN:
+ case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT:
+ case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ default:
+ throw new IllegalArgumentException("Invalid notifyIME type: " + type);
+ }
+
+ if (mListener != null) {
+ mListener.notifyIME(type);
+ }
+ }
+
+ @Override // IGeckoEditableParent
+ public void notifyIMEContext(
+ final IBinder token,
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("notifyIMEContext(");
+ sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state))
+ .append(", type=\"")
+ .append(typeHint)
+ .append("\", inputmode=\"")
+ .append(modeHint)
+ .append("\", autocapitalize=\"")
+ .append(autocapitalize)
+ .append("\", flags=0x")
+ .append(Integer.toHexString(flags))
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Regular notifyIMEContext calls all come from the parent process (with the default child),
+ // so always allow calls from there. We can get additional notifyIMEContext calls during
+ // a session transfer; calls in those cases can come from child processes, and we must
+ // perform a token check in that situation.
+ if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags);
+ }
+ });
+ }
+
+ /* package */ void icNotifyIMEContext(
+ @IMEState final int originalState,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ final String autocapitalize,
+ @IMEContextFlags final int flags) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ // For some input type we will use a widget to display the ui, for those we must not
+ // display the ime. We can display a widget for date and time types and, if the sdk version
+ // is 11 or greater, for datetime/month/week as well.
+ final int state;
+ if ((typeHint != null
+ && (typeHint.equalsIgnoreCase("date")
+ || typeHint.equalsIgnoreCase("time")
+ || typeHint.equalsIgnoreCase("month")
+ || typeHint.equalsIgnoreCase("week")
+ || typeHint.equalsIgnoreCase("datetime-local")))
+ || (modeHint != null && modeHint.equals("none"))) {
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ } else {
+ state = originalState;
+ }
+
+ final int oldState = mIMEState;
+ mIMEState = state;
+ mIMETypeHint = (typeHint == null) ? "" : typeHint;
+ mIMEModeHint = (modeHint == null) ? "" : modeHint;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+ mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize;
+ mIMEFlags = flags;
+
+ if (mListener != null) {
+ mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags);
+ }
+
+ if (mFocusedChild == null) {
+ // We have no focus.
+ return;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) {
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("icNotifyIMEContext: ");
+ sb.append("focus isn't changed. oldState=")
+ .append(oldState)
+ .append(", newState=")
+ .append(state);
+ Log.d(LOGTAG, sb.toString());
+ }
+ if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD)
+ && state == SessionTextInput.EditableListener.IME_STATE_DISABLED)
+ || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED
+ && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED
+ || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) {
+ // Even if focus isn't changed, software keyboard state is changed.
+ // We have to show or dismiss it.
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ true);
+ return;
+ }
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR
+ // will dismiss it.
+ // So ignore to control software keyboard at this time.
+ return;
+ }
+
+ // We changed state while focused. If the old state is unknown, it means this
+ // notifyIMEContext call came _after_ the notifyIME call, so we need to call
+ // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change
+ // counts as a content change.
+ if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true);
+ } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ icRestartInput(
+ GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE,
+ /* toggleSoftInput */ false);
+ }
+ }
+
+ private void icRestartInput(
+ @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) {
+ if (DEBUG) {
+ assertOnIcThread();
+ }
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')');
+ }
+
+ final GeckoSession session = mSession.get();
+ if (session != null) {
+ session.getTextInput().getDelegate().restartInput(session, reason);
+ }
+
+ if (!toggleSoftInput) {
+ return;
+ }
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ int state = mIMEState;
+ if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR
+ && mFocusedChild == null) {
+ // On blur, notifyIMEContext() is called after notifyIME(). Therefore,
+ // mIMEState is not up-to-date here and we need to override it.
+ state = SessionTextInput.EditableListener.IME_STATE_DISABLED;
+ }
+ toggleSoftInput(/* force */ false, state);
+ }
+ });
+ }
+ });
+ }
+
+ public void onCreateInputConnection(final EditorInfo outAttrs) {
+ final int state = mIMEState;
+ final String typeHint = mIMETypeHint;
+ final String modeHint = mIMEModeHint;
+ final String actionHint = mIMEActionHint;
+ final String autocapitalize = mIMEAutocapitalize;
+ final int flags = mIMEFlags;
+
+ // Some keyboards require us to fill out outAttrs even if we return null.
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ outAttrs.actionLabel = null;
+
+ if (modeHint.equals("none")) {
+ // inputmode=none hides VKB at force.
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED);
+ return;
+ }
+
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ outAttrs.inputType = InputType.TYPE_NULL;
+ toggleSoftInput(/* force */ false, state);
+ return;
+ }
+
+ // We give priority to typeHint so that content authors can't annoy
+ // users by doing dumb things like opening the numeric keyboard for
+ // an email form field.
+ outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
+ if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD
+ || "password".equalsIgnoreCase(typeHint)) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD;
+ } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (typeHint.equalsIgnoreCase("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (typeHint.equalsIgnoreCase("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) {
+ outAttrs.inputType =
+ InputType.TYPE_CLASS_NUMBER
+ | InputType.TYPE_NUMBER_VARIATION_NORMAL
+ | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // We look at modeHint
+ if (modeHint.equals("tel")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_PHONE;
+ } else if (modeHint.equals("url")) {
+ outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI;
+ } else if (modeHint.equals("email")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS;
+ } else if (modeHint.equals("numeric")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL;
+ } else if (modeHint.equals("decimal")) {
+ outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL;
+ } else {
+ // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap
+ outAttrs.inputType |=
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE;
+ }
+ }
+
+ if (autocapitalize.equals("characters")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS;
+ } else if (autocapitalize.equals("none")) {
+ // not set anymore.
+ } else if (autocapitalize.equals("sentences")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ } else if (autocapitalize.equals("words")) {
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS;
+ } else if (modeHint.length() == 0
+ && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0
+ && !typeHint.equalsIgnoreCase("text")) {
+ // auto-capitalized mode is the default for types other than text (bug 871884)
+ // except to password, url and email.
+ outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES;
+ }
+
+ if (actionHint.equals("enter")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE;
+ } else if (actionHint.equals("go")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_GO;
+ } else if (actionHint.equals("done")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
+ } else if (actionHint.equals("next") || actionHint.equals("maybenext")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT;
+ } else if (actionHint.equals("previous")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS;
+ } else if (actionHint.equals("search") || typeHint.equals("search")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH;
+ } else if (actionHint.equals("send")) {
+ outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND;
+ } else if (actionHint.length() > 0) {
+ if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\"");
+ outAttrs.actionLabel = actionHint;
+ }
+
+ if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) {
+ outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) {
+ // contenteditable allows image insertion.
+ outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"};
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final Spanned currentText = mText.getCurrentText();
+ outAttrs.initialSelStart = Selection.getSelectionStart(currentText);
+ outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText);
+ outAttrs.setInitialSurroundingText(currentText);
+ }
+
+ toggleSoftInput(/* force */ false, state);
+ }
+
+ /* package */ void toggleSoftInput(final boolean force, final int state) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "toggleSoftInput");
+ }
+ // Can be called from UI or IC thread.
+ final int flags = mIMEFlags;
+
+ // There are three paths that toggleSoftInput() can be called:
+ // 1) through calling restartInput(), which then indirectly calls
+ // onCreateInputConnection() and then toggleSoftInput().
+ // 2) through calling toggleSoftInput() directly from restartInput().
+ // This path is the fallback in case 1) does not happen.
+ // 3) through a system-generated onCreateInputConnection() call when the activity
+ // is restored from background, which then calls toggleSoftInput().
+ // mSoftInputReentrancyGuard is needed to ensure that between the different paths,
+ // the soft input is only toggled exactly once.
+
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ try {
+ final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet();
+ final boolean isReentrant = reentrancyGuard > 1;
+
+ // When using Find In Page, we can still receive notifyIMEContext calls due to the
+ // selection changing when highlighting. However in this case we don't want to
+ // show/hide the keyboard because the find box has the focus and is taking input from
+ // the keyboard.
+ final GeckoSession session = mSession.get();
+
+ if (session == null) {
+ return;
+ }
+
+ final View view = session.getTextInput().getView();
+ final boolean isFocused = (view == null) || view.hasFocus();
+
+ final boolean isUserAction =
+ ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0);
+
+ if (!force && (isReentrant || !isFocused || !isUserAction)) {
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "toggleSoftInput: no-op, reentrant="
+ + isReentrant
+ + ", focused="
+ + isFocused
+ + ", user="
+ + isUserAction);
+ }
+ return;
+ }
+ if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) {
+ session.getTextInput().getDelegate().hideSoftInput(session);
+ return;
+ }
+ {
+ final GeckoBundle bundle = new GeckoBundle();
+ // This bit is subtle. We want to force-zoom to the input
+ // if we're _not_ force-showing the virtual keyboard.
+ //
+ // We only force-show the virtual keyboard as a result of
+ // something that _doesn't_ switch the focus, and we don't
+ // want to move the view out of the focused editor unless
+ // we _actually_ show toggle the keyboard.
+ bundle.putBoolean("force", !force);
+ session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle);
+ }
+ session.getTextInput().getDelegate().showSoftInput(session);
+ } finally {
+ mSoftInputReentrancyGuard.decrementAndGet();
+ }
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void onSelectionChange(
+ final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onSelectionChange(");
+ sb.append(start)
+ .append(", ")
+ .append(end)
+ .append(", ")
+ .append(causedOnlyByComposition)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (mIgnoreSelectionChange) {
+ mIgnoreSelectionChange = false;
+ } else {
+ mText.currentSetSelection(start, end);
+ }
+
+ // We receive selection change notification after receiving replies for pending
+ // events, so we can reset text change bounds at this point.
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ if (causedOnlyByComposition) {
+ // It is unnecessary to sync shadow text since this change is by composition from Java
+ // side.
+ return;
+ }
+
+ // It is ready to synchronize Java text with Gecko text when no more input events is
+ // dispatched.
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ icSyncShadowText();
+ }
+ });
+ }
+
+ private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) {
+ return oldEnd - start == newText.length()
+ && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start);
+ }
+
+ @Override // IGeckoEditableParent
+ public void onTextChange(
+ final IBinder token,
+ final CharSequence text,
+ final int start,
+ final int unboundedOldEnd,
+ final boolean causedOnlyByComposition) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onTextChange(");
+ debugAppend(sb, text)
+ .append(", ")
+ .append(start)
+ .append(", ")
+ .append(unboundedOldEnd)
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ if (unboundedOldEnd >= Integer.MAX_VALUE / 2) {
+ // Integer.MAX_VALUE / 2 is a magic number to synchronize all.
+ // (See GeckoEditableSupport::FlushIMEText.)
+ // Previous text transactions are unnecessary now, so we have to ignore it.
+ mActions.clear();
+ }
+
+ final int currentLength = mText.getCurrentText().length();
+ final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd;
+ final int newEnd = start + text.length();
+
+ if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) {
+ // | oldEnd > currentLength | signals entire text is cleared (e.g. for
+ // newly-focused editors). Simply replace the text in that case; replace in
+ // two steps to properly clear composing spans that span the whole range.
+ mText.currentReplace(0, currentLength, "");
+ mText.currentReplace(0, 0, text);
+
+ // Don't ignore the next selection change because we are re-syncing with Gecko
+ mIgnoreSelectionChange = false;
+
+ mLastTextChangeStart = Integer.MAX_VALUE;
+ mLastTextChangeOldEnd = -1;
+ mLastTextChangeNewEnd = -1;
+ mLastTextChangeReplacedSelection = false;
+
+ } else if (!geckoIsSameText(start, oldEnd, text)) {
+ final Spanned currentText = mText.getCurrentText();
+ final int selStart = Selection.getSelectionStart(currentText);
+ final int selEnd = Selection.getSelectionEnd(currentText);
+
+ // True if the selection was in the middle of the replaced text; in that case
+ // we don't know where to place the selection after replacement, and must rely
+ // on the Gecko selection.
+ mLastTextChangeReplacedSelection |=
+ (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd);
+
+ // Gecko side initiated the text change. Replace in two steps to properly
+ // clear composing spans that span the whole range.
+ mText.currentReplace(start, oldEnd, "");
+ mText.currentReplace(start, start, text);
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+
+ } else {
+ // Nothing to do because the text is the same. This could happen when
+ // the composition is updated for example, in which case we want to keep the
+ // Java selection.
+ final Action action = mActions.peek();
+ mIgnoreSelectionChange =
+ mIgnoreSelectionChange
+ || (action != null
+ && (action.mType == Action.TYPE_REPLACE_TEXT
+ || action.mType == Action.TYPE_SET_SPAN
+ || action.mType == Action.TYPE_REMOVE_SPAN));
+
+ mLastTextChangeStart = Math.min(start, mLastTextChangeStart);
+ mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd);
+ mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd);
+ }
+
+ // onTextChange is always followed by onSelectionChange, so we let
+ // onSelectionChange schedule a shadow text sync.
+ }
+
+ @Override // IGeckoEditableParent
+ public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ final StringBuilder sb = new StringBuilder("onDefaultKeyEvent(");
+ sb.append("action=")
+ .append(event.getAction())
+ .append(", ")
+ .append("keyCode=")
+ .append(event.getKeyCode())
+ .append(", ")
+ .append("metaState=")
+ .append(event.getMetaState())
+ .append(", ")
+ .append("time=")
+ .append(event.getEventTime())
+ .append(", ")
+ .append("repeatCount=")
+ .append(event.getRepeatCount())
+ .append(")");
+ Log.d(LOGTAG, sb.toString());
+ }
+
+ // Allow default key processing even if we're not focused.
+ if (!binderCheckToken(token, /* allowNull */ true)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.onDefaultKeyEvent(event);
+ }
+ });
+ }
+
+ @Override // IGeckoEditableParent
+ public void updateCompositionRects(
+ final IBinder token, final RectF[] rects, final RectF caretRect) {
+ // On Gecko or binder thread.
+ if (DEBUG) {
+ Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")");
+ }
+
+ if (!binderCheckToken(token, /* allowNull */ false)) {
+ return;
+ }
+
+ mIcPostHandler.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ if (mListener == null) {
+ return;
+ }
+ mListener.updateCompositionRects(rects, caretRect);
+ }
+ });
+ }
+
+ // InvocationHandler interface
+
+ static String getConstantName(final Class<?> cls, final String prefix, final Object value) {
+ for (final Field fld : cls.getDeclaredFields()) {
+ try {
+ if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) {
+ return fld.getName();
+ }
+ } catch (final IllegalAccessException e) {
+ }
+ }
+ return String.valueOf(value);
+ }
+
+ private static String getPrintableChar(final char chr) {
+ if (chr >= 0x20 && chr <= 0x7e) {
+ return String.valueOf(chr);
+ } else if (chr == '\n') {
+ return "\u21b2";
+ }
+ return String.format("\\u%04x", (int) chr);
+ }
+
+ static StringBuilder debugAppend(final StringBuilder sb, final Object obj) {
+ if (obj == null) {
+ sb.append("null");
+ } else if (obj instanceof GeckoEditable) {
+ sb.append("GeckoEditable");
+ } else if (obj instanceof GeckoEditableChild) {
+ sb.append("GeckoEditableChild");
+ } else if (Proxy.isProxyClass(obj.getClass())) {
+ debugAppend(sb, Proxy.getInvocationHandler(obj));
+ } else if (obj instanceof Character) {
+ sb.append('\'').append(getPrintableChar((Character) obj)).append('\'');
+ } else if (obj instanceof CharSequence) {
+ final String str = obj.toString();
+ sb.append('"');
+ for (int i = 0; i < str.length(); i++) {
+ final char chr = str.charAt(i);
+ if (chr >= 0x20 && chr <= 0x7e) {
+ sb.append(chr);
+ } else {
+ sb.append(getPrintableChar(chr));
+ }
+ }
+ sb.append('"');
+ } else if (obj.getClass().isArray()) {
+ sb.append(obj.getClass().getComponentType().getSimpleName())
+ .append('[')
+ .append(Array.getLength(obj))
+ .append(']');
+ } else {
+ sb.append(obj);
+ }
+ return sb;
+ }
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final Object target;
+ final Class<?> methodInterface = method.getDeclaringClass();
+ if (DEBUG) {
+ // Editable methods should all be called from the IC thread
+ assertOnIcThread();
+ }
+ if (methodInterface == Editable.class
+ || methodInterface == Appendable.class
+ || methodInterface == Spannable.class) {
+ // Method alters the Editable; route calls to our implementation
+ target = this;
+ } else {
+ target = mText.getShadowText();
+ }
+
+ final Object ret = method.invoke(target, args);
+ if (DEBUG) {
+ final StringBuilder log = new StringBuilder(method.getName());
+ log.append("(");
+ if (args != null) {
+ for (final Object arg : args) {
+ debugAppend(log, arg).append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ if (method.getReturnType().equals(Void.TYPE)) {
+ log.append(")");
+ } else {
+ debugAppend(log.append(") = "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ }
+ return ret;
+ }
+
+ // Spannable interface
+
+ @Override
+ public void removeSpan(final Object what) {
+ if (what == null) {
+ return;
+ }
+
+ if (what == Selection.SELECTION_START || what == Selection.SELECTION_END) {
+ Log.w(LOGTAG, "selection removed with removeSpan()");
+ }
+
+ icOfferAction(Action.newRemoveSpan(what));
+ }
+
+ @Override
+ public void setSpan(final Object what, final int start, final int end, final int flags) {
+ icOfferAction(Action.newSetSpan(what, start, end, flags));
+ }
+
+ // Appendable interface
+
+ @Override
+ public Editable append(final CharSequence text) {
+ return replace(mProxy.length(), mProxy.length(), text, 0, text.length());
+ }
+
+ @Override
+ public Editable append(final CharSequence text, final int start, final int end) {
+ return replace(mProxy.length(), mProxy.length(), text, start, end);
+ }
+
+ @Override
+ public Editable append(final char text) {
+ return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1);
+ }
+
+ // Editable interface
+
+ @Override
+ public InputFilter[] getFilters() {
+ return mFilters;
+ }
+
+ @Override
+ public void setFilters(final InputFilter[] filters) {
+ mFilters = filters;
+ }
+
+ @Override
+ public void clearSpans() {
+ /* XXX this clears the selection spans too,
+ but there is no way to clear the corresponding selection in Gecko */
+ Log.w(LOGTAG, "selection cleared with clearSpans()");
+ icOfferAction(Action.newRemoveSpan(/* what */ null));
+ }
+
+ @Override
+ public Editable replace(
+ final int st, final int en, final CharSequence source, final int start, final int end) {
+ CharSequence text = source;
+ if (start < 0 || start > end || end > text.length()) {
+ Log.e(
+ LOGTAG,
+ "invalid replace offsets: " + start + " to " + end + ", length: " + text.length());
+ throw new IllegalArgumentException("invalid replace offsets");
+ }
+ if (start != 0 || end != text.length()) {
+ text = text.subSequence(start, end);
+ }
+ if (mFilters != null) {
+ // Filter text before sending the request to Gecko
+ for (int i = 0; i < mFilters.length; ++i) {
+ final CharSequence cs = mFilters[i].filter(text, 0, text.length(), mProxy, st, en);
+ if (cs != null) {
+ text = cs;
+ }
+ }
+ }
+ if (text == source) {
+ // Always create a copy
+ text = new SpannableString(source);
+ }
+ icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en)));
+ return mProxy;
+ }
+
+ @Override
+ public void clear() {
+ replace(0, mProxy.length(), "", 0, 0);
+ }
+
+ @Override
+ public Editable delete(final int st, final int en) {
+ return replace(st, en, "", 0, 0);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text, final int start, final int end) {
+ return replace(where, where, text, start, end);
+ }
+
+ @Override
+ public Editable insert(final int where, final CharSequence text) {
+ return replace(where, where, text, 0, text.length());
+ }
+
+ @Override
+ public Editable replace(final int st, final int en, final CharSequence text) {
+ return replace(st, en, text, 0, text.length());
+ }
+
+ /* GetChars interface */
+
+ @Override
+ public void getChars(final int start, final int end, final char[] dest, final int destoff) {
+ /* overridden Editable interface methods in GeckoEditable must not be called directly
+ outside of GeckoEditable. Instead, the call must go through mProxy, which ensures
+ that Java is properly synchronized with Gecko */
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* Spanned interface */
+
+ @Override
+ public int getSpanEnd(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanFlags(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int getSpanStart(final Object tag) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public <T> T[] getSpans(final int start, final int end, final Class<T> type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration
+ public int nextSpanTransition(final int start, final int limit, final Class type) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ /* CharSequence interface */
+
+ @Override
+ public char charAt(final int index) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public int length() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public CharSequence subSequence(final int start, final int end) {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ @Override
+ public String toString() {
+ throw new UnsupportedOperationException("method must be called through mProxy");
+ }
+
+ public boolean onKeyPreIme(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ public boolean onKeyDown(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event);
+ }
+
+ public boolean onKeyUp(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return processKey(view, KeyEvent.ACTION_UP, keyCode, event);
+ }
+
+ public boolean onKeyMultiple(
+ final @Nullable View view,
+ final int keyCode,
+ final int repeatCount,
+ final @NonNull KeyEvent event) {
+ if (keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+ // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters()
+ final String str = event.getCharacters();
+ for (int i = 0; i < str.length(); i++) {
+ final KeyEvent charEvent = getCharKeyEvent(str.charAt(i));
+ if (!processKey(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent)
+ || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ for (int i = 0; i < repeatCount; i++) {
+ if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event)
+ || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public boolean onKeyLongPress(
+ final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) {
+ return false;
+ }
+
+ /** Get a key that represents a given character. */
+ private static KeyEvent getCharKeyEvent(final char c) {
+ final long time = SystemClock.uptimeMillis();
+ return new KeyEvent(
+ time, time, KeyEvent.ACTION_MULTIPLE, KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) {
+ @Override
+ public int getUnicodeChar() {
+ return c;
+ }
+
+ @Override
+ public int getUnicodeChar(final int metaState) {
+ return c;
+ }
+ };
+ }
+
+ private boolean processKey(
+ final @Nullable View view,
+ final int action,
+ final int keyCode,
+ final @NonNull KeyEvent event) {
+ if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) {
+ return false;
+ }
+
+ postToInputConnection(
+ new Runnable() {
+ @Override
+ public void run() {
+ sendKeyEvent(view, action, event);
+ }
+ });
+ return true;
+ }
+
+ private static boolean shouldProcessKey(final int keyCode, final KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_MENU:
+ case KeyEvent.KEYCODE_BACK:
+ case KeyEvent.KEYCODE_VOLUME_UP:
+ case KeyEvent.KEYCODE_VOLUME_DOWN:
+ case KeyEvent.KEYCODE_SEARCH:
+ // ignore HEADSETHOOK to allow hold-for-voice-search to work
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ return false;
+ }
+ return true;
+ }
+
+ private static boolean isComposing(final Spanned text) {
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static int getComposingStart(final Spanned text) {
+ int composingStart = Integer.MAX_VALUE;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingStart = Math.min(composingStart, text.getSpanStart(span));
+ }
+ }
+
+ return composingStart;
+ }
+
+ private static int getComposingEnd(final Spanned text) {
+ int composingEnd = -1;
+ final Object[] spans = text.getSpans(0, text.length(), Object.class);
+ for (final Object span : spans) {
+ if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) {
+ composingEnd = Math.max(composingEnd, text.getSpanEnd(span));
+ }
+ }
+
+ return composingEnd;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
new file mode 100644
index 0000000000..ec53d2803a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java
@@ -0,0 +1,172 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.provider.Settings;
+import android.util.Log;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * A class that automatically adjusts font size settings for web content in Gecko in accordance with
+ * the device's OS font scale setting.
+ *
+ * @see android.provider.Settings.System#FONT_SCALE
+ */
+/* package */ final class GeckoFontScaleListener extends ContentObserver {
+ private static final String LOGTAG = "GeckoFontScaleListener";
+
+ private static final float DEFAULT_FONT_SCALE = 1.0f;
+
+ // We're referencing the *application* context, so this is in fact okay.
+ @SuppressLint("StaticFieldLeak")
+ private static final GeckoFontScaleListener sInstance = new GeckoFontScaleListener();
+
+ private Context mApplicationContext;
+ private GeckoRuntimeSettings mSettings;
+
+ private boolean mAttached;
+ private boolean mEnabled;
+ private boolean mRunning;
+
+ private float mPrevGeckoFontScale;
+
+ public static GeckoFontScaleListener getInstance() {
+ return sInstance;
+ }
+
+ private GeckoFontScaleListener() {
+ // Ensure the ContentObserver callback runs on the UI thread.
+ super(ThreadUtils.getUiHandler());
+ }
+
+ /**
+ * Prepare the GeckoFontScaleListener for usage. If it has been previously enabled, it will now
+ * start actively working.
+ */
+ public void attachToContext(final Context context, final GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ Log.w(LOGTAG, "Already attached!");
+ return;
+ }
+
+ mAttached = true;
+ mSettings = settings;
+ mApplicationContext = context.getApplicationContext();
+ onEnabledChange();
+ }
+
+ /**
+ * Detaches the context and also stops the GeckoFontScaleListener if it was previously enabled.
+ * This will also restore the previously used font size settings.
+ */
+ public void detachFromContext() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!mAttached) {
+ Log.w(LOGTAG, "Already detached!");
+ return;
+ }
+
+ stop();
+ mApplicationContext = null;
+ mSettings = null;
+ mAttached = false;
+ }
+
+ /**
+ * Controls whether the GeckoFontScaleListener should automatically adjust font sizes for web
+ * content in Gecko. When disabling, this will restore the previously used font size settings.
+ *
+ * <p>This method can be called at any time, but the GeckoFontScaleListener won't start actively
+ * adjusting font sizes until it has been attached to a context.
+ *
+ * @param enabled True if automatic font size setting should be enabled.
+ */
+ public void setEnabled(final boolean enabled) {
+ ThreadUtils.assertOnUiThread();
+ mEnabled = enabled;
+ onEnabledChange();
+ }
+
+ /**
+ * Get whether the GeckoFontScaleListener is currently enabled.
+ *
+ * @return True if the GeckoFontScaleListener is currently enabled.
+ */
+ public boolean getEnabled() {
+ return mEnabled;
+ }
+
+ private void onEnabledChange() {
+ if (!mAttached) {
+ return;
+ }
+
+ if (mEnabled) {
+ start();
+ } else {
+ stop();
+ }
+ }
+
+ private void start() {
+ if (mRunning) {
+ return;
+ }
+
+ mPrevGeckoFontScale = mSettings.getFontSizeFactor();
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ final Uri fontSizeSetting = Settings.System.getUriFor(Settings.System.FONT_SCALE);
+ contentResolver.registerContentObserver(fontSizeSetting, false, this);
+ onSystemFontScaleChange(contentResolver, false);
+
+ mRunning = true;
+ }
+
+ private void stop() {
+ if (!mRunning) {
+ return;
+ }
+
+ final ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ contentResolver.unregisterContentObserver(this);
+ onSystemFontScaleChange(contentResolver, /*stopping*/ true);
+
+ mRunning = false;
+ }
+
+ private void onSystemFontScaleChange(
+ final ContentResolver contentResolver, final boolean stopping) {
+ float fontScale;
+
+ if (!stopping) { // Either we were enabled, or else the system font scale changed.
+ fontScale =
+ Settings.System.getFloat(contentResolver, Settings.System.FONT_SCALE, DEFAULT_FONT_SCALE);
+ // Older Android versions don't sanitize the FONT_SCALE value. See Bug 1656078.
+ if (fontScale < 0) {
+ fontScale = DEFAULT_FONT_SCALE;
+ }
+ } else { // We were turned off.
+ fontScale = mPrevGeckoFontScale;
+ }
+
+ mSettings.setFontSizeFactorInternal(fontScale);
+ }
+
+ @UiThread // See constructor.
+ @Override
+ public void onChange(final boolean selfChange) {
+ onSystemFontScaleChange(mApplicationContext.getContentResolver(), false);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
new file mode 100644
index 0000000000..2d2f2d8dd3
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java
@@ -0,0 +1,829 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.res.Configuration;
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.media.AudioManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableString;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.CursorAnchorInfo;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.ExtractedText;
+import android.view.inputmethod.ExtractedTextRequest;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputContentInfo;
+import androidx.annotation.NonNull;
+import java.io.ByteArrayOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import org.mozilla.gecko.Clipboard;
+import org.mozilla.gecko.InputMethods;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/* package */ final class GeckoInputConnection extends BaseInputConnection
+ implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener {
+
+ private static final boolean DEBUG = false;
+ protected static final String LOGTAG = "GeckoInputConnection";
+
+ private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection";
+ private static final String CUSTOM_HANDLER_TEST_CLASS =
+ "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput";
+
+ private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480;
+
+ private static Handler sBackgroundHandler;
+
+ // Managed only by notifyIMEContext; see comments in notifyIMEContext
+ @IMEState private int mIMEState;
+ private String mIMEActionHint = "";
+ private int mLastSelectionStart;
+ private int mLastSelectionEnd;
+
+ private String mCurrentInputMethod = "";
+
+ private final GeckoSession mSession;
+ private final View mView;
+ private final SessionTextInput.EditableClient mEditableClient;
+ protected int mBatchEditCount;
+ private ExtractedTextRequest mUpdateRequest;
+ private final InputConnection mKeyInputConnection;
+ private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder;
+
+ public static SessionTextInput.InputConnectionClient create(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ SessionTextInput.InputConnectionClient ic =
+ new GeckoInputConnection(session, targetView, editable);
+ if (DEBUG) {
+ ic = wrapForDebug(ic);
+ }
+ return ic;
+ }
+
+ private static SessionTextInput.InputConnectionClient wrapForDebug(
+ final SessionTextInput.InputConnectionClient ic) {
+ final InvocationHandler handler =
+ new InvocationHandler() {
+ private final StringBuilder mCallLevel = new StringBuilder();
+
+ @Override
+ public Object invoke(final Object proxy, final Method method, final Object[] args)
+ throws Throwable {
+ final StringBuilder log = new StringBuilder(mCallLevel);
+ log.append("> ").append(method.getName()).append("(");
+ if (args != null) {
+ for (int i = 0; i < args.length; i++) {
+ final Object arg = args[i];
+ // translate argument values to constant names
+ if ("notifyIME".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg));
+ } else if ("notifyIMEContext".equals(method.getName()) && i == 0) {
+ log.append(
+ GeckoEditable.getConstantName(
+ SessionTextInput.EditableListener.class, "IME_STATE_", arg));
+ } else {
+ GeckoEditable.debugAppend(log, arg);
+ }
+ log.append(", ");
+ }
+ if (args.length > 0) {
+ log.setLength(log.length() - 2);
+ }
+ }
+ log.append(")");
+ Log.d(LOGTAG, log.toString());
+
+ mCallLevel.append(' ');
+ Object ret = method.invoke(ic, args);
+ if (ret == ic) {
+ ret = proxy;
+ }
+ mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1));
+
+ log.setLength(mCallLevel.length());
+ log.append("< ").append(method.getName());
+ if (!method.getReturnType().equals(Void.TYPE)) {
+ GeckoEditable.debugAppend(log.append(": "), ret);
+ }
+ Log.d(LOGTAG, log.toString());
+ return ret;
+ }
+ };
+
+ return (SessionTextInput.InputConnectionClient)
+ Proxy.newProxyInstance(
+ GeckoInputConnection.class.getClassLoader(),
+ new Class<?>[] {
+ InputConnection.class,
+ SessionTextInput.InputConnectionClient.class,
+ SessionTextInput.EditableListener.class
+ },
+ handler);
+ }
+
+ protected GeckoInputConnection(
+ final GeckoSession session,
+ final View targetView,
+ final SessionTextInput.EditableClient editable) {
+ super(targetView, true);
+ mSession = session;
+ mView = targetView;
+ mEditableClient = editable;
+ mIMEState = IME_STATE_DISABLED;
+ // InputConnection that sends keys for plugins, which don't have full editors
+ mKeyInputConnection = new BaseInputConnection(targetView, false);
+ }
+
+ @Override
+ public synchronized boolean beginBatchEdit() {
+ mBatchEditCount++;
+ if (mBatchEditCount == 1) {
+ mEditableClient.setBatchMode(true);
+ }
+ return true;
+ }
+
+ @Override
+ public synchronized boolean endBatchEdit() {
+ if (mBatchEditCount <= 0) {
+ Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!");
+ return true;
+ }
+
+ mBatchEditCount--;
+ if (mBatchEditCount != 0) {
+ return true;
+ }
+
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ return true;
+ }
+
+ @Override
+ public Editable getEditable() {
+ return mEditableClient.getEditable();
+ }
+
+ @Override
+ public boolean performContextMenuAction(final int id) {
+ final View view = getView();
+ final Editable editable = getEditable();
+ if (view == null || editable == null) {
+ return false;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ switch (id) {
+ case android.R.id.selectAll:
+ setSelection(0, editable.length());
+ break;
+ case android.R.id.cut:
+ // If selection is empty, we'll select everything
+ if (selStart == selEnd) {
+ // Fill the clipboard
+ Clipboard.setText(view.getContext(), editable);
+ editable.clear();
+ } else {
+ Clipboard.setText(
+ view.getContext(),
+ editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd)));
+ editable.delete(selStart, selEnd);
+ }
+ break;
+ case android.R.id.paste:
+ final String text = Clipboard.getText(view.getContext());
+ if (text != null) {
+ commitText(text, 1);
+ }
+ break;
+ case android.R.id.copy:
+ // Copy the current selection or the empty string if nothing is selected.
+ final String copiedText =
+ selStart == selEnd
+ ? ""
+ : editable
+ .toString()
+ .substring(Math.min(selStart, selEnd), Math.max(selStart, selEnd));
+ Clipboard.setText(view.getContext(), copiedText);
+ break;
+ }
+ return true;
+ }
+
+ @Override
+ public boolean performEditorAction(final int editorAction) {
+ if (editorAction == EditorInfo.IME_ACTION_PREVIOUS && !mIMEActionHint.equals("previous")) {
+ // This action is [Previous] key on FireTV's keyboard.
+ // [Previous] closes software keyboard, and don't generate any keyboard event.
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().hideSoftInput(mSession);
+ }
+ });
+ return true;
+ }
+ return super.performEditorAction(editorAction);
+ }
+
+ @Override
+ public ExtractedText getExtractedText(final ExtractedTextRequest req, final int flags) {
+ if (req == null) return null;
+
+ if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req;
+
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return null;
+ }
+ final int selStart = Selection.getSelectionStart(editable);
+ final int selEnd = Selection.getSelectionEnd(editable);
+
+ final ExtractedText extract = new ExtractedText();
+ extract.flags = 0;
+ extract.partialStartOffset = -1;
+ extract.partialEndOffset = -1;
+ extract.selectionStart = selStart;
+ extract.selectionEnd = selEnd;
+ extract.startOffset = 0;
+ if ((req.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extract.text = new SpannableString(editable);
+ } else {
+ extract.text = editable.toString();
+ }
+ return extract;
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public View getView() {
+ return mView;
+ }
+
+ @NonNull
+ /* package */ GeckoSession.TextInputDelegate getInputDelegate() {
+ return mSession.getTextInput().getDelegate();
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onTextChange() {
+ final Editable editable = getEditable();
+ if (mUpdateRequest == null || editable == null) {
+ return;
+ }
+
+ final ExtractedTextRequest request = mUpdateRequest;
+ final ExtractedText extractedText = new ExtractedText();
+ extractedText.flags = 0;
+ // Update the entire Editable range
+ extractedText.partialStartOffset = -1;
+ extractedText.partialEndOffset = -1;
+ extractedText.selectionStart = Selection.getSelectionStart(editable);
+ extractedText.selectionEnd = Selection.getSelectionEnd(editable);
+ extractedText.startOffset = 0;
+ if ((request.flags & GET_TEXT_WITH_STYLES) != 0) {
+ extractedText.text = new SpannableString(editable);
+ } else {
+ extractedText.text = editable.toString();
+ }
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateExtractedText(mSession, request, extractedText);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onSelectionChange() {
+
+ final Editable editable = getEditable();
+ if (editable != null) {
+ mLastSelectionStart = Selection.getSelectionStart(editable);
+ mLastSelectionEnd = Selection.getSelectionEnd(editable);
+ notifySelectionChange(mLastSelectionStart, mLastSelectionEnd);
+ }
+ }
+
+ private void notifySelectionChange(final int start, final int end) {
+ final Editable editable = getEditable();
+ if (editable == null) {
+ return;
+ }
+
+ final int compositionStart = getComposingSpanStart(editable);
+ final int compositionEnd = getComposingSpanEnd(editable);
+
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .updateSelection(mSession, start, end, compositionStart, compositionEnd);
+ }
+ });
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDiscardComposition() {
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. But ATOK series do nothing. So we have to
+ // restart input method to remove composition as workaround.
+ if (!InputMethods.needsRestartInput(InputMethods.getCurrentInputMethod(view.getContext()))) {
+ return;
+ }
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate()
+ .restartInput(
+ mSession, GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE);
+ }
+ });
+ }
+
+ @TargetApi(21)
+ @Override // SessionTextInput.EditableListener
+ public void updateCompositionRects(final RectF[] rects, final RectF caretRect) {
+ if (!(Build.VERSION.SDK_INT >= 21)) {
+ return;
+ }
+
+ final View view = getView();
+ if (view == null) {
+ return;
+ }
+
+ final Editable content = getEditable();
+ if (content == null) {
+ return;
+ }
+
+ final int composingStart = getComposingSpanStart(content);
+ final int composingEnd = getComposingSpanEnd(content);
+ if (composingStart < 0 || composingEnd < 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "No composition for updates");
+ }
+ return;
+ }
+
+ final CharSequence composition = content.subSequence(composingStart, composingEnd);
+
+ view.post(
+ new Runnable() {
+ @Override
+ public void run() {
+ updateCompositionRectsOnUi(view, rects, caretRect, composition);
+ }
+ });
+ }
+
+ @TargetApi(21)
+ /* package */ void updateCompositionRectsOnUi(
+ final View view, final RectF[] rects, final RectF caretRect, final CharSequence composition) {
+ if (mCursorAnchorInfoBuilder == null) {
+ mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
+ }
+ mCursorAnchorInfoBuilder.reset();
+
+ final Matrix matrix = new Matrix();
+ mSession.getClientToScreenOffsetMatrix(matrix);
+ mCursorAnchorInfoBuilder.setMatrix(matrix);
+
+ for (int i = 0; i < rects.length; i++) {
+ mCursorAnchorInfoBuilder.addCharacterBounds(
+ i,
+ rects[i].left,
+ rects[i].top,
+ rects[i].right,
+ rects[i].bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ mCursorAnchorInfoBuilder.setComposingText(0, composition);
+
+ if (!caretRect.isEmpty()) {
+ // Gecko doesn't provide baseline information of caret.
+ mCursorAnchorInfoBuilder.setInsertionMarkerLocation(
+ caretRect.left,
+ caretRect.top,
+ caretRect.bottom,
+ caretRect.bottom,
+ CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION);
+ }
+
+ final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build();
+ getView()
+ .post(
+ new Runnable() {
+ @Override
+ public void run() {
+ getInputDelegate().updateCursorAnchorInfo(mSession, info);
+ }
+ });
+ }
+
+ @Override
+ public boolean requestCursorUpdates(final int cursorUpdateMode) {
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.ONE_SHOT);
+ }
+
+ if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.START_MONITOR);
+ } else {
+ mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.END_MONITOR);
+ }
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void onDefaultKeyEvent(final KeyEvent event) {
+ ThreadUtils.runOnUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ GeckoInputConnection.this.performDefaultKeyAction(event);
+ }
+ });
+ }
+
+ private static synchronized Handler getBackgroundHandler() {
+ if (sBackgroundHandler != null) {
+ return sBackgroundHandler;
+ }
+ // Don't use GeckoBackgroundThread because Gecko thread may block waiting on
+ // GeckoBackgroundThread. If we were to use GeckoBackgroundThread, due to IME,
+ // GeckoBackgroundThread may end up also block waiting on Gecko thread and a
+ // deadlock occurs
+ final Thread backgroundThread =
+ new Thread(
+ new Runnable() {
+ @Override
+ public void run() {
+ Looper.prepare();
+ synchronized (GeckoInputConnection.class) {
+ sBackgroundHandler = new Handler();
+ GeckoInputConnection.class.notify();
+ }
+ Looper.loop();
+ // We should never be exiting the thread loop.
+ throw new IllegalThreadStateException("unreachable code");
+ }
+ },
+ LOGTAG);
+ backgroundThread.setDaemon(true);
+ backgroundThread.start();
+ while (sBackgroundHandler == null) {
+ try {
+ // wait for new thread to set sBackgroundHandler
+ GeckoInputConnection.class.wait();
+ } catch (final InterruptedException e) {
+ }
+ }
+ return sBackgroundHandler;
+ }
+
+ private synchronized boolean canReturnCustomHandler() {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return false;
+ }
+ for (final StackTraceElement frame : Thread.currentThread().getStackTrace()) {
+ // We only return our custom Handler to InputMethodManager's InputConnection
+ // proxy. For all other purposes, we return the regular Handler.
+ // InputMethodManager retrieves the Handler for its InputConnection proxy
+ // inside its method startInputInner(), so we check for that here. This is
+ // valid from Android 2.2 to at least Android 4.2. If this situation ever
+ // changes, we gracefully fall back to using the regular Handler.
+ if ("startInputInner".equals(frame.getMethodName())
+ && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) {
+ // Only return our own Handler to InputMethodManager and only prior to 24.
+ return Build.VERSION.SDK_INT < 24;
+ }
+ if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName())
+ && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) {
+ // InputConnection tests should also run on the custom handler
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean isPhysicalKeyboardPresent() {
+ final View v = getView();
+ if (v == null) {
+ return false;
+ }
+ final Configuration config = v.getContext().getResources().getConfiguration();
+ return config.keyboard != Configuration.KEYBOARD_NOKEYS;
+ }
+
+ @Override // InputConnection
+ public Handler getHandler() {
+ final Handler handler;
+ if (isPhysicalKeyboardPresent()) {
+ handler = ThreadUtils.getUiHandler();
+ } else {
+ handler = getBackgroundHandler();
+ }
+ return mEditableClient.setInputConnectionHandler(handler);
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public Handler getHandler(final Handler defHandler) {
+ if (!canReturnCustomHandler()) {
+ return defHandler;
+ }
+
+ return getHandler();
+ }
+
+ @Override // InputConnection
+ public void closeConnection() {
+ if (mBatchEditCount != 0) {
+ // GBoard may call this into batch edit mode then it doesn't call endBatchEdit.
+ // Since we are recycle GeckoInputConnection, we have to reset
+ // batch count even if IME/keyboard bug.
+ if (DEBUG) {
+ Log.d(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ }
+ mBatchEditCount = 0;
+ // setBatchMode will call onTextChange and/or onSelectionChange for us.
+ mEditableClient.setBatchMode(false);
+ }
+ super.closeConnection();
+ }
+
+ @Override // SessionTextInput.InputConnectionClient
+ public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) {
+ if (mIMEState == IME_STATE_DISABLED) {
+ return null;
+ }
+
+ final Context context = getView().getContext();
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ if (Math.min(metrics.widthPixels, metrics.heightPixels) > INLINE_IME_MIN_DISPLAY_SIZE) {
+ // prevent showing full-screen keyboard only when the screen is tall enough
+ // to show some reasonable amount of the page (see bug 752709)
+ outAttrs.imeOptions |= EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_FULLSCREEN;
+ }
+
+ if (DEBUG) {
+ Log.d(
+ LOGTAG,
+ "mapped IME states to: inputType = "
+ + Integer.toHexString(outAttrs.inputType)
+ + ", imeOptions = "
+ + Integer.toHexString(outAttrs.imeOptions));
+ }
+
+ final String prevInputMethod = mCurrentInputMethod;
+ mCurrentInputMethod = InputMethods.getCurrentInputMethod(context);
+ if (DEBUG) {
+ Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod);
+ }
+
+ outAttrs.initialSelStart = mLastSelectionStart;
+ outAttrs.initialSelEnd = mLastSelectionEnd;
+ return this;
+ }
+
+ private boolean replaceComposingSpanWithSelection() {
+ final Editable content = getEditable();
+ if (content == null) {
+ return false;
+ }
+ final int a = getComposingSpanStart(content);
+ final int b = getComposingSpanEnd(content);
+ if (a != -1 && b != -1) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "removing composition at " + a + "-" + b);
+ }
+ removeComposingSpans(content);
+ Selection.setSelection(content, a, b);
+ }
+ return true;
+ }
+
+ @Override
+ public boolean commitText(final CharSequence text, final int newCursorPosition) {
+ if (InputMethods.shouldCommitCharAsKey(mCurrentInputMethod)
+ && text.length() == 1
+ && newCursorPosition > 0) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "committing \"" + text + "\" as key");
+ }
+ // mKeyInputConnection is a BaseInputConnection that commits text as keys;
+ // but we first need to replace any composing span with a selection,
+ // so that the new key events will generate characters to replace
+ // text from the old composing span
+ return replaceComposingSpanWithSelection()
+ && mKeyInputConnection.commitText(text, newCursorPosition);
+ }
+ return super.commitText(text, newCursorPosition);
+ }
+
+ @Override
+ public boolean setSelection(final int start, final int end) {
+ if (start < 0 || end < 0) {
+ // Some keyboards (e.g. Samsung) can call setSelection with
+ // negative offsets. In that case we ignore the call, similar to how
+ // BaseInputConnection.setSelection ignores offsets that go past the length.
+ return true;
+ }
+ return super.setSelection(start, end);
+ }
+
+ @Override
+ public boolean sendKeyEvent(final @NonNull KeyEvent event) {
+ final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event);
+ mEditableClient.sendKeyEvent(getView(), event.getAction(), translatedEvent);
+ return false; // seems to always return false
+ }
+
+ private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ENTER:
+ if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0
+ && mIMEActionHint.equals("maybenext")) {
+ // XXX It is not good to dispatch tab key for web compatibility.
+ // See https://github.com/w3c/uievents/issues/253 and bug 1600540.
+ return new KeyEvent(
+ event.getDownTime(),
+ event.getEventTime(),
+ event.getAction(),
+ KeyEvent.KEYCODE_TAB,
+ 0);
+ }
+ break;
+ }
+ return event;
+ }
+
+ // Called by OnDefaultKeyEvent handler, up from Gecko
+ /* package */ void performDefaultKeyAction(final KeyEvent event) {
+ switch (event.getKeyCode()) {
+ case KeyEvent.KEYCODE_MUTE:
+ case KeyEvent.KEYCODE_HEADSETHOOK:
+ case KeyEvent.KEYCODE_MEDIA_PLAY:
+ case KeyEvent.KEYCODE_MEDIA_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE:
+ case KeyEvent.KEYCODE_MEDIA_STOP:
+ case KeyEvent.KEYCODE_MEDIA_NEXT:
+ case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
+ case KeyEvent.KEYCODE_MEDIA_REWIND:
+ case KeyEvent.KEYCODE_MEDIA_RECORD:
+ case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
+ case KeyEvent.KEYCODE_MEDIA_CLOSE:
+ case KeyEvent.KEYCODE_MEDIA_EJECT:
+ case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK:
+ // Forward media keypresses to the registered handler so headset controls work
+ // Does the same thing as Chromium
+ // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445
+ // These are all the keys dispatchMediaKeyEvent supports.
+ if (Build.VERSION.SDK_INT >= 19) {
+ // dispatchMediaKeyEvent is only available on Android 4.4+
+ final Context viewContext = getView().getContext();
+ final AudioManager am =
+ (AudioManager) viewContext.getSystemService(Context.AUDIO_SERVICE);
+ am.dispatchMediaKeyEvent(event);
+ }
+ break;
+ }
+ }
+
+ @TargetApi(Build.VERSION_CODES.N_MR1)
+ @Override
+ public boolean commitContent(
+ final InputContentInfo inputContentInfo, final int flags, final Bundle opts) {
+ final boolean requestPermission =
+ ((flags & InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0);
+ if (requestPermission) {
+ try {
+ inputContentInfo.requestPermission();
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "InputContentInfo.requestPermission() failed.", e);
+ return false;
+ }
+ }
+
+ try (final InputStream inputStream =
+ getView()
+ .getContext()
+ .getContentResolver()
+ .openInputStream(inputContentInfo.getContentUri());
+ final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ final byte[] data = new byte[4096];
+ int readed;
+ while ((readed = inputStream.read(data)) != -1) {
+ outputStream.write(data, 0, readed);
+ }
+ mEditableClient.insertImage(
+ outputStream.toByteArray(), inputContentInfo.getDescription().getMimeType(0));
+ } catch (final FileNotFoundException e) {
+ Log.e(LOGTAG, "Cannot open provider URI.", e);
+ return false;
+ } catch (final IOException e) {
+ Log.e(LOGTAG, "Cannot read/write provider URI.", e);
+ return false;
+ } finally {
+ if (requestPermission) {
+ inputContentInfo.releasePermission();
+ }
+ }
+
+ return true;
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public void notifyIME(final @IMENotificationType int type) {
+ switch (type) {
+ case NOTIFY_IME_OF_FOCUS:
+ // Showing/hiding vkb is done in notifyIMEContext
+ if (mBatchEditCount != 0) {
+ Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount);
+ mBatchEditCount = 0;
+ }
+ break;
+
+ case NOTIFY_IME_OF_BLUR:
+ break;
+
+ case NOTIFY_IME_OF_TOKEN:
+ case NOTIFY_IME_OPEN_VKB:
+ case NOTIFY_IME_REPLY_EVENT:
+ case NOTIFY_IME_TO_CANCEL_COMPOSITION:
+ case NOTIFY_IME_TO_COMMIT_COMPOSITION:
+ default:
+ if (DEBUG) {
+ throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type);
+ }
+ break;
+ }
+ }
+
+ @Override // SessionTextInput.EditableListener
+ public synchronized void notifyIMEContext(
+ @IMEState final int state,
+ final String typeHint,
+ final String modeHint,
+ final String actionHint,
+ @IMEContextFlags final int flags) {
+ // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext,
+ // and not reset anywhere else. Usually, notifyIMEContext is called right after a
+ // focus or blur, so resetting mIMEState during the focus or blur seems harmless.
+ // However, this behavior is not guaranteed. Gecko may call notifyIMEContext
+ // independent of focus change; that is, a focus change may not be accompanied by
+ // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not
+ // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318)
+ /* When IME is 'disabled', IME processing is disabled.
+ In addition, the IME UI is hidden */
+ mIMEState = state;
+ mIMEActionHint = (actionHint == null) ? "" : actionHint;
+
+ // These fields are reset here and will be updated when restartInput is called below
+ mUpdateRequest = null;
+ mCurrentInputMethod = "";
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java
new file mode 100644
index 0000000000..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<ByteBuffer> mBuffers = new LinkedList<>();
+ private boolean mEOF;
+ private boolean mClosed;
+ private boolean mHaveError;
+ private long mReadTimeout;
+ private boolean mResumed;
+ private Support mSupport;
+
+ /**
+ * This is only called via JNI. The support instance provides callbacks for the native
+ * counterpart.
+ *
+ * @param support An instance of {@link Support}, used for native callbacks.
+ */
+ /* package */ GeckoInputStream(final @Nullable Support support) {
+ mSupport = support;
+ }
+
+ public void setReadTimeoutMillis(final long millis) {
+ mReadTimeout = millis;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ super.close();
+ mClosed = true;
+
+ if (mSupport != null) {
+ mSupport.close();
+ mSupport = null;
+ }
+ }
+
+ @Override
+ public synchronized int available() throws IOException {
+ if (mClosed) {
+ return 0;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ return buf != null ? buf.remaining() : 0;
+ }
+
+ private void ensureNotClosed() throws IOException {
+ if (mClosed) {
+ throw new IOException("Stream is closed");
+ }
+ }
+
+ @Override
+ public synchronized int read() throws IOException {
+ ensureNotClosed();
+
+ final int expect = Integer.SIZE / 8;
+ final byte[] bytes = new byte[expect];
+
+ int count = 0;
+ while (count < expect) {
+ final long bytesRead = read(bytes, count, expect - count);
+ if (bytesRead < 0) {
+ return -1;
+ }
+
+ count += bytesRead;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ return buffer.getInt();
+ }
+
+ @Override
+ public int read(final @NonNull byte[] b) throws IOException {
+ return read(b, 0, b.length);
+ }
+
+ @Override
+ public synchronized int read(final @NonNull byte[] dest, final int offset, final int length)
+ throws IOException {
+ ensureNotClosed();
+
+ final long startTime = System.currentTimeMillis();
+ while (!mEOF && mBuffers.size() == 0) {
+ if (mReadTimeout > 0 && (System.currentTimeMillis() - startTime) >= mReadTimeout) {
+ throw new IOException("Timed out");
+ }
+
+ // The underlying channel is suspended, so resume that before
+ // waiting for a buffer.
+ if (!mResumed) {
+ if (mSupport != null) {
+ mSupport.resume();
+ }
+ mResumed = true;
+ }
+
+ try {
+ wait(mReadTimeout);
+ } catch (final InterruptedException e) {
+ }
+ }
+
+ if (mEOF && mBuffers.size() == 0) {
+ if (mHaveError) {
+ throw new IOException("Unknown error");
+ }
+
+ // We have no data and we're not expecting more.
+ return -1;
+ }
+
+ final ByteBuffer buf = mBuffers.peekFirst();
+ final int readCount = Math.min(length, buf.remaining());
+ buf.get(dest, offset, readCount);
+
+ if (buf.remaining() == 0) {
+ // We're done with this buffer, advance the queue.
+ mBuffers.removeFirst();
+ }
+
+ return readCount;
+ }
+
+ /** Called by native code to indicate that no more data will be sent via {@link #appendBuffer}. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendEof() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ notifyAll();
+ }
+
+ /** Called by native code to indicate that there was an error while reading the stream. */
+ @WrapForJNI(calledFrom = "gecko")
+ public synchronized void sendError() {
+ if (mEOF) {
+ throw new IllegalStateException("Already have EOF");
+ }
+
+ mEOF = true;
+ mHaveError = true;
+ notifyAll();
+ }
+
+ /**
+ * Called by native code to 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,
+ *
+ * <pre>
+ * public GeckoResult&lt;Integer&gt; divide(final int dividend, final int divisor) {
+ * final GeckoResult&lt;Integer&gt; result = new GeckoResult&lt;&gt;();
+ * (new Thread(() -&gt; {
+ * if (divisor != 0) {
+ * result.complete(dividend / divisor);
+ * } else {
+ * result.completeExceptionally(new ArithmeticException("Dividing by zero"));
+ * }
+ * })).start();
+ * return result;
+ * }</pre>
+ *
+ * <p>To retrieve the completed value or exception, use one of the {@link #then} methods to register
+ * listeners on the result. Listeners are run on the thread where the GeckoResult is created if a
+ * {@link Looper} is present. For example, to retrieve a completed value,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // value == 21
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * });</pre>
+ *
+ * <p>And to retrieve a completed exception,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }, new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>{@link #then} calls may be chained to complete multiple asynchonous operations in sequence.
+ * This example takes an integer, converts it to a String, and appends it to another String,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * return GeckoResult.fromValue(value.toString());
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final String value) {
+ * return GeckoResult.fromValue("42 / 2 = " + value);
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "42 / 2 = 21"
+ * return null;
+ * }
+ * });</pre>
+ *
+ * <p>Chaining works with exception listeners as well. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * return "foo";
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == "foo"
+ * }
+ * });</pre>
+ *
+ * <p>A completed value/exception will propagate down the chain even if an intermediate step does
+ * not have a value/exception listener. For example,
+ *
+ * <pre>
+ * divide(42, 0).then(new GeckoResult.OnValueListener&lt;Integer, String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onValue(final Integer value) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) {
+ * // exception instanceof ArithmeticException
+ * }
+ * });</pre>
+ *
+ * <p>However, any propagated value will be coerced to null. For example,
+ *
+ * <pre>
+ * divide(42, 2).then(new GeckoResult.OnExceptionListener&lt;String&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;String&gt; onException(final Throwable exception) {
+ * // Not called
+ * }
+ * }).then(new GeckoResult.OnValueListener&lt;String, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final String value) {
+ * // value == null
+ * }
+ * });</pre>
+ *
+ * <p>If a GeckoResult is created on a thread without a {@link Looper}, {@link
+ * #then(OnValueListener, OnExceptionListener)} is unusable (and will throw {@link
+ * IllegalThreadStateException}). In this scenario, the value is only available via {@link
+ * #poll(long)}. Alternatively, you may also chain the GeckoResult to one with a {@link Handler} via
+ * {@link #withHandler(Handler)}. You may then use {@link #then(OnValueListener,
+ * OnExceptionListener)} on the returned GeckoResult normally.
+ *
+ * <p>Any exception thrown by a listener are automatically used to complete the result. At the end
+ * of every chain, there is an implicit exception listener that rethrows any uncaught and unhandled
+ * exception as {@link UncaughtException}. The following example will cause {@link
+ * UncaughtException} to be thrown because {@code BazException} is uncaught and unhandled at the end
+ * of the chain,
+ *
+ * <pre>
+ * GeckoResult.fromValue(42).then(new GeckoResult.OnValueListener&lt;Integer, Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onValue(final Integer value) throws FooException {
+ * throw new FooException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Exception {
+ * // exception instanceof FooException
+ * throw new BarException();
+ * }
+ * }).then(new GeckoResult.OnExceptionListener&lt;Void&gt;() {
+ * &#64;Override
+ * public GeckoResult&lt;Void&gt; onException(final Throwable exception) throws Throwable {
+ * // exception instanceof BarException
+ * return new BazException();
+ * }
+ * });</pre>
+ *
+ * @param <T> The type of the value delivered via the GeckoResult.
+ */
+@AnyThread
+public class GeckoResult<T> {
+ private static final String LOGTAG = "GeckoResult";
+
+ private interface Dispatcher {
+ void dispatch(Runnable r);
+ }
+
+ private static class HandlerDispatcher implements Dispatcher {
+ HandlerDispatcher(final Handler h) {
+ mHandler = h;
+ }
+
+ public void dispatch(final Runnable r) {
+ mHandler.post(r);
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof HandlerDispatcher)) {
+ return false;
+ }
+ return mHandler.equals(((HandlerDispatcher) other).mHandler);
+ }
+
+ @Override
+ public int hashCode() {
+ return mHandler.hashCode();
+ }
+
+ Handler mHandler;
+ }
+
+ private static class XPCOMEventTargetDispatcher implements Dispatcher {
+ private IXPCOMEventTarget mEventTarget;
+
+ public XPCOMEventTargetDispatcher(final IXPCOMEventTarget eventTarget) {
+ mEventTarget = eventTarget;
+ }
+
+ @Override
+ public void dispatch(final Runnable r) {
+ mEventTarget.execute(r);
+ }
+ }
+
+ private static class DirectDispatcher implements Dispatcher {
+ public void dispatch(final Runnable r) {
+ r.run();
+ }
+
+ static DirectDispatcher sInstance = new DirectDispatcher();
+
+ private DirectDispatcher() {}
+ }
+
+ public static final class UncaughtException extends RuntimeException {
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public UncaughtException(final Throwable cause) {
+ super(cause);
+ }
+ }
+
+ /** Interface used to delegate cancellation operations for a {@link GeckoResult}. */
+ @AnyThread
+ public interface CancellationDelegate {
+
+ /**
+ * This method should attempt to cancel the in-progress operation for the result to which this
+ * instance was attached. See {@link GeckoResult#cancel()} for more details.
+ *
+ * @return A {@link GeckoResult} resolving to "true" if cancellation was successful, "false"
+ * otherwise.
+ */
+ default @NonNull GeckoResult<Boolean> cancel() {
+ return GeckoResult.fromValue(false);
+ }
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#DENY}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> deny() {
+ return GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ /**
+ * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#ALLOW}
+ */
+ @AnyThread
+ @NonNull
+ public static GeckoResult<AllowOrDeny> allow() {
+ return GeckoResult.fromValue(AllowOrDeny.ALLOW);
+ }
+
+ // The default dispatcher for listeners on this GeckoResult. Other dispatchers can be specified
+ // when the listener is registered.
+ private final Dispatcher mDispatcher;
+ private boolean mComplete;
+ private T mValue;
+ private Throwable mError;
+ private boolean mIsUncaughtError;
+ private SimpleArrayMap<Dispatcher, ArrayList<Runnable>> mListeners = new SimpleArrayMap<>();
+
+ private GeckoResult<?> mParent;
+ private CancellationDelegate mCancellationDelegate;
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ */
+ @WrapForJNI
+ public GeckoResult() {
+ if (ThreadUtils.isOnUiThread()) {
+ mDispatcher = new HandlerDispatcher(ThreadUtils.getUiHandler());
+ } else if (Looper.myLooper() != null) {
+ mDispatcher = new HandlerDispatcher(new Handler());
+ } else if (XPCOMEventTarget.launcherThread().isOnCurrentThread()) {
+ mDispatcher = new XPCOMEventTargetDispatcher(XPCOMEventTarget.launcherThread());
+ } else {
+ mDispatcher = null;
+ }
+ }
+
+ /**
+ * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link
+ * #completeExceptionally(Throwable)} in order to fulfill the result.
+ *
+ * @param handler This {@link Handler} will be used for dispatching listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)}.
+ */
+ public GeckoResult(final Handler handler) {
+ mDispatcher = new HandlerDispatcher(handler);
+ }
+
+ /**
+ * This constructs a result that is chained to the specified result.
+ *
+ * @param from The {@link GeckoResult} to copy.
+ */
+ public GeckoResult(final GeckoResult<T> from) {
+ this();
+ completeFrom(from);
+ }
+
+ /**
+ * Construct a result that is completed with the specified value.
+ *
+ * @param value The value used to complete the newly created result.
+ * @param <U> Type for the result.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <U> GeckoResult<U> fromValue(@Nullable final U value) {
+ final GeckoResult<U> result = new GeckoResult<>();
+ result.complete(value);
+ return result;
+ }
+
+ /**
+ * Construct a result that is completed with the specified {@link Throwable}. May not be null.
+ *
+ * @param error The exception used to complete the newly created result.
+ * @param <T> Type for the result if the result had been completed without exception.
+ * @return The completed {@link GeckoResult}
+ */
+ @WrapForJNI
+ public static @NonNull <T> GeckoResult<T> fromException(@NonNull final Throwable error) {
+ final GeckoResult<T> result = new GeckoResult<>();
+ result.completeExceptionally(error);
+ return result;
+ }
+
+ @Override
+ public synchronized int hashCode() {
+ return Arrays.hashCode(new Object[] {mComplete, mValue, mError});
+ }
+
+ // This can go away once we can rely on java.util.Objects.equals() (API 19)
+ private static boolean objectEquals(final Object a, final Object b) {
+ return a == b || (a != null && a.equals(b));
+ }
+
+ @Override
+ public synchronized boolean equals(final Object other) {
+ if (other instanceof GeckoResult<?>) {
+ final GeckoResult<?> result = (GeckoResult<?>) other;
+ return result.mComplete == mComplete
+ && objectEquals(result.mError, mError)
+ && objectEquals(result.mValue, mValue);
+ }
+
+ return false;
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(@NonNull final OnValueListener<T, U> valueListener) {
+ return then(valueListener, null);
+ }
+
+ /**
+ * Convenience method for {@link #map(OnValueMapper, OnExceptionMapper)}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(@Nullable final OnValueMapper<T, U> valueMapper) {
+ return map(valueMapper, null);
+ }
+
+ /**
+ * Transform the value and error of this {@link GeckoResult}.
+ *
+ * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionMapper An instance of {@link OnExceptionMapper}, called when the {@link
+ * GeckoResult} is completed with an exception.
+ * @param <U> Type of the new value that is returned by the mapper.
+ * @return A new {@link GeckoResult} that will contain the mapped value.
+ */
+ public @NonNull <U> GeckoResult<U> map(
+ @Nullable final OnValueMapper<T, U> valueMapper,
+ @Nullable final OnExceptionMapper exceptionMapper) {
+ final OnValueListener<T, U> valueListener =
+ valueMapper != null ? value -> GeckoResult.fromValue(valueMapper.onValue(value)) : null;
+ final OnExceptionListener<U> exceptionListener =
+ exceptionMapper != null
+ ? error -> GeckoResult.fromException(exceptionMapper.onException(error))
+ : null;
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}.
+ *
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Exception}.
+ * @param <U> Type of the new result that is returned by the listener.
+ * @return A new {@link GeckoResult} that the listener will complete.
+ */
+ public @NonNull <U> GeckoResult<U> exceptionally(
+ @NonNull final OnExceptionListener<U> exceptionListener) {
+ return then(null, exceptionListener);
+ }
+
+ /**
+ * Replacement for {@link java.util.function.Consumer} for devices with minApi &lt; 24.
+ *
+ * @param <T> the type of the input for this consumer.
+ */
+ // TODO: Remove this when we move to min API 24
+ public interface Consumer<T> {
+ /**
+ * Run this consumer for the given input.
+ *
+ * @param t the input value.
+ */
+ @AnyThread
+ void accept(@Nullable T t);
+ }
+
+ /**
+ * Convenience method for {@link #accept(Consumer, Consumer)}.
+ *
+ * @param valueListener An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(@Nullable final Consumer<T> valueListener) {
+ return accept(valueListener, null);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueConsumer An instance of {@link Consumer}, called when the {@link GeckoResult} is
+ * completed with a value.
+ * @param exceptionConsumer An instance of {@link Consumer}, called when the {@link GeckoResult}
+ * is completed with an {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> accept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ final OnValueListener<T, Void> valueListener =
+ valueConsumer == null
+ ? null
+ : value -> {
+ valueConsumer.accept(value);
+ return null;
+ };
+
+ final OnExceptionListener<Void> exceptionListener =
+ exceptionConsumer == null
+ ? null
+ : value -> {
+ exceptionConsumer.accept(value);
+ return null;
+ };
+
+ return then(valueListener, exceptionListener);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed regardless of success
+ * status. Listeners will be invoked on the {@link Looper} returned from {@link #getLooper()}. If
+ * null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param finallyRunnable An instance of {@link Runnable}, called when the {@link GeckoResult} is
+ * completed with a value or a {@link Throwable}.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull GeckoResult<Void> finally_(@NonNull final Runnable finallyRunnable) {
+ final OnValueListener<T, Void> valueListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ final OnExceptionListener<Void> exceptionListener =
+ value -> {
+ finallyRunnable.run();
+ return null;
+ };
+ return then(valueListener, exceptionListener);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(@Nullable final Consumer<T> valueConsumer) {
+ return getOrAccept(valueConsumer, null);
+ }
+
+ /* package */ @NonNull
+ GeckoResult<Void> getOrAccept(
+ @Nullable final Consumer<T> valueConsumer,
+ @Nullable final Consumer<Throwable> exceptionConsumer) {
+ if (haveValue() && valueConsumer != null) {
+ valueConsumer.accept(mValue);
+ return GeckoResult.fromValue(null);
+ }
+
+ if (haveError() && exceptionConsumer != null) {
+ exceptionConsumer.accept(mError);
+ return GeckoResult.fromValue(null);
+ }
+
+ return accept(valueConsumer, exceptionConsumer);
+ }
+
+ /**
+ * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or
+ * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link
+ * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}.
+ *
+ * <p>If the result is already complete when this method is called, listeners will be invoked in a
+ * future {@link Looper} iteration.
+ *
+ * @param valueListener An instance of {@link OnValueListener}, called when the {@link
+ * GeckoResult} is completed with a value.
+ * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link
+ * GeckoResult} is completed with an {@link Throwable}.
+ * @param <U> Type of the new result that is returned by the listeners.
+ * @return A new {@link GeckoResult} that the listeners will complete.
+ */
+ public @NonNull <U> GeckoResult<U> then(
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (mDispatcher == null) {
+ throw new IllegalThreadStateException("Must have a Handler");
+ }
+
+ return thenInternal(mDispatcher, valueListener, exceptionListener);
+ }
+
+ private @NonNull <U> GeckoResult<U> thenInternal(
+ @NonNull final Dispatcher dispatcher,
+ @Nullable final OnValueListener<T, U> valueListener,
+ @Nullable final OnExceptionListener<U> exceptionListener) {
+ if (valueListener == null && exceptionListener == null) {
+ throw new IllegalArgumentException("At least one listener should be non-null");
+ }
+
+ final GeckoResult<U> result = new GeckoResult<U>();
+ result.mParent = this;
+ thenInternal(
+ dispatcher,
+ () -> {
+ try {
+ if (haveValue()) {
+ result.completeFrom(valueListener != null ? valueListener.onValue(mValue) : null);
+ } else if (!haveError()) {
+ // Listener called without completion?
+ throw new AssertionError();
+ } else if (exceptionListener != null) {
+ result.completeFrom(exceptionListener.onException(mError));
+ } else {
+ result.mIsUncaughtError = mIsUncaughtError;
+ result.completeExceptionally(mError);
+ }
+ } catch (final Throwable e) {
+ if (!result.mComplete) {
+ result.mIsUncaughtError = true;
+ result.completeExceptionally(e);
+ } else if (e instanceof RuntimeException) {
+ // This should only be UncaughtException, but we rethrow all RuntimeExceptions
+ // to avoid squelching logic errors in GeckoResult itself.
+ throw (RuntimeException) e;
+ }
+ }
+ });
+ return result;
+ }
+
+ private synchronized void thenInternal(
+ @NonNull final Dispatcher dispatcher, @NonNull final Runnable listener) {
+ if (mComplete) {
+ dispatcher.dispatch(listener);
+ } else {
+ if (!mListeners.containsKey(dispatcher)) {
+ mListeners.put(dispatcher, new ArrayList<>(1));
+ }
+ mListeners.get(dispatcher).add(listener);
+ }
+ }
+
+ @WrapForJNI
+ private void nativeThen(
+ @NonNull final GeckoCallback accept, @NonNull final GeckoCallback reject) {
+ // NB: We could use the lambda syntax here, but given all the layers
+ // of abstraction it's helpful to see the types written explicitly.
+ thenInternal(
+ DirectDispatcher.sInstance,
+ new OnValueListener<T, Void>() {
+ @Override
+ public GeckoResult<Void> onValue(final T value) {
+ accept.call(value);
+ return null;
+ }
+ },
+ new OnExceptionListener<Void>() {
+ @Override
+ public GeckoResult<Void> onException(final Throwable exception) {
+ reject.call(exception);
+ return null;
+ }
+ });
+ }
+
+ /**
+ * @return Get the {@link Looper} that will be used to schedule listeners registered via {@link
+ * #then(OnValueListener, OnExceptionListener)}.
+ */
+ public @Nullable Looper getLooper() {
+ if (mDispatcher == null || !(mDispatcher instanceof HandlerDispatcher)) {
+ return null;
+ }
+
+ return ((HandlerDispatcher) mDispatcher).mHandler.getLooper();
+ }
+
+ /**
+ * Returns a new GeckoResult that will be completed by this instance. Listeners registered via
+ * {@link #then(OnValueListener, OnExceptionListener)} will be run on the specified {@link
+ * Handler}.
+ *
+ * @param handler A {@link Handler} where listeners will be run. May be null.
+ * @return A new GeckoResult.
+ */
+ public @NonNull GeckoResult<T> withHandler(final @Nullable Handler handler) {
+ final GeckoResult<T> result = new GeckoResult<>(handler);
+ result.completeFrom(this);
+ return result;
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @SuppressWarnings("varargs")
+ @SafeVarargs
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @NonNull GeckoResult<V>... pending) {
+ return allOf(Arrays.asList(pending));
+ }
+
+ /**
+ * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances
+ * are complete.
+ *
+ * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The
+ * list is guaranteed to be in the same order as the inputs.
+ *
+ * <p>If any of the {@link GeckoResult} fails, the returned result will fail.
+ *
+ * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value
+ * <code>null</code>.
+ *
+ * @param pending the input {@link GeckoResult}s.
+ * @param <V> type of the {@link GeckoResult}'s values.
+ * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when
+ * at least one of the inputs fail.
+ */
+ @NonNull
+ public static <V> GeckoResult<List<V>> allOf(final @Nullable List<GeckoResult<V>> pending) {
+ if (pending == null) {
+ return GeckoResult.fromValue(null);
+ }
+
+ return new AllOfResult<>(pending);
+ }
+
+ private static class AllOfResult<V> extends GeckoResult<List<V>> {
+ private boolean mFailed = false;
+ private int mResultCount = 0;
+ private final List<V> mAccumulator;
+ private final List<GeckoResult<V>> mPending;
+
+ public AllOfResult(final @NonNull List<GeckoResult<V>> pending) {
+ // Initialize the list with nulls so we can fill it in the same order as the input list
+ mAccumulator = new ArrayList<>(Collections.nCopies(pending.size(), null));
+ mPending = pending;
+
+ // If the input list is empty, there's nothing to do
+ if (pending.size() == 0) {
+ complete(mAccumulator);
+ return;
+ }
+
+ // We use iterators so we can access the index and preserve the list order
+ final ListIterator<GeckoResult<V>> it = pending.listIterator();
+ while (it.hasNext()) {
+ final int index = it.nextIndex();
+ it.next().accept(value -> onResult(value, index), this::onError);
+ }
+ }
+
+ private void onResult(final V value, final int index) {
+ if (mFailed) {
+ // Some other element in the list already failed, nothing to do here
+ return;
+ }
+
+ mResultCount++;
+ mAccumulator.set(index, value);
+
+ if (mResultCount == mPending.size()) {
+ complete(mAccumulator);
+ }
+ }
+
+ private void onError(final Throwable error) {
+ mFailed = true;
+ completeExceptionally(error);
+ }
+ }
+
+ private void dispatchLocked() {
+ if (!mComplete) {
+ throw new IllegalStateException("Cannot dispatch unless result is complete");
+ }
+
+ if (mListeners.isEmpty()) {
+ if (mIsUncaughtError) {
+ // We have no listeners to forward the uncaught exception to;
+ // rethrow the exception to make it visible.
+ throw new UncaughtException(mError);
+ }
+ return;
+ }
+
+ if (mDispatcher == null) {
+ throw new AssertionError("Shouldn't have listeners with null dispatcher");
+ }
+
+ for (int i = 0; i < mListeners.size(); ++i) {
+ final Dispatcher dispatcher = mListeners.keyAt(i);
+ final ArrayList<Runnable> jobs = mListeners.valueAt(i);
+ dispatcher.dispatch(
+ () -> {
+ for (final Runnable job : jobs) {
+ job.run();
+ }
+ });
+ }
+ mListeners.clear();
+ }
+
+ /**
+ * Completes this result based on another result.
+ *
+ * @param other The result that this result should mirror
+ */
+ public void completeFrom(final @Nullable GeckoResult<T> other) {
+ if (other == null) {
+ complete(null);
+ return;
+ }
+
+ this.mCancellationDelegate = other.mCancellationDelegate;
+ other.thenInternal(
+ DirectDispatcher.sInstance,
+ () -> {
+ if (other.haveValue()) {
+ complete(other.mValue);
+ } else {
+ mIsUncaughtError = other.mIsUncaughtError;
+ completeExceptionally(other.mError);
+ }
+ });
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>You must not call this method if the current thread has a {@link Looper} due to the
+ * possibility of a deadlock. If this occurs, {@link IllegalStateException} is thrown.
+ *
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws IllegalThreadStateException if this method is called on a thread that has a {@link
+ * Looper}.
+ */
+ public synchronized @Nullable T poll() throws Throwable {
+ if (Looper.myLooper() != null) {
+ throw new IllegalThreadStateException("Cannot poll indefinitely from thread with Looper");
+ }
+
+ return poll(Long.MAX_VALUE);
+ }
+
+ /**
+ * Return the value of this result, waiting for it to be completed if necessary. If the result is
+ * completed with an exception it will be rethrown here.
+ *
+ * <p>Caution is advised if the caller is on a thread with a {@link Looper}, as it's possible to
+ * effectively deadlock in cases when the work is being completed on the calling thread. It's
+ * preferable to use {@link #then(OnValueListener, OnExceptionListener)} in such circumstances,
+ * but if you must use this method consider a small timeout value.
+ *
+ * @param timeoutMillis Number of milliseconds to wait for the result to complete.
+ * @return The value of this result.
+ * @throws Throwable The {@link Throwable} contained in this result, if any.
+ * @throws TimeoutException if we wait more than timeoutMillis before the result is completed.
+ */
+ public synchronized @Nullable T poll(final long timeoutMillis) throws Throwable {
+ final long start = SystemClock.uptimeMillis();
+ long remaining = timeoutMillis;
+ while (!mComplete && remaining > 0) {
+ try {
+ wait(remaining);
+ } catch (final InterruptedException e) {
+ }
+
+ remaining = timeoutMillis - (SystemClock.uptimeMillis() - start);
+ }
+
+ if (!mComplete) {
+ throw new TimeoutException();
+ }
+
+ if (haveError()) {
+ throw mError;
+ }
+
+ return mValue;
+ }
+
+ /**
+ * Complete the result with the specified value. IllegalStateException is thrown if the result is
+ * already complete.
+ *
+ * @param value The value used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void complete(final @Nullable T value) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ mValue = value;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * Complete the result with the specified {@link Throwable}. IllegalStateException is thrown if
+ * the result is already complete.
+ *
+ * @param exception The {@link Throwable} used to complete the result.
+ * @throws IllegalStateException If the result is already completed.
+ */
+ @WrapForJNI
+ public synchronized void completeExceptionally(@NonNull final Throwable exception) {
+ if (mComplete) {
+ throw new IllegalStateException("result is already complete");
+ }
+
+ if (exception == null) {
+ throw new IllegalArgumentException("Throwable must not be null");
+ }
+
+ mError = exception;
+ mComplete = true;
+
+ dispatchLocked();
+ notifyAll();
+ }
+
+ /**
+ * An interface used to deliver values to listeners of a {@link GeckoResult}
+ *
+ * @param <T> Type of the value delivered via {@link #onValue(Object)}
+ * @param <U> Type of the value for the result returned from {@link #onValue(Object)}
+ */
+ public interface OnValueListener<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<U> onValue(@Nullable T value) throws Throwable;
+ }
+
+ /**
+ * An interface used to map {@link GeckoResult} values.
+ *
+ * @param <T> Type of the value delivered via {@link #onValue}
+ * @param <U> Type of the new value returned by {@link #onValue}
+ */
+ public interface OnValueMapper<T, U> {
+ /**
+ * Called when a {@link GeckoResult} is completed with a value. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param value The value of the {@link GeckoResult}
+ * @return Value used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ U onValue(@Nullable T value) throws Throwable;
+ }
+
+ /** An interface used to map {@link GeckoResult} exceptions. */
+ public interface OnExceptionMapper {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Exception used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ Throwable onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ /**
+ * An interface used to deliver exceptions to listeners of a {@link GeckoResult}
+ *
+ * @param <V> Type of the vale for the result returned from {@link #onException(Throwable)}
+ */
+ public interface OnExceptionListener<V> {
+ /**
+ * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same
+ * thread where the GeckoResult was created or on the {@link Handler} provided via {@link
+ * #withHandler(Handler)}.
+ *
+ * @param exception Exception that completed the result.
+ * @return Result used to complete the next result in the chain. May be null.
+ * @throws Throwable Exception used to complete next result in the chain.
+ */
+ @AnyThread
+ @Nullable
+ GeckoResult<V> onException(@NonNull Throwable exception) throws Throwable;
+ }
+
+ @WrapForJNI
+ private static class GeckoCallback extends JNIObject {
+ private native void call(Object arg);
+
+ @Override
+ protected native void disposeNative();
+ }
+
+ private boolean haveValue() {
+ return mComplete && mError == null;
+ }
+
+ private boolean haveError() {
+ return mComplete && mError != null;
+ }
+
+ /**
+ * Attempts to cancel the operation associated with this result.
+ *
+ * <p>If this result has a {@link CancellationDelegate} attached via {@link
+ * #setCancellationDelegate(CancellationDelegate)}, the return value will be the result of calling
+ * {@link CancellationDelegate#cancel()} on that instance. Otherwise, if this result is chained to
+ * another result (via return value from {@link OnValueListener}), we will walk up the chain until
+ * a CancellationDelegate is found and run it. If no CancellationDelegate is found, a result
+ * resolving to "false" will be returned.
+ *
+ * <p>If this result is already complete, the returned result will always resolve to false.
+ *
+ * <p>If the returned result resolves to true, this result will be completed with a {@link
+ * CancellationException}.
+ *
+ * @return A GeckoResult resolving to a boolean indicating success or failure of the cancellation
+ * attempt.
+ */
+ public synchronized @NonNull GeckoResult<Boolean> cancel() {
+ if (haveValue() || haveError()) {
+ return GeckoResult.fromValue(false);
+ }
+
+ if (mCancellationDelegate != null) {
+ return mCancellationDelegate
+ .cancel()
+ .then(
+ value -> {
+ if (value) {
+ try {
+ this.completeExceptionally(new CancellationException());
+ } catch (final IllegalStateException e) {
+ // Can't really do anything about this.
+ }
+ }
+ return GeckoResult.fromValue(value);
+ });
+ }
+
+ if (mParent != null) {
+ return mParent.cancel();
+ }
+
+ return GeckoResult.fromValue(false);
+ }
+
+ /**
+ * Sets the instance of {@link CancellationDelegate} that will be invoked by {@link #cancel()}.
+ *
+ * @param delegate an instance of CancellationDelegate.
+ */
+ public void setCancellationDelegate(final @Nullable CancellationDelegate delegate) {
+ mCancellationDelegate = delegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java
new file mode 100644
index 0000000000..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 <a
+ * href="https://sentry.io">Sentry</a> and Mozilla's <a
+ * href="https://wiki.mozilla.org/Socorro">Socorro</a>. <br>
+ * <br>
+ * Be aware, the minidump can contain personally identifiable information. Ensure you are obeying
+ * all applicable laws and policies before sending this to a remote server.
+ *
+ * @see GeckoRuntimeSettings.Builder#crashHandler(Class)
+ */
+ public static final String EXTRA_MINIDUMP_PATH = "minidumpPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a string with the
+ * path to a file containing extra metadata about the crash. The file contains key-value pairs in
+ * the form
+ *
+ * <pre>Key=Value</pre>
+ *
+ * Be aware, it may contain sensitive data such as the URI that was loaded at the time of the
+ * crash.
+ */
+ public static final String EXTRA_EXTRAS_PATH = "extrasPath";
+
+ /**
+ * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String matching
+ * one of the `CRASHED_PROCESS_TYPE_*` constants, describing what type of process the crash
+ * occurred in.
+ *
+ * @see GeckoSession.ContentDelegate#onCrash(GeckoSession)
+ */
+ public static final String EXTRA_CRASH_PROCESS_TYPE = "processType";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating the main application process was
+ * affected by the crash, which is therefore fatal.
+ */
+ public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a foreground child process, such as a
+ * content process, crashed. The application may be able to recover from this crash, but it was
+ * likely noticable to the user.
+ */
+ public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD";
+
+ /**
+ * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a background child process crashed. This
+ * should have been recovered from automatically, and will have had minimal impact to the user, if
+ * any.
+ */
+ public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD";
+
+ private final MemoryController mMemoryController = new MemoryController();
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef(
+ value = {
+ CRASHED_PROCESS_TYPE_MAIN,
+ CRASHED_PROCESS_TYPE_FOREGROUND_CHILD,
+ CRASHED_PROCESS_TYPE_BACKGROUND_CHILD
+ })
+ public @interface CrashedProcessType {}
+
+ private final class LifecycleListener implements LifecycleObserver {
+ private boolean mPaused = false;
+
+ public LifecycleListener() {}
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
+ void onCreate() {
+ Log.d(LOGTAG, "Lifecycle: onCreate");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_START)
+ void onStart() {
+ Log.d(LOGTAG, "Lifecycle: onStart");
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
+ void onResume() {
+ Log.d(LOGTAG, "Lifecycle: onResume");
+ if (mPaused) {
+ // Do not trigger the first onResume event because it breaks nsAppShell::sPauseCount counter
+ // thresholds.
+ GeckoThread.onResume();
+ }
+ mPaused = false;
+ // Can resume location services, checks if was in use before going to background
+ GeckoAppShell.resumeLocation();
+ // Monitor network status and send change notifications to Gecko
+ // while active.
+ GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext());
+
+ // Set settings that may have changed between last app opening
+ GeckoAppShell.setIs24HourFormat(
+ DateFormat.is24HourFormat(GeckoAppShell.getApplicationContext()));
+ }
+
+ @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
+ void onPause() {
+ Log.d(LOGTAG, "Lifecycle: onPause");
+ mPaused = true;
+ // Pause listening for locations when in background
+ GeckoAppShell.pauseLocation();
+ // Stop monitoring network status while inactive.
+ GeckoNetworkManager.getInstance().stop();
+ GeckoThread.onPause();
+ }
+ }
+
+ private static GeckoRuntime sDefaultRuntime;
+
+ /**
+ * Get the default runtime for the given context. This will create and initialize the runtime with
+ * the default settings.
+ *
+ * <p>Note: Only use this for session-less apps. For regular apps, use create() instead.
+ *
+ * @param context An application context for the default runtime.
+ * @return The (static) default runtime for the context.
+ */
+ @UiThread
+ public static synchronized @NonNull GeckoRuntime getDefault(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "getDefault");
+ }
+ if (sDefaultRuntime == null) {
+ sDefaultRuntime = new GeckoRuntime();
+ sDefaultRuntime.attachTo(context);
+ sDefaultRuntime.init(context, new GeckoRuntimeSettings());
+ }
+
+ return sDefaultRuntime;
+ }
+
+ private static GeckoRuntime sRuntime;
+ private GeckoRuntimeSettings mSettings;
+ private Delegate mDelegate;
+ private ServiceWorkerDelegate mServiceWorkerDelegate;
+ private WebNotificationDelegate mNotificationDelegate;
+ private ActivityDelegate mActivityDelegate;
+ private OrientationController mOrientationController;
+ private StorageController mStorageController;
+ private final WebExtensionController mWebExtensionController;
+ private WebPushController mPushController;
+ private final ContentBlockingController mContentBlockingController;
+ private final Autocomplete.StorageProxy mAutocompleteStorageProxy;
+ private final ProfilerController mProfilerController;
+ private final GeckoScreenChangeListener mScreenChangeListener;
+
+ private GeckoRuntime() {
+ mWebExtensionController = new WebExtensionController(this);
+ mContentBlockingController = new ContentBlockingController();
+ mAutocompleteStorageProxy = new Autocomplete.StorageProxy();
+ mProfilerController = new ProfilerController();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ mScreenChangeListener = new GeckoScreenChangeListener();
+ } else {
+ mScreenChangeListener = null;
+ }
+
+ if (sRuntime != null) {
+ throw new IllegalStateException("Only one GeckoRuntime instance is allowed");
+ }
+ sRuntime = this;
+ }
+
+ @WrapForJNI
+ @UiThread
+ /* package */ @Nullable
+ static GeckoRuntime getInstance() {
+ return sRuntime;
+ }
+
+ /**
+ * Called by mozilla::dom::ClientOpenWindow to retrieve the window id to use for a
+ * ServiceWorkerClients.openWindow() request.
+ *
+ * @param url validated Url being requested to be opened in a new window.
+ * @return SessionID to use for the request.
+ */
+ @SuppressLint("WrongThread") // for .isOpen() which is called on the UI thread
+ @WrapForJNI(calledFrom = "gecko")
+ private static @NonNull GeckoResult<String> serviceWorkerOpenWindow(final @NonNull String url) {
+ if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) {
+ final GeckoResult<String> result = new GeckoResult<>();
+ // perform the onOpenWindow call in the UI thread
+ ThreadUtils.runOnUiThread(
+ () -> {
+ sRuntime
+ .mServiceWorkerDelegate
+ .onOpenWindow(url)
+ .accept(
+ session -> {
+ if (session != null) {
+ if (!session.isOpen()) {
+ session.open(sRuntime);
+ }
+ result.complete(session.getId());
+ } else {
+ result.complete(null);
+ }
+ });
+ });
+ return result;
+ } else {
+ return GeckoResult.fromException(
+ new java.lang.RuntimeException("No available Service Worker delegate."));
+ }
+ }
+
+ /**
+ * Attach the runtime to the given context.
+ *
+ * @param context The new context to attach to.
+ */
+ @UiThread
+ public void attachTo(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "attachTo " + context.getApplicationContext());
+ }
+ final Context appContext = context.getApplicationContext();
+ if (!appContext.equals(GeckoAppShell.getApplicationContext())) {
+ GeckoAppShell.setApplicationContext(appContext);
+ }
+ }
+
+ private final BundleEventListener mEventListener =
+ new BundleEventListener() {
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ final Class<?> crashHandler = GeckoRuntime.this.getSettings().mCrashHandler;
+
+ if ("Gecko:Exited".equals(event) && mDelegate != null) {
+ mDelegate.onShutdown();
+ EventDispatcher.getInstance()
+ .unregisterUiThreadListener(mEventListener, "Gecko:Exited");
+ } else if ("GeckoView:Test:NewTab".equals(event)) {
+ final String url = message.getString("url", "about:blank");
+ serviceWorkerOpenWindow(url)
+ .then(
+ (GeckoResult.OnValueListener<String, Void>)
+ value -> {
+ callback.sendSuccess(value);
+ return null;
+ })
+ .exceptionally(
+ (GeckoResult.OnExceptionListener<Void>)
+ error -> {
+ callback.sendError(error + " Could not open tab.");
+ return null;
+ });
+ } else if ("GeckoView:ChildCrashReport".equals(event) && crashHandler != null) {
+ final Context context = GeckoAppShell.getApplicationContext();
+ final Intent i = new Intent(ACTION_CRASHED, null, context, crashHandler);
+ i.putExtra(EXTRA_MINIDUMP_PATH, message.getString(EXTRA_MINIDUMP_PATH));
+ i.putExtra(EXTRA_EXTRAS_PATH, message.getString(EXTRA_EXTRAS_PATH));
+ i.putExtra(EXTRA_CRASH_PROCESS_TYPE, message.getString(EXTRA_CRASH_PROCESS_TYPE));
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(i);
+ } else {
+ context.startService(i);
+ }
+ }
+ }
+ };
+
+ private static String getProcessName(final Context context) {
+ final ActivityManager manager =
+ (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
+ final List<ActivityManager.RunningAppProcessInfo> infos = manager.getRunningAppProcesses();
+ if (infos == null) {
+ return null;
+ }
+ for (final ActivityManager.RunningAppProcessInfo info : infos) {
+ if (info.pid == Process.myPid()) {
+ return info.processName;
+ }
+ }
+
+ return null;
+ }
+
+ /* package */ boolean init(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "init");
+ }
+ int flags = GeckoThread.FLAG_PRELOAD_CHILD;
+
+ if (settings.getPauseForDebuggerEnabled()) {
+ flags |= GeckoThread.FLAG_DEBUGGING;
+ }
+
+ final Class<?> crashHandler = settings.getCrashHandler();
+ if (crashHandler != null) {
+ try {
+ final ServiceInfo info =
+ context.getPackageManager().getServiceInfo(new ComponentName(context, crashHandler), 0);
+ if (info.processName.equals(getProcessName(context))) {
+ throw new IllegalArgumentException(
+ "Crash handler service must run in a separate process");
+ }
+
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "GeckoView:ChildCrashReport");
+
+ flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER;
+ } catch (final PackageManager.NameNotFoundException e) {
+ throw new IllegalArgumentException("Crash handler must be registered as a service");
+ }
+ }
+
+ GeckoAppShell.useMaxScreenDepth(settings.getUseMaxScreenDepth());
+ GeckoAppShell.setDisplayDensityOverride(settings.getDisplayDensityOverride());
+ GeckoAppShell.setDisplayDpiOverride(settings.getDisplayDpiOverride());
+ GeckoAppShell.setScreenSizeOverride(settings.getScreenSizeOverride());
+ GeckoAppShell.setCrashHandlerService(settings.getCrashHandler());
+ GeckoFontScaleListener.getInstance().attachToContext(context, settings);
+
+ Bundle extras = settings.getExtras();
+ String[] args = settings.getArguments();
+ Map<String, Object> prefs = settings.getPrefsMap();
+
+ // Older versions have problems with SnakeYaml
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ String configFilePath = settings.getConfigFilePath();
+ if (configFilePath == null) {
+ // Default to /data/local/tmp/$PACKAGE-geckoview-config.yaml if android:debuggable="true"
+ // or if this application is the current Android "debug_app", and to not read configuration
+ // from a file otherwise.
+ if (isApplicationDebuggable(context) || isApplicationCurrentDebugApp(context)) {
+ configFilePath =
+ String.format(CONFIG_FILE_PATH_TEMPLATE, context.getApplicationInfo().packageName);
+ }
+ }
+
+ if (configFilePath != null && !configFilePath.isEmpty()) {
+ try {
+ final DebugConfig debugConfig = DebugConfig.fromFile(new File(configFilePath));
+ Log.i(LOGTAG, "Adding debug configuration from: " + configFilePath);
+ prefs = debugConfig.mergeIntoPrefs(prefs);
+ args = debugConfig.mergeIntoArgs(args);
+ extras = debugConfig.mergeIntoExtras(extras);
+ } catch (final DebugConfig.ConfigException e) {
+ Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e);
+ } catch (final FileNotFoundException e) {
+ }
+ }
+ }
+
+ final GeckoThread.InitInfo info =
+ GeckoThread.InitInfo.builder()
+ .args(args)
+ .extras(extras)
+ .flags(flags)
+ .prefs(prefs)
+ .outFilePath(extras != null ? extras.getString("out_file") : null)
+ .build();
+
+ if (info.xpcshell
+ && !"org.mozilla.geckoview.test_runner"
+ .equals(context.getApplicationContext().getPackageName())) {
+ throw new IllegalArgumentException("Only the test app can run -xpcshell.");
+ }
+
+ if (info.xpcshell) {
+ // Xpcshell tests need multi-e10s to work properly
+ settings.setProcessCount(BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_COUNT);
+ }
+
+ if (!GeckoThread.init(info)) {
+ Log.w(LOGTAG, "init failed (could not initiate GeckoThread)");
+ return false;
+ }
+
+ if (!GeckoThread.launch()) {
+ Log.w(LOGTAG, "init failed (GeckoThread already launched)");
+ return false;
+ }
+
+ mSettings = settings;
+
+ // Bug 1453062 -- the EventDispatcher should really live here (or in GeckoThread)
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(mEventListener, "Gecko:Exited", "GeckoView:Test:NewTab");
+
+ // Attach and commit settings.
+ mSettings.attachTo(this);
+
+ // Initialize the system ClipboardManager by accessing it on the main thread.
+ GeckoAppShell.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE);
+
+ // Add process lifecycle listener to react to backgrounding events.
+ ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleListener());
+
+ // Add Display Manager listener to listen screen orientation change.
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.enable();
+ }
+
+ mProfilerController.addMarker(
+ "GeckoView Initialization START", mProfilerController.getProfilerTime());
+ return true;
+ }
+
+ private boolean isApplicationDebuggable(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+ return (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
+ }
+
+ private boolean isApplicationCurrentDebugApp(final @NonNull Context context) {
+ final ApplicationInfo applicationInfo = context.getApplicationInfo();
+
+ final String currentDebugApp;
+ if (Build.VERSION.SDK_INT >= 17) {
+ currentDebugApp =
+ Settings.Global.getString(context.getContentResolver(), Settings.Global.DEBUG_APP);
+ } else {
+ currentDebugApp =
+ Settings.System.getString(context.getContentResolver(), Settings.System.DEBUG_APP);
+ }
+ return applicationInfo.packageName.equals(currentDebugApp);
+ }
+
+ /* package */ void setDefaultPrefs(final GeckoBundle prefs) {
+ EventDispatcher.getInstance().dispatch("GeckoView:SetDefaultPrefs", prefs);
+ }
+
+ /**
+ * Create a new runtime with default settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(final @NonNull Context context) {
+ ThreadUtils.assertOnUiThread();
+ return create(context, new GeckoRuntimeSettings());
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoRuntime.
+ *
+ * @return an instance of {@link WebExtensionController}.
+ */
+ @UiThread
+ public @NonNull WebExtensionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Returns the ContentBlockingController for this GeckoRuntime.
+ *
+ * @return An instance of {@link ContentBlockingController}.
+ */
+ @UiThread
+ public @NonNull ContentBlockingController getContentBlockingController() {
+ return mContentBlockingController;
+ }
+
+ /**
+ * Returns a ProfilerController for this GeckoRuntime.
+ *
+ * @return an instance of {@link ProfilerController}.
+ */
+ @UiThread
+ public @NonNull ProfilerController getProfilerController() {
+ return mProfilerController;
+ }
+
+ /**
+ * Create a new runtime with the given settings and attach it to the given context.
+ *
+ * <p>Create will throw if there is already an active Gecko instance running, to prevent that,
+ * bind the runtime to the process lifetime instead of the activity lifetime.
+ *
+ * @param context The context of the runtime.
+ * @param settings The settings for the runtime.
+ * @return An initialized runtime.
+ */
+ @UiThread
+ public static @NonNull GeckoRuntime create(
+ final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) {
+ ThreadUtils.assertOnUiThread();
+ if (DEBUG) {
+ Log.d(LOGTAG, "create " + context);
+ }
+
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.attachTo(context);
+
+ if (!runtime.init(context, settings)) {
+ throw new IllegalStateException("Failed to initialize GeckoRuntime");
+ }
+
+ context.registerComponentCallbacks(runtime.mMemoryController);
+
+ return runtime;
+ }
+
+ /** Shutdown the runtime. This will invalidate all attached sessions. */
+ @AnyThread
+ public void shutdown() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "shutdown");
+ }
+
+ GeckoSystemStateListener.getInstance().shutdown();
+
+ if (mScreenChangeListener != null) {
+ mScreenChangeListener.disable();
+ }
+
+ GeckoThread.forceQuit();
+ }
+
+ public interface Delegate {
+ /**
+ * This is called when the runtime shuts down. Any GeckoSession instances that were opened with
+ * this instance are now considered closed.
+ */
+ @UiThread
+ void onShutdown();
+ }
+
+ /**
+ * Set a delegate for receiving callbacks relevant to to this GeckoRuntime.
+ *
+ * @param delegate an implementation of {@link GeckoRuntime.Delegate}.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable Delegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Returns the current delegate, if any.
+ *
+ * @return an instance of {@link GeckoRuntime.Delegate} or null if no delegate has been set.
+ */
+ @UiThread
+ public @Nullable Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ /**
+ * Set the {@link Autocomplete.StorageDelegate} instance on this runtime. This delegate is
+ * required for handling autocomplete storage requests.
+ *
+ * @param delegate The {@link Autocomplete.StorageDelegate} handling autocomplete storage
+ * requests.
+ */
+ @UiThread
+ public void setAutocompleteStorageDelegate(
+ final @Nullable Autocomplete.StorageDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mAutocompleteStorageProxy.setDelegate(delegate);
+ }
+
+ /**
+ * Get the {@link Autocomplete.StorageDelegate} instance set on this runtime.
+ *
+ * @return The {@link Autocomplete.StorageDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable Autocomplete.StorageDelegate getAutocompleteStorageDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mAutocompleteStorageProxy.getDelegate();
+ }
+
+ @UiThread
+ public interface ServiceWorkerDelegate {
+
+ /**
+ * This is called when a service worker tries to open a new window using client.openWindow() The
+ * GeckoView application should provide an open {@link GeckoSession} to open the url.
+ *
+ * @param url Url which the Service Worker wishes to open in a new window.
+ * @return New or existing open {@link GeckoSession} in which to open the requested url.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow">openWindow()</a>
+ */
+ @UiThread
+ @NonNull
+ GeckoResult<GeckoSession> onOpenWindow(@NonNull String url);
+ }
+
+ /**
+ * Sets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @param serviceWorkerDelegate An instance of {@link ServiceWorkerDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service
+ * Worker API</a>
+ */
+ @UiThread
+ public void setServiceWorkerDelegate(
+ final @Nullable ServiceWorkerDelegate serviceWorkerDelegate) {
+ mServiceWorkerDelegate = serviceWorkerDelegate;
+ }
+
+ /**
+ * Gets the {@link ServiceWorkerDelegate} to be used for Service Worker requests.
+ *
+ * @return the {@link ServiceWorkerDelegate} instance set by {@link #setServiceWorkerDelegate}
+ */
+ @UiThread
+ @Nullable
+ public ServiceWorkerDelegate getServiceWorkerDelegate() {
+ return mServiceWorkerDelegate;
+ }
+
+ /**
+ * Sets the delegate to be used for handling Web Notifications.
+ *
+ * @param delegate An instance of {@link WebNotificationDelegate}.
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web
+ * Notifications</a>
+ */
+ @UiThread
+ public void setWebNotificationDelegate(final @Nullable WebNotificationDelegate delegate) {
+ mNotificationDelegate = delegate;
+ }
+
+ @WrapForJNI
+ /* package */ 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 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">Web
+ * Authentication API</a>
+ */
+ public interface ActivityDelegate {
+ /**
+ * Sometimes GeckoView needs the application to perform a {@link
+ * android.app.Activity#startActivityForResult(Intent, int)} on its behalf. Implementations of
+ * this method should call that based on the information in the passed {@link PendingIntent},
+ * collect the result, and resolve the returned {@link GeckoResult} with that data. If the
+ * Activity does not return {@link android.app.Activity#RESULT_OK}, the {@link GeckoResult} must
+ * be completed with an exception of your choosing.
+ *
+ * @param intent The {@link PendingIntent} to launch
+ * @return A {@link GeckoResult} that is eventually resolved with the Activity result.
+ */
+ @UiThread
+ @Nullable
+ GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent);
+ }
+
+ /**
+ * Set the {@link ActivityDelegate} instance on this runtime. This delegate is used to provide
+ * GeckoView support for launching external activities and receiving results from those
+ * activities.
+ *
+ * @param delegate The {@link ActivityDelegate} handling intent launching requests.
+ */
+ @UiThread
+ public void setActivityDelegate(final @Nullable ActivityDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mActivityDelegate = delegate;
+ }
+
+ /**
+ * Get the {@link ActivityDelegate} instance set on this runtime, if any,
+ *
+ * @return The {@link ActivityDelegate} set on this runtime.
+ */
+ @UiThread
+ public @Nullable ActivityDelegate getActivityDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mActivityDelegate;
+ }
+
+ @AnyThread
+ /* package */ GeckoResult<Intent> startActivityForResult(final @NonNull PendingIntent intent) {
+ if (!ThreadUtils.isOnUiThread()) {
+ // Delegates expect to be called on the UI thread.
+ final GeckoResult<Intent> result = new GeckoResult<>();
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<Intent> delegateResult = startActivityForResult(intent);
+ if (delegateResult != null) {
+ delegateResult.accept(
+ val -> result.complete(val), e -> result.completeExceptionally(e));
+ } else {
+ result.completeExceptionally(new IllegalStateException("No result"));
+ }
+ });
+
+ return result;
+ }
+
+ if (mActivityDelegate == null) {
+ return GeckoResult.fromException(new IllegalStateException("No delegate attached"));
+ }
+
+ @SuppressLint("WrongThread")
+ GeckoResult<Intent> result = mActivityDelegate.onStartActivityForResult(intent);
+ if (result == null) {
+ result = GeckoResult.fromException(new IllegalStateException("No result"));
+ }
+
+ return result;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoRuntimeSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Notify Gecko that the screen orientation has changed. */
+ @UiThread
+ public void orientationChanged() {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update();
+ }
+
+ /**
+ * Notify Gecko that the device configuration has changed.
+ *
+ * @param newConfig The new Configuration object, {@link android.content.res.Configuration}.
+ */
+ @UiThread
+ public void configurationChanged(final @NonNull Configuration newConfig) {
+ ThreadUtils.assertOnUiThread();
+ GeckoSystemStateListener.getInstance().updateNightMode(newConfig.uiMode);
+ }
+
+ /**
+ * Notify Gecko that the screen orientation has changed.
+ *
+ * @param newOrientation The new screen orientation, as retrieved e.g. from the current {@link
+ * android.content.res.Configuration}.
+ */
+ @UiThread
+ public void orientationChanged(final int newOrientation) {
+ ThreadUtils.assertOnUiThread();
+ GeckoScreenOrientation.getInstance().update(newOrientation);
+ }
+
+ /**
+ * Get the orientation controller for this runtime. The orientation controller can be used to
+ * manage changes to and locking of the screen orientation.
+ *
+ * @return The {@link OrientationController} for this instance.
+ */
+ @UiThread
+ public @NonNull OrientationController getOrientationController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOrientationController == null) {
+ mOrientationController = new OrientationController();
+ }
+ return mOrientationController;
+ }
+
+ /**
+ * Converts GeckoScreenOrientation to ActivityInfo orientation
+ *
+ * @return A {@link ActivityInfo} orientation.
+ */
+ @AnyThread
+ private int toAndroidOrientation(final int geckoOrientation) {
+ if (geckoOrientation == ScreenOrientation.PORTRAIT_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_PRIMARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_SECONDARY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.DEFAULT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
+ } else if (geckoOrientation == ScreenOrientation.PORTRAIT.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+ } else if (geckoOrientation == ScreenOrientation.LANDSCAPE.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ } else if (geckoOrientation == ScreenOrientation.ANY.value) {
+ return ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+ }
+ return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ }
+
+ /**
+ * Lock screen orientation using OrientationController's onOrientationLock.
+ *
+ * @return A {@link GeckoResult} that resolves an orientation lock.
+ */
+ @WrapForJNI(calledFrom = "gecko")
+ private @NonNull GeckoResult<Boolean> lockScreenOrientation(final int aOrientation) {
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate == null) {
+ // Delegate is not set
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ final GeckoResult<AllowOrDeny> response =
+ delegate.onOrientationLock(toAndroidOrientation(aOrientation));
+ if (response == null) {
+ // Delegate is default. So lock orientation is not implemented
+ res.completeExceptionally(new Exception("Not supported"));
+ return;
+ }
+ res.completeFrom(response.map(v -> v == AllowOrDeny.ALLOW));
+ });
+ return res;
+ }
+
+ /** Unlock screen orientation using OrientationController's onOrientationUnlock. */
+ @WrapForJNI(calledFrom = "gecko")
+ private void unlockScreenOrientation() {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final OrientationController.OrientationDelegate delegate =
+ getOrientationController().getDelegate();
+ if (delegate != null) {
+ delegate.onOrientationUnlock();
+ }
+ });
+ }
+
+ /**
+ * Get the storage controller for this runtime. The storage controller can be used to manage
+ * persistent storage data accumulated by {@link GeckoSession}.
+ *
+ * @return The {@link StorageController} for this instance.
+ */
+ @UiThread
+ public @NonNull StorageController getStorageController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mStorageController == null) {
+ mStorageController = new StorageController();
+ }
+ return mStorageController;
+ }
+
+ /**
+ * Get the Web Push controller for this runtime. The Web Push controller can be used to allow
+ * content to use the Web Push API.
+ *
+ * @return The {@link WebPushController} for this instance.
+ */
+ @UiThread
+ public @NonNull WebPushController getWebPushController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mPushController == null) {
+ mPushController = new WebPushController();
+ }
+
+ return mPushController;
+ }
+
+ /**
+ * Appends notes to crash report.
+ *
+ * @param notes The application notes to append to the crash report.
+ */
+ @AnyThread
+ public void appendAppNotesToCrashReport(@NonNull final String notes) {
+ final String notesWithNewLine = notes + "\n";
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ GeckoAppShell.nativeAppendAppNotesToCrashReport(notesWithNewLine);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ GeckoAppShell.class,
+ "nativeAppendAppNotesToCrashReport",
+ String.class,
+ notesWithNewLine);
+ }
+ // This function already adds a newline
+ GeckoAppShell.appendAppNotesToCrashReport(notes);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeParcelable(mSettings, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mSettings = source.readParcelable(getClass().getClassLoader());
+ }
+
+ public static final Parcelable.Creator<GeckoRuntime> CREATOR =
+ new Parcelable.Creator<GeckoRuntime>() {
+ @Override
+ @AnyThread
+ public GeckoRuntime createFromParcel(final Parcel in) {
+ final GeckoRuntime runtime = new GeckoRuntime();
+ runtime.readFromParcel(in);
+ return runtime;
+ }
+
+ @Override
+ @AnyThread
+ public GeckoRuntime[] newArray(final int size) {
+ return new GeckoRuntime[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java
new file mode 100644
index 0000000000..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<GeckoRuntimeSettings> {
+ @Override
+ protected @NonNull GeckoRuntimeSettings newSettings(
+ final @Nullable GeckoRuntimeSettings settings) {
+ return new GeckoRuntimeSettings(settings);
+ }
+
+ /**
+ * Set the custom Gecko process arguments.
+ *
+ * @param args The Gecko process arguments.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder arguments(final @NonNull String[] args) {
+ if (args == null) {
+ throw new IllegalArgumentException("Arguments must not be null");
+ }
+ getSettings().mArgs = args;
+ return this;
+ }
+
+ /**
+ * Set the custom Gecko intent extras.
+ *
+ * @param extras The Gecko intent extras.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder extras(final @NonNull Bundle extras) {
+ if (extras == null) {
+ throw new IllegalArgumentException("Extras must not be null");
+ }
+ getSettings().mExtras = extras;
+ return this;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>, on
+ * older devices this will be silently ignored.
+ *
+ * @param configFilePath Configuration file path to read from, or <code>null</code> to use
+ * default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml</code>.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder configFilePath(final @Nullable String configFilePath) {
+ getSettings().mConfigFilePath = configFilePath;
+ return this;
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder javaScriptEnabled(final boolean flag) {
+ getSettings().mJavaScript.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder remoteDebuggingEnabled(final boolean enabled) {
+ getSettings().mRemoteDebugging.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled. Default is true.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder webFontsEnabled(final boolean flag) {
+ getSettings().mWebFonts.set(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Set whether there should be a pause during startup. This is useful if you need to wait for a
+ * debugger to attach.
+ *
+ * @param enabled A flag determining whether there will be a pause early in startup. Defaults to
+ * false.
+ * @return This Builder.
+ */
+ public @NonNull Builder pauseForDebugger(final boolean enabled) {
+ getSettings().mDebugPause = enabled;
+ return this;
+ }
+
+ /**
+ * Set whether the to report the full bit depth of the device.
+ *
+ * <p>By default, 24 bits are reported for high memory devices and 16 bits for low memory
+ * devices. If set to true, the device's maximum bit depth is reported. On most modern devices
+ * this will be 32 bit screen depth.
+ *
+ * @param enable A flag determining whether maximum screen depth should be used.
+ * @return This Builder.
+ */
+ public @NonNull Builder useMaxScreenDepth(final boolean enable) {
+ getSettings().mUseMaxScreenDepth = enable;
+ return this;
+ }
+
+ /**
+ * Set whether web manifest support is enabled.
+ *
+ * <p>This controls if Gecko actually downloads, or "obtains", web manifests and processes them.
+ * Without setting this pref, trying to obtain a manifest throws.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder webManifest(final boolean enabled) {
+ getSettings().mWebManifest.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use
+ * of the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return The builder instance.
+ */
+ public @NonNull Builder consoleOutput(final boolean enabled) {
+ getSettings().mConsoleOutput.set(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return The builder instance.
+ */
+ public @NonNull Builder automaticFontSizeAdjustment(final boolean enabled) {
+ getSettings().setAutomaticFontSizeAdjustment(enabled);
+ return this;
+ }
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0
+ * disables both this feature and {@link Builder#fontInflation font inflation}.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontSizeFactor(final float fontSizeFactor) {
+ getSettings().setFontSizeFactor(fontSizeFactor);
+ return this;
+ }
+
+ /**
+ * Enable the Enterprise Roots feature.
+ *
+ * <p>When Enabled, GeckoView will fetch the third-party root certificates added to the Android
+ * OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return The builder instance
+ */
+ public @NonNull Builder enterpriseRootsEnabled(final boolean enabled) {
+ getSettings().setEnterpriseRootsEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set whether or not font inflation for non mobile-friendly pages should be enabled. The
+ * default value of this setting is <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link Builder#fontSizeFactor font
+ * size factor} currently in use.
+ *
+ * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic
+ * font size adjustment} has already been enabled.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder fontInflation(final boolean enabled) {
+ getSettings().setFontInflationEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Set the display density override.
+ *
+ * @param density The display density value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDensityOverride(final float density) {
+ getSettings().mDisplayDensityOverride = density;
+ return this;
+ }
+
+ /**
+ * Set the display DPI override.
+ *
+ * @param dpi The display DPI value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder displayDpiOverride(final int dpi) {
+ getSettings().mDisplayDpiOverride = dpi;
+ return this;
+ }
+
+ /**
+ * Set the screen size override.
+ *
+ * @param width The screen width value to use for overriding the system default.
+ * @param height The screen height value to use for overriding the system default.
+ * @return The builder instance.
+ */
+ public @NonNull Builder screenSizeOverride(final int width, final int height) {
+ getSettings().mScreenWidthOverride = width;
+ getSettings().mScreenHeightOverride = height;
+ return this;
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is
+ * provided via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull Builder loginAutofillEnabled(final boolean enabled) {
+ getSettings().setLoginAutofillEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * When set, the specified {@link android.app.Service} will be started by an {@link
+ * android.content.Intent} with action {@link GeckoRuntime#ACTION_CRASHED} when a crash is
+ * encountered. Crash details can be found in the Intent extras, such as {@link
+ * GeckoRuntime#EXTRA_MINIDUMP_PATH}. <br>
+ * <br>
+ * The crash handler Service must be declared to run in a different process from the {@link
+ * GeckoRuntime}. Additionally, the handler will be run as a foreground service, so the normal
+ * rules about activating a foreground service apply. <br>
+ * <br>
+ * In practice, you have one of three options once the crash handler is started:
+ *
+ * <ul>
+ * <li>Call {@link android.app.Service#startForeground(int, android.app.Notification)}. You
+ * can then take as much time as necessary to report the crash.
+ * <li>Start an activity. Unless you also call {@link android.app.Service#startForeground(int,
+ * android.app.Notification)} this should be in a different process from the crash
+ * handler, since Android will kill the crash handler process as part of the background
+ * execution limitations.
+ * <li>Schedule work via {@link android.app.job.JobScheduler}. This will allow you to do
+ * substantial work in the background without execution limits.
+ * </ul>
+ *
+ * <br>
+ * You can use {@link CrashReporter} to send the report to Mozilla, which provides Mozilla with
+ * data needed to fix the crash. Be aware that the minidump may contain personally identifiable
+ * information (PII). Consult Mozilla's <a href="https://www.mozilla.org/en-US/privacy/">privacy
+ * policy</a> for information on how this data will be handled.
+ *
+ * @param handler The class for the crash handler Service.
+ * @return This builder instance.
+ * @see <a href="https://developer.android.com/about/versions/oreo/background">Android
+ * Background Execution Limits</a>
+ * @see GeckoRuntime#ACTION_CRASHED
+ */
+ public @NonNull Builder crashHandler(final @Nullable Class<? extends Service> handler) {
+ getSettings().mCrashHandler = handler;
+ return this;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales List of locale codes in Gecko format ("en" or "en-US").
+ * @return The builder instance.
+ */
+ public @NonNull Builder locales(final @Nullable String[] requestedLocales) {
+ getSettings().mRequestedLocales = requestedLocales;
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull Builder contentBlocking(final @NonNull ContentBlocking.Settings cb) {
+ getSettings().mContentBlocking = cb;
+ return this;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder preferredColorScheme(final @ColorScheme int scheme) {
+ getSettings().setPreferredColorScheme(scheme);
+ return this;
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder inputAutoZoomEnabled(final boolean flag) {
+ getSettings().mInputAutoZoom.set(flag);
+ return this;
+ }
+
+ /**
+ * Set whether double tap zooming should be enabled.
+ *
+ * @param flag True if double tap zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder doubleTapZoomingEnabled(final boolean flag) {
+ getSettings().mDoubleTapZooming.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder glMsaaLevel(final int level) {
+ getSettings().mGlMsaaLevel.set(level);
+ return this;
+ }
+
+ /**
+ * Add a {@link RuntimeTelemetry.Delegate} instance to this GeckoRuntime. This delegate can be
+ * used by the app to receive streaming telemetry data from GeckoView.
+ *
+ * @param delegate the delegate that will handle telemetry
+ * @return The builder instance.
+ */
+ public @NonNull Builder telemetryDelegate(final @NonNull RuntimeTelemetry.Delegate delegate) {
+ getSettings().mTelemetryProxy = new RuntimeTelemetry.Proxy(delegate);
+ getSettings().mTelemetryEnabled.set(true);
+ return this;
+ }
+
+ /**
+ * Enables GeckoView and Gecko Logging. Logging is on by default. Does not control all logging
+ * in Gecko. Logging done in Java code must be stripped out at build time.
+ *
+ * @param enable True if logging is enabled.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder debugLogging(final boolean enable) {
+ getSettings().mDevToolsConsoleToLogcat.set(enable);
+ getSettings().mConsoleServiceToLogcat.set(enable);
+ getSettings().mGeckoViewLogLevel.set(enable ? "Debug" : "Fatal");
+ return this;
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to
+ * break in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder aboutConfigEnabled(final boolean flag) {
+ getSettings().mAboutConfig.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder forceUserScalableEnabled(final boolean flag) {
+ getSettings().mForceUserScalable.set(flag);
+ return this;
+ }
+
+ /**
+ * Sets whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder allowInsecureConnections(final @HttpsOnlyMode int level) {
+ getSettings().setAllowInsecureConnections(level);
+ return this;
+ }
+ }
+
+ private GeckoRuntime mRuntime;
+ /* package */ String[] mArgs;
+ /* package */ Bundle mExtras;
+ /* package */ String mConfigFilePath;
+
+ /* package */ ContentBlocking.Settings mContentBlocking;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull ContentBlocking.Settings getContentBlocking() {
+ return mContentBlocking;
+ }
+
+ /* package */ final Pref<Boolean> mWebManifest = new Pref<Boolean>("dom.manifest.enabled", true);
+ /* package */ final Pref<Boolean> mJavaScript = new Pref<Boolean>("javascript.enabled", true);
+ /* package */ final Pref<Boolean> mRemoteDebugging =
+ new Pref<Boolean>("devtools.debugger.remote-enabled", false);
+ /* package */ final Pref<Integer> mWebFonts =
+ new Pref<Integer>("browser.display.use_document_fonts", 1);
+ /* package */ final Pref<Boolean> mConsoleOutput =
+ new Pref<Boolean>("geckoview.console.enabled", false);
+ /* package */ float mFontSizeFactor = 1f;
+ /* package */ final Pref<Boolean> mEnterpriseRootsEnabled =
+ new Pref<>("security.enterprise_roots.enabled", false);
+ /* package */ final Pref<Integer> mFontInflationMinTwips =
+ new Pref<>("font.size.inflation.minTwips", 0);
+ /* package */ final Pref<Boolean> mInputAutoZoom = new Pref<>("formhelper.autozoom", true);
+ /* package */ final Pref<Boolean> mDoubleTapZooming =
+ new Pref<>("apz.allow_double_tap_zooming", true);
+ /* package */ final Pref<Integer> mGlMsaaLevel = new Pref<>("webgl.msaa-samples", 4);
+ /* package */ final Pref<Boolean> mTelemetryEnabled =
+ new Pref<>("toolkit.telemetry.geckoview.streaming", false);
+ /* package */ final Pref<String> mGeckoViewLogLevel = new Pref<>("geckoview.logging", "Debug");
+ /* package */ final Pref<Boolean> mConsoleServiceToLogcat =
+ new Pref<>("consoleservice.logcat", true);
+ /* package */ final Pref<Boolean> mDevToolsConsoleToLogcat =
+ new Pref<>("devtools.console.stdout.chrome", true);
+ /* package */ final Pref<Boolean> mAboutConfig = new Pref<>("general.aboutConfig.enable", false);
+ /* package */ final Pref<Boolean> mForceUserScalable =
+ new Pref<>("browser.ui.zoom.force-user-scalable", false);
+ /* package */ final Pref<Boolean> mAutofillLogins =
+ new Pref<Boolean>("signon.autofillForms", true);
+ /* package */ final Pref<Boolean> mHttpsOnly =
+ new Pref<Boolean>("dom.security.https_only_mode", false);
+ /* package */ final Pref<Boolean> mHttpsOnlyPrivateMode =
+ new Pref<Boolean>("dom.security.https_only_mode_pbm", false);
+ /* package */ final Pref<Integer> mProcessCount = new Pref<>("dom.ipc.processCount", 2);
+
+ /* package */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM;
+
+ /* package */ boolean mForceEnableAccessibility;
+ /* package */ boolean mDebugPause;
+ /* package */ boolean mUseMaxScreenDepth;
+ /* package */ float mDisplayDensityOverride = -1.0f;
+ /* package */ int mDisplayDpiOverride;
+ /* package */ int mScreenWidthOverride;
+ /* package */ int mScreenHeightOverride;
+ /* package */ Class<? extends Service> mCrashHandler;
+ /* package */ String[] mRequestedLocales;
+ /* package */ RuntimeTelemetry.Proxy mTelemetryProxy;
+
+ /**
+ * Attach and commit the settings to the given runtime.
+ *
+ * @param runtime The runtime to attach to.
+ */
+ /* package */ void attachTo(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ commit();
+
+ if (mTelemetryProxy != null) {
+ mTelemetryProxy.attach();
+ }
+ }
+
+ @Override // RuntimeSettings
+ public @Nullable GeckoRuntime getRuntime() {
+ return mRuntime;
+ }
+
+ /* package */ GeckoRuntimeSettings() {
+ this(null);
+ }
+
+ /* package */ GeckoRuntimeSettings(final @Nullable GeckoRuntimeSettings settings) {
+ super(/* parent */ null);
+
+ if (settings == null) {
+ mArgs = new String[0];
+ mExtras = new Bundle();
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, null /* settings */);
+ return;
+ }
+
+ updateSettings(settings);
+ }
+
+ private void updateSettings(final @NonNull GeckoRuntimeSettings settings) {
+ updatePrefs(settings);
+
+ mArgs = settings.getArguments().clone();
+ mExtras = new Bundle(settings.getExtras());
+ mContentBlocking = new ContentBlocking.Settings(this /* parent */, settings.mContentBlocking);
+
+ mForceEnableAccessibility = settings.mForceEnableAccessibility;
+ mDebugPause = settings.mDebugPause;
+ mUseMaxScreenDepth = settings.mUseMaxScreenDepth;
+ mDisplayDensityOverride = settings.mDisplayDensityOverride;
+ mDisplayDpiOverride = settings.mDisplayDpiOverride;
+ mScreenWidthOverride = settings.mScreenWidthOverride;
+ mScreenHeightOverride = settings.mScreenHeightOverride;
+ mCrashHandler = settings.mCrashHandler;
+ mRequestedLocales = settings.mRequestedLocales;
+ mConfigFilePath = settings.mConfigFilePath;
+ mTelemetryProxy = settings.mTelemetryProxy;
+ }
+
+ /* package */ void commit() {
+ commitLocales();
+ commitResetPrefs();
+ }
+
+ /**
+ * Get the custom Gecko process arguments.
+ *
+ * @return The Gecko process arguments.
+ */
+ public @NonNull String[] getArguments() {
+ return mArgs;
+ }
+
+ /**
+ * Get the custom Gecko intent extras.
+ *
+ * @return The Gecko intent extras.
+ */
+ public @NonNull Bundle getExtras() {
+ return mExtras;
+ }
+
+ /**
+ * Path to configuration file from which GeckoView will read configuration options such as Gecko
+ * process arguments, environment variables, and preferences.
+ *
+ * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} &gt; 21</code>.
+ *
+ * @return Path to configuration file from which GeckoView will read configuration options, or
+ * <code>null</code> for default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml
+ * </code>.
+ */
+ public @Nullable String getConfigFilePath() {
+ return mConfigFilePath;
+ }
+
+ /**
+ * Get whether JavaScript support is enabled.
+ *
+ * @return Whether JavaScript support is enabled.
+ */
+ public boolean getJavaScriptEnabled() {
+ return mJavaScript.get();
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param flag A flag determining whether JavaScript should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setJavaScriptEnabled(final boolean flag) {
+ mJavaScript.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get whether remote debugging support is enabled.
+ *
+ * @return True if remote debugging support is enabled.
+ */
+ public boolean getRemoteDebuggingEnabled() {
+ return mRemoteDebugging.get();
+ }
+
+ /**
+ * Set whether remote debugging support should be enabled.
+ *
+ * @param enabled True if remote debugging should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setRemoteDebuggingEnabled(final boolean enabled) {
+ mRemoteDebugging.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether web fonts support is enabled.
+ *
+ * @return Whether web fonts support is enabled.
+ */
+ public boolean getWebFontsEnabled() {
+ return mWebFonts.get() != 0 ? true : false;
+ }
+
+ /**
+ * Set whether support for web fonts should be enabled.
+ *
+ * @param flag A flag determining whether web fonts should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebFontsEnabled(final boolean flag) {
+ mWebFonts.commit(flag ? 1 : 0);
+ return this;
+ }
+
+ /**
+ * Gets whether the pause-for-debugger is enabled or not.
+ *
+ * @return True if the pause is enabled.
+ */
+ public boolean getPauseForDebuggerEnabled() {
+ return mDebugPause;
+ }
+
+ /**
+ * Gets whether accessibility is force enabled or not.
+ *
+ * @return true if accessibility is force enabled.
+ */
+ public boolean getForceEnableAccessibility() {
+ return mForceEnableAccessibility;
+ }
+
+ /**
+ * Sets whether accessibility is force enabled or not.
+ *
+ * <p>Useful when testing accessibility.
+ *
+ * @param value whether accessibility is force enabled or not
+ * @return this GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceEnableAccessibility(final boolean value) {
+ mForceEnableAccessibility = value;
+ SessionAccessibility.setForceEnabled(value);
+ return this;
+ }
+
+ /**
+ * Gets whether the compositor should use the maximum screen depth when rendering.
+ *
+ * @return True if the maximum screen depth should be used.
+ */
+ public boolean getUseMaxScreenDepth() {
+ return mUseMaxScreenDepth;
+ }
+
+ /**
+ * Gets the display density override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Float getDisplayDensityOverride() {
+ if (mDisplayDensityOverride > 0.0f) {
+ return mDisplayDensityOverride;
+ }
+ return null;
+ }
+
+ /**
+ * Gets the display DPI override value.
+ *
+ * @return Returns a positive number. Will return null if not set.
+ */
+ public @Nullable Integer getDisplayDpiOverride() {
+ if (mDisplayDpiOverride > 0) {
+ return mDisplayDpiOverride;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable Class<? extends Service> getCrashHandler() {
+ return mCrashHandler;
+ }
+
+ /**
+ * Gets the screen size override value.
+ *
+ * @return Returns a Rect containing the dimensions to use for the window size. Will return null
+ * if not set.
+ */
+ public @Nullable Rect getScreenSizeOverride() {
+ if ((mScreenWidthOverride > 0) && (mScreenHeightOverride > 0)) {
+ return new Rect(0, 0, mScreenWidthOverride, mScreenHeightOverride);
+ }
+ return null;
+ }
+
+ /**
+ * Gets the list of requested locales.
+ *
+ * @return A list of locale codes in Gecko format ("en" or "en-US").
+ */
+ public @Nullable String[] getLocales() {
+ return mRequestedLocales;
+ }
+
+ /**
+ * Set the locale.
+ *
+ * @param requestedLocales An ordered list of locales in Gecko format ("en-US").
+ */
+ public void setLocales(final @Nullable String[] requestedLocales) {
+ mRequestedLocales = requestedLocales;
+ commitLocales();
+ }
+
+ private void commitLocales() {
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("requestedLocales", mRequestedLocales);
+ data.putString("acceptLanguages", computeAcceptLanguages());
+ EventDispatcher.getInstance().dispatch("GeckoView:SetLocale", data);
+ }
+
+ private String computeAcceptLanguages() {
+ final LinkedHashMap<String, String> locales = new LinkedHashMap<>();
+
+ // Explicitly-set app prefs come first:
+ if (mRequestedLocales != null) {
+ for (final String locale : mRequestedLocales) {
+ locales.put(locale.toLowerCase(Locale.ROOT), locale);
+ }
+ }
+ // OS prefs come second:
+ for (final String locale : getDefaultLocales()) {
+ final String localeLowerCase = locale.toLowerCase(Locale.ROOT);
+ if (!locales.containsKey(localeLowerCase)) {
+ locales.put(localeLowerCase, locale);
+ }
+ }
+
+ return TextUtils.join(",", locales.values());
+ }
+
+ private static String[] getDefaultLocales() {
+ if (VERSION.SDK_INT >= 24) {
+ final LocaleList localeList = LocaleList.getDefault();
+ final String[] locales = new String[localeList.size()];
+ for (int i = 0; i < localeList.size(); i++) {
+ locales[i] = localeList.get(i).toLanguageTag();
+ }
+ return locales;
+ }
+ final String[] locales = new String[1];
+ final Locale locale = Locale.getDefault();
+ if (VERSION.SDK_INT >= 21) {
+ locales[0] = locale.toLanguageTag();
+ return locales;
+ }
+
+ locales[0] = getLanguageTag(locale);
+ return locales;
+ }
+
+ private static String getLanguageTag(final Locale locale) {
+ final StringBuilder out = new StringBuilder(locale.getLanguage());
+ final String country = locale.getCountry();
+ final String variant = locale.getVariant();
+ if (!TextUtils.isEmpty(country)) {
+ out.append('-').append(country);
+ }
+ if (!TextUtils.isEmpty(variant)) {
+ out.append('-').append(variant);
+ }
+ // e.g. "en", "en-US", or "en-US-POSIX".
+ return out.toString();
+ }
+
+ /**
+ * Sets whether Web Manifest processing support is enabled.
+ *
+ * @param enabled A flag determining whether Web Manifest processing support is enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setWebManifestEnabled(final boolean enabled) {
+ mWebManifest.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not Web Manifest processing support is enabled.
+ *
+ * @return True if web manifest processing support is enabled.
+ */
+ public boolean getWebManifestEnabled() {
+ return mWebManifest.get();
+ }
+
+ /**
+ * Set whether or not web console messages should go to logcat.
+ *
+ * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use of
+ * the console API.
+ *
+ * @param enabled A flag determining whether or not web console messages should be printed to
+ * logcat.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setConsoleOutputEnabled(final boolean enabled) {
+ mConsoleOutput.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not web console messages are sent to logcat.
+ *
+ * @return True if console output is enabled.
+ */
+ public boolean getConsoleOutputEnabled() {
+ return mConsoleOutput.get();
+ }
+
+ /**
+ * Set whether or not font sizes in web content should be automatically scaled according to the
+ * device's current system font scale setting. Enabling this will prevent modification of the
+ * {@link GeckoRuntimeSettings#setFontSizeFactor font size factor}. Disabling this setting will
+ * restore the previously used value for the {@link GeckoRuntimeSettings#getFontSizeFactor font
+ * size factor}.
+ *
+ * @param enabled A flag determining whether or not font sizes should be scaled automatically to
+ * match the device's system font scale.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAutomaticFontSizeAdjustment(final boolean enabled) {
+ GeckoFontScaleListener.getInstance().setEnabled(enabled);
+ return this;
+ }
+
+ /**
+ * Get whether or not the font sizes for web content are automatically adjusted to match the
+ * device's system font scale setting.
+ *
+ * @return True if font sizes are automatically adjusted.
+ */
+ public boolean getAutomaticFontSizeAdjustment() {
+ return GeckoFontScaleListener.getInstance().getEnabled();
+ }
+
+ private static final int FONT_INFLATION_BASE_VALUE = 120;
+
+ /**
+ * Set a font size factor that will operate as a global text zoom. All font sizes will be
+ * multiplied by this factor.
+ *
+ * <p>The default factor is 1.0.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * <p>This setting cannot be modified while {@link
+ * GeckoRuntimeSettings#setAutomaticFontSizeAdjustment automatic font size adjustment} is enabled.
+ *
+ * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 disables
+ * both this feature and {@link GeckoRuntimeSettings#setFontInflationEnabled font inflation}.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontSizeFactor(final float fontSizeFactor) {
+ if (getAutomaticFontSizeAdjustment()) {
+ throw new IllegalStateException("Not allowed when automatic font size adjustment is enabled");
+ }
+ return setFontSizeFactorInternal(fontSizeFactor);
+ }
+
+ /*
+ * Enable the Enteprise Roots feature.
+ *
+ * When Enabled, GeckoView will fetch the third-party root certificates added to the
+ * Android OS CA store and will use them internally.
+ *
+ * @param enabled whether to enable this feature or not
+ * @return This GeckoRuntimeSettings instance
+ */
+ public @NonNull GeckoRuntimeSettings setEnterpriseRootsEnabled(final boolean enabled) {
+ mEnterpriseRootsEnabled.commit(enabled);
+ return this;
+ }
+
+ /**
+ * Gets whether the Enteprise Roots feature is enabled or not.
+ *
+ * @return true if the feature is enabled, false otherwise.
+ */
+ public boolean getEnterpriseRootsEnabled() {
+ return mEnterpriseRootsEnabled.get();
+ }
+
+ private static final float DEFAULT_FONT_SIZE_FACTOR = 1f;
+
+ private float sanitizeFontSizeFactor(final float fontSizeFactor) {
+ if (fontSizeFactor < 0) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new IllegalArgumentException("fontSizeFactor cannot be < 0");
+ } else {
+ Log.e(LOGTAG, "fontSizeFactor cannot be < 0");
+ return DEFAULT_FONT_SIZE_FACTOR;
+ }
+ }
+
+ return fontSizeFactor;
+ }
+
+ /* package */ @NonNull
+ GeckoRuntimeSettings setFontSizeFactorInternal(final float fontSizeFactor) {
+ final 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 <code>false</code>.
+ *
+ * <p>When enabled, font sizes will be increased on all pages that are lacking a &lt;meta&gt;
+ * viewport tag and have been loaded in a session using {@link
+ * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic
+ * will attempt to increase font sizes for the main text content of the page only.
+ *
+ * <p>The magnitude of font inflation applied depends on the {@link
+ * GeckoRuntimeSettings#setFontSizeFactor font size factor} currently in use.
+ *
+ * <p>Currently, any changes only take effect after a reload of the session.
+ *
+ * @param enabled A flag determining whether or not font inflation should be enabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setFontInflationEnabled(final boolean enabled) {
+ final int minTwips = enabled ? Math.round(FONT_INFLATION_BASE_VALUE * getFontSizeFactor()) : 0;
+ mFontInflationMinTwips.commit(minTwips);
+ return this;
+ }
+
+ /**
+ * Get whether or not font inflation for non mobile-friendly pages is currently enabled.
+ *
+ * @return True if font inflation is enabled.
+ */
+ public boolean getFontInflationEnabled() {
+ return mFontInflationMinTwips.get() > 0;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({COLOR_SCHEME_LIGHT, COLOR_SCHEME_DARK, COLOR_SCHEME_SYSTEM})
+ public @interface ColorScheme {}
+
+ /** A light theme for web content is preferred. */
+ public static final int COLOR_SCHEME_LIGHT = 0;
+
+ /** A dark theme for web content is preferred. */
+ public static final int COLOR_SCHEME_DARK = 1;
+
+ /** The preferred color scheme will be based on system settings. */
+ public static final int COLOR_SCHEME_SYSTEM = -1;
+
+ /**
+ * Gets the preferred color scheme override for web content.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ */
+ public @ColorScheme int getPreferredColorScheme() {
+ return mPreferredColorScheme;
+ }
+
+ /**
+ * Sets the preferred color scheme override for web content.
+ *
+ * @param scheme The preferred color scheme. Must be one of the {@link
+ * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setPreferredColorScheme(final @ColorScheme int scheme) {
+ if (mPreferredColorScheme != scheme) {
+ mPreferredColorScheme = scheme;
+ GeckoSystemStateListener.onDeviceChanged();
+ }
+ return this;
+ }
+
+ /**
+ * Gets whether auto-zoom to editable fields is enabled.
+ *
+ * @return True if auto-zoom is enabled, false otherwise.
+ */
+ public boolean getInputAutoZoomEnabled() {
+ return mInputAutoZoom.get();
+ }
+
+ /**
+ * Set whether auto-zoom to editable fields should be enabled.
+ *
+ * @param flag True if auto-zoom should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setInputAutoZoomEnabled(final boolean flag) {
+ mInputAutoZoom.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether double-tap zooming is enabled.
+ *
+ * @return True if double-tap zooming is enabled, false otherwise.
+ */
+ public boolean getDoubleTapZoomingEnabled() {
+ return mDoubleTapZooming.get();
+ }
+
+ /**
+ * Sets whether double tap zooming is enabled.
+ *
+ * @param flag true if double tap zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setDoubleTapZoomingEnabled(final boolean flag) {
+ mDoubleTapZooming.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets the current WebGL MSAA level.
+ *
+ * @return number of MSAA samples, 0 if MSAA is disabled.
+ */
+ public int getGlMsaaLevel() {
+ return mGlMsaaLevel.get();
+ }
+
+ /**
+ * Sets the WebGL MSAA level.
+ *
+ * @param level number of MSAA samples, 0 if MSAA should be disabled.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setGlMsaaLevel(final int level) {
+ mGlMsaaLevel.commit(level);
+ return this;
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable RuntimeTelemetry.Delegate getTelemetryDelegate() {
+ return mTelemetryProxy.getDelegate();
+ }
+
+ /**
+ * Gets whether about:config is enabled or not.
+ *
+ * @return True if about:config is enabled, false otherwise.
+ */
+ public boolean getAboutConfigEnabled() {
+ return mAboutConfig.get();
+ }
+
+ /**
+ * Sets whether or not about:config should be enabled. This is a page that allows users to
+ * directly modify Gecko preferences. Modification of some preferences may cause the app to break
+ * in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc.
+ *
+ * @param flag True if about:config should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAboutConfigEnabled(final boolean flag) {
+ mAboutConfig.commit(flag);
+ return this;
+ }
+
+ /**
+ * Gets whether or not force user scalable zooming should be enabled or not.
+ *
+ * @return True if force user scalable zooming should be enabled, false otherwise.
+ */
+ public boolean getForceUserScalableEnabled() {
+ return mForceUserScalable.get();
+ }
+
+ /**
+ * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set
+ * on the viewport.
+ *
+ * @param flag True if force user scalable zooming should be enabled, false otherwise.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setForceUserScalableEnabled(final boolean flag) {
+ mForceUserScalable.commit(flag);
+ return this;
+ }
+
+ /**
+ * Get whether login form autofill is enabled.
+ *
+ * @return True if login autofill is enabled.
+ */
+ public boolean getLoginAutofillEnabled() {
+ return mAutofillLogins.get();
+ }
+
+ /**
+ * Set whether login forms should be filled automatically if only one viable candidate is provided
+ * via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}.
+ *
+ * @param enabled A flag determining whether login autofill should be enabled.
+ * @return The builder instance.
+ */
+ public @NonNull GeckoRuntimeSettings setLoginAutofillEnabled(final boolean enabled) {
+ mAutofillLogins.commit(enabled);
+ return this;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ALLOW_ALL, HTTPS_ONLY_PRIVATE, HTTPS_ONLY})
+ public @interface HttpsOnlyMode {}
+
+ /** Allow all insecure connections */
+ public static final int ALLOW_ALL = 0;
+
+ /** Allow insecure connections in normal browsing, but only HTTPS in private browsing. */
+ public static final int HTTPS_ONLY_PRIVATE = 1;
+
+ /** Only allow HTTPS connections. */
+ public static final int HTTPS_ONLY = 2;
+
+ /**
+ * Get whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @return One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ */
+ public @HttpsOnlyMode int getAllowInsecureConnections() {
+ final boolean httpsOnly = mHttpsOnly.get();
+ final boolean httpsOnlyPrivate = mHttpsOnlyPrivateMode.get();
+ if (httpsOnly) {
+ return HTTPS_ONLY;
+ } else if (httpsOnlyPrivate) {
+ return HTTPS_ONLY_PRIVATE;
+ }
+ return ALLOW_ALL;
+ }
+
+ /**
+ * Set whether and where insecure (non-HTTPS) connections are allowed.
+ *
+ * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants.
+ * @return This GeckoRuntimeSettings instance.
+ */
+ public @NonNull GeckoRuntimeSettings setAllowInsecureConnections(final @HttpsOnlyMode int level) {
+ switch (level) {
+ case ALLOW_ALL:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ case HTTPS_ONLY_PRIVATE:
+ mHttpsOnly.commit(false);
+ mHttpsOnlyPrivateMode.commit(true);
+ break;
+ case HTTPS_ONLY:
+ mHttpsOnly.commit(true);
+ mHttpsOnlyPrivateMode.commit(false);
+ break;
+ default:
+ throw new IllegalArgumentException("Invalid setting for setAllowInsecureConnections");
+ }
+ return this;
+ }
+
+ // For internal use only
+ /* protected */ @NonNull
+ GeckoRuntimeSettings setProcessCount(final int processCount) {
+ mProcessCount.commit(processCount);
+ return this;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel out, final int flags) {
+ super.writeToParcel(out, flags);
+
+ out.writeStringArray(mArgs);
+ mExtras.writeToParcel(out, flags);
+ ParcelableUtils.writeBoolean(out, mForceEnableAccessibility);
+ ParcelableUtils.writeBoolean(out, mDebugPause);
+ ParcelableUtils.writeBoolean(out, mUseMaxScreenDepth);
+ out.writeFloat(mDisplayDensityOverride);
+ out.writeInt(mDisplayDpiOverride);
+ out.writeInt(mScreenWidthOverride);
+ out.writeInt(mScreenHeightOverride);
+ out.writeString(mCrashHandler != null ? mCrashHandler.getName() : null);
+ out.writeStringArray(mRequestedLocales);
+ out.writeString(mConfigFilePath);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ super.readFromParcel(source);
+
+ mArgs = source.createStringArray();
+ mExtras.readFromParcel(source);
+ mForceEnableAccessibility = ParcelableUtils.readBoolean(source);
+ mDebugPause = ParcelableUtils.readBoolean(source);
+ mUseMaxScreenDepth = ParcelableUtils.readBoolean(source);
+ mDisplayDensityOverride = source.readFloat();
+ mDisplayDpiOverride = source.readInt();
+ mScreenWidthOverride = source.readInt();
+ mScreenHeightOverride = source.readInt();
+
+ final String crashHandlerName = source.readString();
+ if (crashHandlerName != null) {
+ try {
+ @SuppressWarnings("unchecked")
+ final Class<? extends Service> handler =
+ (Class<? extends Service>) Class.forName(crashHandlerName);
+
+ mCrashHandler = handler;
+ } catch (final ClassNotFoundException e) {
+ }
+ }
+
+ mRequestedLocales = source.createStringArray();
+ mConfigFilePath = source.readString();
+ }
+
+ public static final Parcelable.Creator<GeckoRuntimeSettings> CREATOR =
+ new Parcelable.Creator<GeckoRuntimeSettings>() {
+ @Override
+ public GeckoRuntimeSettings createFromParcel(final Parcel in) {
+ final GeckoRuntimeSettings settings = new GeckoRuntimeSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoRuntimeSettings[] newArray(final int size) {
+ return new GeckoRuntimeSettings[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java
new file mode 100644
index 0000000000..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<Bitmap> result,
+ final Bitmap target,
+ final int x,
+ final int y,
+ final int srcWidth,
+ final int srcHeight,
+ final int outWidth,
+ final int outHeight);
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "current")
+ public native void enableLayerUpdateNotifications(boolean enable);
+
+ // The compositor invokes this function just before compositing a frame where the
+ // document is different from the document composited on the last frame. In these
+ // cases, the viewport information we have in Java is no longer valid and needs to
+ // be replaced with the new viewport information provided.
+ @WrapForJNI(calledFrom = "ui")
+ private void updateRootFrameMetrics(
+ final float scrollX, final float scrollY, final float zoom) {
+ GeckoSession.this.onMetricsChanged(scrollX, scrollY, zoom);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollVelocity(final float x, final float y) {
+ GeckoSession.this.updateOverscrollVelocity(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void updateOverscrollOffset(final float x, final float y) {
+ GeckoSession.this.updateOverscrollOffset(x, y);
+ }
+
+ @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko")
+ public native void onSafeAreaInsetsChanged(int top, int right, int bottom, int left);
+
+ @WrapForJNI(calledFrom = "ui")
+ public void setPointerIcon(
+ final int defaultCursor, final Bitmap customCursor, final float x, final float y) {
+ GeckoSession.this.setPointerIcon(defaultCursor, customCursor, x, y);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ disposeNative();
+ }
+ }
+
+ /* package */ final Compositor mCompositor = new Compositor();
+
+ @WrapForJNI(stubName = "GetCompositor", calledFrom = "ui")
+ private Object getCompositorFromNative() {
+ // Only used by native code.
+ return mCompositorReady ? mCompositor : null;
+ }
+
+ private final GeckoSessionHandler<HistoryDelegate> mHistoryHandler =
+ new GeckoSessionHandler<HistoryDelegate>(
+ "GeckoViewHistory",
+ this,
+ new String[] {
+ "GeckoView:OnVisited", "GeckoView:GetVisited", "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final HistoryDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:OnVisited".equals(event)) {
+ final GeckoResult<Boolean> result =
+ delegate.onVisited(
+ GeckoSession.this,
+ message.getString("url"),
+ message.getString("lastVisitedURL"),
+ message.getInt("flags"));
+
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited.booleanValue()),
+ exception -> callback.sendSuccess(false));
+ } else if ("GeckoView:GetVisited".equals(event)) {
+ final String[] urls = message.getStringArray("urls");
+
+ final GeckoResult<boolean[]> result = delegate.getVisited(GeckoSession.this, urls);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ visited -> callback.sendSuccess(visited),
+ exception -> callback.sendError("Failed to fetch visited statuses for URIs"));
+ } else if ("GeckoView:StateUpdated".equals(event)) {
+
+ final GeckoBundle update = message.getBundle("data");
+
+ if (update == null) {
+ return;
+ }
+ final int previousHistorySize = mStateCache.size();
+ mStateCache.updateSessionState(update);
+
+ final ProgressDelegate progressDelegate = getProgressDelegate();
+ if (progressDelegate != null) {
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ progressDelegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+
+ if (update.getBundle("historychange") != null) {
+ final SessionState state = new SessionState(mStateCache);
+
+ delegate.onHistoryStateChange(GeckoSession.this, state);
+
+ // If the previous history was larger than one entry and the new size is one, it means
+ // the
+ // History has been purged and the navigation delegate needs to be update.
+ if ((previousHistorySize > 1)
+ && (state.size() == 1)
+ && mNavigationHandler.getDelegate() != null) {
+ mNavigationHandler.getDelegate().onCanGoForward(GeckoSession.this, false);
+ mNavigationHandler.getDelegate().onCanGoBack(GeckoSession.this, false);
+ }
+ }
+ }
+ }
+ };
+
+ private final WebExtension.SessionController mWebExtensionController;
+
+ private final GeckoSessionHandler<ContentDelegate> mContentHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewContent",
+ this,
+ new String[] {
+ "GeckoView:ContentCrash",
+ "GeckoView:ContentKill",
+ "GeckoView:ContextMenu",
+ "GeckoView:DOMMetaViewportFit",
+ "GeckoView:PageTitleChanged",
+ "GeckoView:DOMWindowClose",
+ "GeckoView:ExternalResponse",
+ "GeckoView:FocusRequest",
+ "GeckoView:FullScreenEnter",
+ "GeckoView:FullScreenExit",
+ "GeckoView:WebAppManifest",
+ "GeckoView:FirstContentfulPaint",
+ "GeckoView:PaintStatusReset",
+ "GeckoView:PreviewImage",
+ "GeckoView:CookieBannerEvent:Detected",
+ "GeckoView:CookieBannerEvent:Handled",
+ "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<WebResponse> 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<NavigationDelegate> mNavigationHandler =
+ new GeckoSessionHandler<NavigationDelegate>(
+ "GeckoViewNavigation",
+ this,
+ new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"},
+ new String[] {
+ "GeckoView:OnLoadError", "GeckoView:OnLoadRequest",
+ }) {
+ // This needs to match nsIBrowserDOMWindow.idl
+ private int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return NavigationDelegate.TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB
+ return NavigationDelegate.TARGET_WINDOW_NEW;
+ }
+ }
+
+ @Override
+ public void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+
+ if ("GeckoView:OnLoadRequest".equals(event)) {
+ callback.sendSuccess(false);
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ callback.sendSuccess(null);
+ } else {
+ super.handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ // For .isOpen(), the linter is not smart enough to figure out we're asserting that we're on
+ // the UI thread.
+ @SuppressLint("WrongThread")
+ @Override
+ public void handleMessage(
+ final NavigationDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:LocationChange".equals(event)) {
+ if (message.getBoolean("isTopLevel")) {
+ final GeckoBundle[] perms = message.getBundleArray("permissions");
+ final List<PermissionDelegate.ContentPermission> permList =
+ PermissionDelegate.ContentPermission.fromBundleArray(perms);
+ delegate.onLocationChange(GeckoSession.this, message.getString("uri"), permList);
+ }
+ delegate.onCanGoBack(GeckoSession.this, message.getBoolean("canGoBack"));
+ delegate.onCanGoForward(GeckoSession.this, message.getBoolean("canGoForward"));
+ } else if ("GeckoView:OnLoadRequest".equals(event)) {
+ final NavigationDelegate.LoadRequest request =
+ new NavigationDelegate.LoadRequest(
+ message.getString("uri"),
+ message.getString("triggerUri"),
+ message.getInt("where"),
+ message.getInt("flags"),
+ message.getBoolean("hasUserGesture"),
+ /* isDirectNavigation */ false);
+
+ if (!IntentUtils.isUriSafeForScheme(request.uri)) {
+ callback.sendError("Blocked unsafe intent URI");
+
+ delegate.onLoadError(
+ GeckoSession.this,
+ request.uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> result =
+ delegate.onLoadRequest(GeckoSession.this, request);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ value -> {
+ ThreadUtils.assertOnUiThread();
+ if (value == AllowOrDeny.ALLOW) {
+ return false;
+ }
+ if (value == AllowOrDeny.DENY) {
+ return true;
+ }
+ throw new IllegalArgumentException("Invalid response");
+ }));
+ } else if ("GeckoView:OnLoadError".equals(event)) {
+ final String uri = message.getString("uri");
+ final long errorCode = message.getLong("error");
+ final int errorModule = message.getInt("errorModule");
+ final int errorClass = message.getInt("errorClass");
+
+ final WebRequestError err =
+ WebRequestError.fromGeckoError(errorCode, errorModule, errorClass, null);
+
+ final GeckoResult<String> result = delegate.onLoadError(GeckoSession.this, uri, err);
+ if (result == null) {
+ callback.sendError("abort");
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(
+ url -> {
+ if (url == null) {
+ throw new IllegalArgumentException("abort");
+ }
+ return url;
+ }));
+ } else if ("GeckoView:OnNewSession".equals(event)) {
+ final String uri = message.getString("uri");
+ final GeckoResult<GeckoSession> result = delegate.onNewSession(GeckoSession.this, uri);
+ if (result == null) {
+ callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.getString("newSessionId");
+ callback.resolveTo(
+ result.map(
+ session -> {
+ ThreadUtils.assertOnUiThread();
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new AssertionError("Must use an unopened GeckoSession instance");
+ }
+
+ if (GeckoSession.this.mWindow == null) {
+ throw new IllegalArgumentException("Session is not attached to a window");
+ }
+
+ session.open(GeckoSession.this.mWindow.runtime, newSessionId);
+ return true;
+ }));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<PrintDelegate> mPrintHandler =
+ new GeckoSessionHandler<PrintDelegate>(
+ "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<InputStream> pdfResult = saveAsPdfByBrowsingContext(cbcId);
+ final GeckoBundle bundle = new GeckoBundle();
+ pdfResult
+ .accept(
+ pdfStream -> {
+ final GeckoResult<Boolean> 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<ContentDelegate> mProcessHangHandler =
+ new GeckoSessionHandler<ContentDelegate>(
+ "GeckoViewProcessHangMonitor", this, new String[] {"GeckoView:HangReport"}) {
+
+ @Override
+ protected void handleMessage(
+ final ContentDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback eventCallback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+
+ final GeckoResult<SlowScriptResponse> result =
+ delegate.onSlowScript(GeckoSession.this, message.getString("scriptFileName"));
+ if (result != null) {
+ final int mReportId = message.getInt("hangId");
+ result.accept(
+ stopOrContinue -> {
+ if (stopOrContinue != null) {
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", mReportId);
+ switch (stopOrContinue) {
+ case STOP:
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ break;
+ case CONTINUE:
+ mEventDispatcher.dispatch("GeckoView:HangReportWait", bundle);
+ break;
+ }
+ }
+ });
+ } else {
+ // default to stopping the script
+ final GeckoBundle bundle = new GeckoBundle();
+ bundle.putInt("hangId", message.getInt("hangId"));
+ mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ProgressDelegate> mProgressHandler =
+ new GeckoSessionHandler<ProgressDelegate>(
+ "GeckoViewProgress",
+ this,
+ new String[] {
+ "GeckoView:PageStart",
+ "GeckoView:PageStop",
+ "GeckoView:ProgressChanged",
+ "GeckoView:SecurityChanged",
+ "GeckoView:StateUpdated",
+ }) {
+ @Override
+ public void handleMessage(
+ final ProgressDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri"));
+ if ("GeckoView:PageStart".equals(event)) {
+ delegate.onPageStart(GeckoSession.this, message.getString("uri"));
+ } else if ("GeckoView:PageStop".equals(event)) {
+ delegate.onPageStop(GeckoSession.this, message.getBoolean("success"));
+ } else if ("GeckoView:ProgressChanged".equals(event)) {
+ delegate.onProgressChange(GeckoSession.this, message.getInt("progress"));
+ } else if ("GeckoView:SecurityChanged".equals(event)) {
+ final GeckoBundle identity = message.getBundle("identity");
+ delegate.onSecurityChange(
+ GeckoSession.this, new ProgressDelegate.SecurityInformation(identity));
+ } else if ("GeckoView:StateUpdated".equals(event)) {
+ final GeckoBundle update = message.getBundle("data");
+ if (update != null) {
+ if (getHistoryDelegate() == null) {
+ mStateCache.updateSessionState(update);
+ final SessionState state = new SessionState(mStateCache);
+ if (!state.isEmpty()) {
+ delegate.onSessionStateChange(GeckoSession.this, state);
+ }
+ }
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ScrollDelegate> mScrollHandler =
+ new GeckoSessionHandler<ScrollDelegate>(
+ "GeckoViewScroll", this, new String[] {"GeckoView:ScrollChanged"}) {
+ @Override
+ public void handleMessage(
+ final ScrollDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ScrollChanged".equals(event)) {
+ delegate.onScrollChanged(
+ GeckoSession.this, message.getInt("scrollX"), message.getInt("scrollY"));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<ContentBlocking.Delegate> mContentBlockingHandler =
+ new GeckoSessionHandler<ContentBlocking.Delegate>(
+ "GeckoViewContentBlocking", this, new String[] {"GeckoView:ContentBlockingEvent"}) {
+ @Override
+ public void handleMessage(
+ final ContentBlocking.Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+
+ if ("GeckoView:ContentBlockingEvent".equals(event)) {
+ final ContentBlocking.BlockEvent be = ContentBlocking.BlockEvent.fromBundle(message);
+ if (be.isBlocking()) {
+ delegate.onContentBlocked(GeckoSession.this, be);
+ } else {
+ delegate.onContentLoaded(GeckoSession.this, be);
+ }
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<PermissionDelegate> mPermissionHandler =
+ new GeckoSessionHandler<PermissionDelegate>(
+ "GeckoViewPermission",
+ this,
+ new String[] {
+ "GeckoView:AndroidPermission",
+ "GeckoView:ContentPermission",
+ "GeckoView:MediaPermission"
+ }) {
+ @Override
+ public void handleMessage(
+ final PermissionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if (delegate == null) {
+ callback.sendSuccess(/* granted */ false);
+ return;
+ }
+ if ("GeckoView:AndroidPermission".equals(event)) {
+ delegate.onAndroidPermissionsRequest(
+ GeckoSession.this,
+ message.getStringArray("perms"),
+ new PermissionCallback("android", callback));
+ } else if ("GeckoView:ContentPermission".equals(event)) {
+ final GeckoResult<Integer> res =
+ delegate.onContentPermissionRequest(
+ GeckoSession.this, new PermissionDelegate.ContentPermission(message));
+ if (res == null) {
+ callback.sendSuccess(PermissionDelegate.ContentPermission.VALUE_PROMPT);
+ return;
+ }
+
+ callback.resolveTo(res);
+ } else if ("GeckoView:MediaPermission".equals(event)) {
+ final GeckoBundle[] videoBundles = message.getBundleArray("video");
+ final GeckoBundle[] audioBundles = message.getBundleArray("audio");
+ PermissionDelegate.MediaSource[] videos = null;
+ PermissionDelegate.MediaSource[] audios = null;
+
+ if (videoBundles != null) {
+ videos = new PermissionDelegate.MediaSource[videoBundles.length];
+ for (int i = 0; i < videoBundles.length; i++) {
+ videos[i] = new PermissionDelegate.MediaSource(videoBundles[i]);
+ }
+ }
+
+ if (audioBundles != null) {
+ audios = new PermissionDelegate.MediaSource[audioBundles.length];
+ for (int i = 0; i < audioBundles.length; i++) {
+ audios[i] = new PermissionDelegate.MediaSource(audioBundles[i]);
+ }
+ }
+
+ delegate.onMediaPermissionRequest(
+ GeckoSession.this,
+ message.getString("uri"),
+ videos,
+ audios,
+ new PermissionCallback("media", callback));
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<SelectionActionDelegate> mSelectionActionDelegate =
+ new GeckoSessionHandler<SelectionActionDelegate>(
+ "GeckoViewSelectionAction",
+ this,
+ new String[] {
+ "GeckoView:HideSelectionAction",
+ "GeckoView:ShowSelectionAction",
+ "GeckoView:HideMagnifier",
+ "GeckoView:ShowMagnifier",
+ "GeckoView:ClipboardPermissionRequest",
+ "GeckoView:DismissClipboardPermissionRequest",
+ }) {
+ @Override
+ public void handleMessage(
+ final SelectionActionDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage: " + event);
+ if ("GeckoView:ShowSelectionAction".equals(event)) {
+ final @SelectionActionDelegateAction HashSet<String> actionsSet =
+ new HashSet<>(Arrays.asList(message.getStringArray("actions")));
+ final SelectionActionDelegate.Selection selection =
+ new SelectionActionDelegate.Selection(message, actionsSet, mEventDispatcher);
+
+ delegate.onShowActionRequest(GeckoSession.this, selection);
+
+ } else if ("GeckoView:HideSelectionAction".equals(event)) {
+ final String reasonString = message.getString("reason");
+ final int reason;
+ if ("invisibleselection".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION;
+ } else if ("presscaret".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION;
+ } else if ("scroll".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL;
+ } else if ("visibilitychange".equals(reasonString)) {
+ reason = SelectionActionDelegate.HIDE_REASON_NO_SELECTION;
+ } else {
+ throw new IllegalArgumentException();
+ }
+
+ delegate.onHideAction(GeckoSession.this, reason);
+ } else if ("GeckoView:ShowMagnifier".equals(event)) {
+ final PointF point = message.getPointF("screenPoint");
+ if (point == null) {
+ throw new IllegalArgumentException("Invalid argument");
+ }
+
+ // Magnifier is surface coordinate.
+ point.x -= GeckoSession.this.mLeft;
+ point.y -= GeckoSession.this.mClientTop;
+ GeckoSession.this.getMagnifier().show(point);
+ } else if ("GeckoView:HideMagnifier".equals(event)) {
+ GeckoSession.this.getMagnifier().dismiss();
+ } else if ("GeckoView:ClipboardPermissionRequest".equals(event)) {
+ final SelectionActionDelegate.ClipboardPermission permission =
+ new SelectionActionDelegate.ClipboardPermission(message);
+
+ final GeckoResult<AllowOrDeny> result =
+ delegate.onShowClipboardPermissionRequest(GeckoSession.this, permission);
+ callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return true;
+ }
+ if (value == AllowOrDeny.DENY) {
+ return false;
+ }
+ throw new IllegalArgumentException("Invalid response");
+ }));
+ } else if ("GeckoView:DismissClipboardPermissionRequest".equals(event)) {
+ delegate.onDismissClipboardPermissionRequest(GeckoSession.this);
+ }
+ }
+ };
+
+ private final GeckoSessionHandler<MediaDelegate> mMediaHandler =
+ new GeckoSessionHandler<MediaDelegate>(
+ "GeckoViewMedia",
+ this,
+ new String[] {
+ "GeckoView:MediaRecordingStatusChanged",
+ }) {
+ @Override
+ public void handleMessage(
+ final MediaDelegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if ("GeckoView:MediaRecordingStatusChanged".equals(event)) {
+ final GeckoBundle[] deviceBundles = message.getBundleArray("devices");
+ final MediaDelegate.RecordingDevice[] devices =
+ new MediaDelegate.RecordingDevice[deviceBundles.length];
+ for (int i = 0; i < deviceBundles.length; i++) {
+ devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]);
+ }
+ delegate.onRecordingStatusChanged(GeckoSession.this, devices);
+ return;
+ }
+ }
+ };
+
+ private final MediaSession.Handler mMediaSessionHandler = new MediaSession.Handler(this);
+
+ /* package */ int handlersCount;
+
+ private final GeckoSessionHandler<?>[] mSessionHandlers =
+ new GeckoSessionHandler<?>[] {
+ mContentHandler,
+ mHistoryHandler,
+ mMediaHandler,
+ mNavigationHandler,
+ mPermissionHandler,
+ 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<String> getUserAgent() {
+ return mEventDispatcher.queryString("GeckoView:GetUserAgent");
+ }
+
+ /**
+ * Get the default user agent for this GeckoView build.
+ *
+ * <p>This method does not account for any override that might have been applied to the user agent
+ * string.
+ *
+ * @return the default user agent string
+ */
+ @AnyThread
+ public static @NonNull String getDefaultUserAgent() {
+ return BuildConfig.USER_AGENT_GECKOVIEW_MOBILE;
+ }
+
+ /**
+ * Get the current permission delegate for this GeckoSession.
+ *
+ * @return PermissionDelegate instance or null if using default delegate.
+ */
+ @UiThread
+ public @Nullable PermissionDelegate getPermissionDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mPermissionHandler.getDelegate();
+ }
+
+ /**
+ * Set the current permission delegate for this GeckoSession.
+ *
+ * @param delegate PermissionDelegate instance or null to use the default delegate.
+ */
+ @UiThread
+ public void setPermissionDelegate(final @Nullable PermissionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mPermissionHandler.setDelegate(delegate, this);
+ }
+
+ private PromptDelegate mPromptDelegate;
+
+ private final Listener mListener = new Listener();
+
+ /* package */ static final class Window extends JNIObject implements IInterface {
+ public final GeckoRuntime runtime;
+ private WeakReference<GeckoSession> mOwner;
+ private NativeQueue mNativeQueue;
+ private Binder mBinder;
+
+ public Window(
+ final @NonNull GeckoRuntime runtime,
+ final @NonNull GeckoSession owner,
+ final @NonNull NativeQueue nativeQueue) {
+ this.runtime = runtime;
+ mOwner = new WeakReference<>(owner);
+ mNativeQueue = nativeQueue;
+ }
+
+ @Override // IInterface
+ public Binder asBinder() {
+ if (mBinder == null) {
+ mBinder = new Binder();
+ mBinder.attachInterface(this, Window.class.getName());
+ }
+ return mBinder;
+ }
+
+ // Create a new Gecko window and assign an initial set of Java session objects to it.
+ @WrapForJNI(dispatchTo = "proxy")
+ public static native void open(
+ Window instance,
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData,
+ String id,
+ String chromeUri,
+ boolean privateMode);
+
+ @Override // JNIObject
+ public void disposeNative() {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeDisposeNative();
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY, this, "nativeDisposeNative");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "DisposeNative")
+ private native void nativeDisposeNative();
+
+ // Force the underlying Gecko window to close and release assigned Java objects.
+ public void close() {
+ // Reset our queue, so we don't end up with queued calls on a disposed object.
+ synchronized (this) {
+ if (mNativeQueue == null) {
+ // Already closed elsewhere.
+ return;
+ }
+ mNativeQueue.reset(State.INITIAL);
+ mNativeQueue = null;
+ mOwner = new WeakReference<>(null);
+ }
+
+ // Detach ourselves from the binder as well, to prevent this window from being
+ // read from any parcels.
+ asBinder().attachInterface(null, Window.class.getName());
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeClose();
+ } else {
+ GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, "nativeClose");
+ }
+ }
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Close")
+ private native void nativeClose();
+
+ @WrapForJNI(dispatchTo = "proxy", stubName = "Transfer")
+ private native void nativeTransfer(
+ NativeQueue queue,
+ Compositor compositor,
+ EventDispatcher dispatcher,
+ SessionAccessibility.NativeProvider sessionAccessibility,
+ GeckoBundle initData);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachEditable(IGeckoEditableParent parent);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void attachAccessibility(
+ SessionAccessibility.NativeProvider sessionAccessibility);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ public native void printToPdf(GeckoResult<InputStream> geckoResult);
+
+ @WrapForJNI(dispatchTo = "proxy")
+ private native void printToPdf(GeckoResult<InputStream> 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<Boolean> onLoadRequest(
+ final @NonNull String uri,
+ final int windowType,
+ final int flags,
+ final @Nullable String triggeringUri,
+ final boolean hasUserGesture,
+ final boolean isTopLevel) {
+ final ProfilerController profilerController = runtime.getProfilerController();
+ final Double onLoadRequestProfilerStartTime = profilerController.getProfilerTime();
+ final Runnable addMarker =
+ () ->
+ profilerController.addMarker(
+ "GeckoSession.onLoadRequest", onLoadRequestProfilerStartTime);
+
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ // Don't handle any load request if we can't get the session for some reason.
+ return GeckoResult.fromValue(false);
+ }
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+
+ ThreadUtils.postToUiThread(
+ new Runnable() {
+ @Override
+ public void run() {
+ final NavigationDelegate delegate = session.getNavigationDelegate();
+
+ if (delegate == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ if (!IntentUtils.isUriSafeForScheme(uri)) {
+ delegate.onLoadError(
+ session,
+ uri,
+ new WebRequestError(
+ WebRequestError.ERROR_MALFORMED_URI,
+ WebRequestError.ERROR_CATEGORY_URI,
+ null));
+ res.complete(true);
+ addMarker.run();
+ return;
+ }
+
+ final String trigger = TextUtils.isEmpty(triggeringUri) ? null : triggeringUri;
+ final NavigationDelegate.LoadRequest req =
+ new NavigationDelegate.LoadRequest(
+ uri,
+ trigger,
+ windowType,
+ flags,
+ hasUserGesture,
+ false /* isDirectNavigation */);
+ final GeckoResult<AllowOrDeny> reqResponse =
+ isTopLevel
+ ? delegate.onLoadRequest(session, req)
+ : delegate.onSubframeLoadRequest(session, req);
+
+ if (reqResponse == null) {
+ res.complete(false);
+ addMarker.run();
+ return;
+ }
+
+ reqResponse.accept(
+ value -> {
+ if (value == AllowOrDeny.DENY) {
+ res.complete(true);
+ } else {
+ res.complete(false);
+ }
+ addMarker.run();
+ },
+ ex -> {
+ // This is incredibly ugly and unreadable because checkstyle sucks.
+ res.complete(false);
+ addMarker.run();
+ });
+ }
+ });
+
+ return res;
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void passExternalWebResponse(final WebResponse response) {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onExternalResponse(session, response);
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onShowDynamicToolbar() {
+ final Window self = this;
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = self.mOwner.get();
+ if (session == null) {
+ return;
+ }
+ final ContentDelegate delegate = session.getContentDelegate();
+ if (delegate != null) {
+ delegate.onShowDynamicToolbar(session);
+ }
+ });
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private void onUpdateSessionStore(final GeckoBundle aBundle) {
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoSession session = mOwner.get();
+ if (session == null) {
+ return;
+ }
+ GeckoBundle scroll = aBundle.getBundle("scroll");
+ if (scroll == null) {
+ scroll = new GeckoBundle();
+ aBundle.putBundle("scroll", scroll);
+ }
+
+ // Here we unfortunately need to do some re-mapping since `zoom` is passed in a separate
+ // bunds and we wish to keep the bundle format.
+ scroll.putBundle("zoom", aBundle.getBundle("zoom"));
+ final SessionState stateCache = session.mStateCache;
+ stateCache.updateSessionState(aBundle);
+ final SessionState state = new SessionState(stateCache);
+ if (!state.isEmpty()) {
+ final ProgressDelegate progressDelegate = session.getProgressDelegate();
+ if (progressDelegate != null) {
+ progressDelegate.onSessionStateChange(session, state);
+ } else {
+ }
+ }
+ });
+ }
+ }
+
+ private class Listener implements BundleEventListener {
+ /* package */ void registerListeners() {
+ getEventDispatcher()
+ .registerUiThreadListener(
+ this,
+ "GeckoView:PinOnScreen",
+ "GeckoView:Prompt",
+ "GeckoView:Prompt:Dismiss",
+ "GeckoView:Prompt:Update",
+ null);
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:PinOnScreen".equals(event)) {
+ GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned"));
+ } else if ("GeckoView:Prompt".equals(event)) {
+ mPromptController.handleEvent(GeckoSession.this, message.getBundle("prompt"), callback);
+ } else if ("GeckoView:Prompt:Dismiss".equals(event)) {
+ mPromptController.dismissPrompt(message.getString("id"));
+ } else if ("GeckoView:Prompt:Update".equals(event)) {
+ mPromptController.updatePrompt(message.getBundle("prompt"));
+ }
+ }
+ }
+
+ private final PromptController mPromptController;
+
+ protected @Nullable Window mWindow;
+ private GeckoSessionSettings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession() {
+ this(null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSession(final @Nullable GeckoSessionSettings settings) {
+ mSettings = new GeckoSessionSettings(settings, this);
+ mListener.registerListeners();
+
+ mWebExtensionController = new WebExtension.SessionController(this);
+ mPromptController = new PromptController();
+
+ mAutofillSupport = new Autofill.Support(this);
+ mAutofillSupport.registerListeners();
+
+ if (BuildConfig.DEBUG_BUILD && handlersCount != mSessionHandlers.length) {
+ throw new AssertionError("Add new handler to handlers list");
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mWindow == null) {
+ return null;
+ }
+ return mWindow.runtime;
+ }
+
+ /* package */ synchronized void abandonWindow() {
+ if (mWindow == null) {
+ return;
+ }
+
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ true);
+ mWindow = null;
+ onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ false);
+ }
+
+ /**
+ * Return whether this session is open.
+ *
+ * @return True if session is open.
+ * @see #open
+ * @see #close
+ */
+ @UiThread
+ public boolean isOpen() {
+ ThreadUtils.assertOnUiThread();
+ return mWindow != null;
+ }
+
+ /* package */ boolean isReady() {
+ return mNativeQueue.isReady();
+ }
+
+ private GeckoBundle createInitData() {
+ final GeckoBundle initData = new GeckoBundle(2);
+ initData.putBundle("settings", mSettings.toBundle());
+
+ final GeckoBundle modules = new GeckoBundle(mSessionHandlers.length);
+ for (final GeckoSessionHandler<?> handler : mSessionHandlers) {
+ modules.putBoolean(handler.getName(), handler.isEnabled());
+ }
+ initData.putBundle("modules", modules);
+ return initData;
+ }
+
+ /**
+ * Opens the session.
+ *
+ * <p>Call this when you are ready to use a GeckoSession instance.
+ *
+ * <p>The session is in a 'closed' state when first created. Opening it creates the underlying
+ * Gecko objects necessary to load a page, etc. Most GeckoSession methods only take affect on an
+ * open session, and are queued until the session is opened here. Opening a session is an
+ * asynchronous operation.
+ *
+ * @param runtime The Gecko runtime to attach this session to.
+ * @see #close
+ * @see #isOpen
+ */
+ @UiThread
+ public void open(final @NonNull GeckoRuntime runtime) {
+ open(runtime, UUID.randomUUID().toString().replace("-", ""));
+ }
+
+ /* package */ void open(final @NonNull GeckoRuntime runtime, final String id) {
+ ThreadUtils.assertOnUiThread();
+
+ if (isOpen()) {
+ // We will leak the existing Window if we open another one.
+ throw new IllegalStateException("Session is open");
+ }
+
+ final String chromeUri = mSettings.getChromeUri();
+ final boolean isPrivate = mSettings.getUsePrivateMode();
+
+ mId = id;
+ mWindow = new Window(runtime, this, mNativeQueue);
+ mWebExtensionController.setRuntime(runtime);
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ true);
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ Window.open(
+ mWindow,
+ mNativeQueue,
+ mCompositor,
+ mEventDispatcher,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ createInitData(),
+ mId,
+ chromeUri,
+ isPrivate);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ Window.class,
+ "open",
+ Window.class,
+ mWindow,
+ NativeQueue.class,
+ mNativeQueue,
+ Compositor.class,
+ mCompositor,
+ EventDispatcher.class,
+ mEventDispatcher,
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility != null ? mAccessibility.nativeProvider : null,
+ GeckoBundle.class,
+ createInitData(),
+ String.class,
+ mId,
+ String.class,
+ chromeUri,
+ isPrivate);
+ }
+
+ onWindowChanged(WINDOW_OPEN, /* inProgress */ false);
+ }
+
+ /**
+ * Closes the session.
+ *
+ * <p>This frees the underlying Gecko objects and unloads the current page. The session may be
+ * reopened later, but page state is not restored. Call this when you are finished using a
+ * GeckoSession instance.
+ *
+ * @see #open
+ * @see #isOpen
+ */
+ @UiThread
+ public void close() {
+ ThreadUtils.assertOnUiThread();
+
+ if (!isOpen()) {
+ Log.w(LOGTAG, "Attempted to close a GeckoSession that was already closed.");
+ return;
+ }
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ true);
+
+ // We need to ensure the compositor releases any Surface it currently holds.
+ onSurfaceDestroyed();
+
+ mWindow.close();
+ mWindow.disposeNative();
+ // Can't access the compositor after we dispose of the window
+ mCompositorReady = false;
+ mWindow = null;
+
+ onWindowChanged(WINDOW_CLOSE, /* inProgress */ false);
+ }
+
+ private void onWindowChanged(final int change, final boolean inProgress) {
+ if ((change == WINDOW_OPEN || change == WINDOW_TRANSFER_IN) && !inProgress) {
+ mTextInput.onWindowChanged(mWindow);
+ }
+ if ((change == WINDOW_CLOSE || change == WINDOW_TRANSFER_OUT) && !inProgress) {
+ getAutofillSupport().clear();
+ }
+ }
+
+ /**
+ * Get the SessionTextInput instance for this session. May be called on any thread.
+ *
+ * @return SessionTextInput instance.
+ */
+ @AnyThread
+ public @NonNull SessionTextInput getTextInput() {
+ // May be called on any thread.
+ return mTextInput;
+ }
+
+ /**
+ * Get the SessionAccessibility instance for this session.
+ *
+ * @return SessionAccessibility instance.
+ */
+ @UiThread
+ public @NonNull SessionAccessibility getAccessibility() {
+ ThreadUtils.assertOnUiThread();
+ if (mAccessibility != null) {
+ return mAccessibility;
+ }
+
+ mAccessibility = new SessionAccessibility(this);
+ if (mWindow != null) {
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ mWindow.attachAccessibility(mAccessibility.nativeProvider);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ mWindow,
+ "attachAccessibility",
+ SessionAccessibility.NativeProvider.class,
+ mAccessibility.nativeProvider);
+ }
+ }
+ return mAccessibility;
+ }
+
+ /**
+ * Get the SessionMagnifier instance for this session.
+ *
+ * @return SessionMagnifier instance.
+ */
+ @UiThread
+ /* package */ @NonNull
+ SessionMagnifier getMagnifier() {
+ ThreadUtils.assertOnUiThread();
+ if (mMagnifier == null) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ mMagnifier = new SessionMagnifierP(mCompositor);
+ } else {
+ mMagnifier = new SessionMagnifier() {};
+ }
+ }
+
+ return mMagnifier;
+ }
+
+ // The priority of the GeckoSession, either default or high.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({PRIORITY_DEFAULT, PRIORITY_HIGH})
+ public @interface Priority {}
+
+ /** Value for Priority when it is default. */
+ public static final int PRIORITY_DEFAULT = 0;
+
+ /** Value for Priority when it is high. */
+ public static final int PRIORITY_HIGH = 1;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ LOAD_FLAGS_NONE,
+ LOAD_FLAGS_BYPASS_CACHE,
+ LOAD_FLAGS_BYPASS_PROXY,
+ LOAD_FLAGS_EXTERNAL,
+ LOAD_FLAGS_ALLOW_POPUPS,
+ LOAD_FLAGS_FORCE_ALLOW_DATA_URI,
+ LOAD_FLAGS_REPLACE_HISTORY,
+ 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.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ */
+ public static final int HEADER_FILTER_CORS_SAFELISTED = 1;
+
+ /**
+ * Allows most headers.
+ *
+ * <p>Note: the <code>Host</code> and <code>Connection</code> headers are still ignored.
+ *
+ * <p>This should only be used when input is hard-coded from the app or when properly sanitized,
+ * as some headers could cause unexpected consequences and security issues.
+ *
+ * <p>Only use this if you know what you're doing.
+ */
+ public static final int HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(value = {HEADER_FILTER_CORS_SAFELISTED, HEADER_FILTER_UNRESTRICTED_UNSAFE})
+ public @interface HeaderFilter {}
+
+ /**
+ * Main entry point for loading URIs into a {@link GeckoSession}.
+ *
+ * <p>The simplest use case is loading a URIs with no extra options, this can be accomplished by
+ * specifying the URI in {@link #uri} and then calling {@link #load}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().uri("http://mozilla.org"));
+ * </code></pre>
+ *
+ * This class can also be used to load <code>data:</code> URIs, either from a <code>byte[]</code>
+ * array or a <code>String</code> using {@link #data}, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader().data("the data:1234,5678", "text/plain"));
+ * </code></pre>
+ *
+ * This class also allows you to specify some extra data, e.g. you can set a referrer using {@link
+ * #referrer} which can either be a {@link GeckoSession} or a plain URL string. You can also
+ * specify some Load Flags using {@link #flags}.
+ *
+ * <p>The class is structured as a Builder, so method calls can be easily chained, e.g.
+ *
+ * <pre><code>
+ * session.load(new Loader()
+ * .url("http://mozilla.org")
+ * .referrer("http://my-referrer.com")
+ * .flags(...));
+ * </code></pre>
+ */
+ @AnyThread
+ public static class Loader {
+ private String mUri;
+ private GeckoSession mReferrerSession;
+ private String mReferrerUri;
+ private GeckoBundle mHeaders;
+ private @LoadFlags int mLoadFlags = LOAD_FLAGS_NONE;
+ private boolean mIsDataUri;
+ private @HeaderFilter int mHeaderFilter = HEADER_FILTER_CORS_SAFELISTED;
+
+ private static @NonNull String createDataUri(
+ @NonNull final byte[] bytes, @Nullable final String mimeType) {
+ return String.format(
+ "data:%s;base64,%s",
+ mimeType != null ? mimeType : "", Base64.encodeToString(bytes, Base64.NO_WRAP));
+ }
+
+ private static @NonNull String createDataUri(
+ @NonNull final String data, @Nullable final String mimeType) {
+ return String.format("data:%s,%s", mimeType != null ? mimeType : "", data);
+ }
+
+ @Override
+ public int hashCode() {
+ // Move to Objects.hashCode once our MIN_SDK >= 19
+ return Arrays.hashCode(
+ new Object[] {
+ mUri, mReferrerSession, mReferrerUri, mHeaders, mLoadFlags, mIsDataUri, mHeaderFilter
+ });
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ return Objects.equals(a, b);
+ }
+
+ return (a == b) || (a != null && a.equals(b));
+ }
+
+ @Override
+ public boolean equals(final @Nullable Object obj) {
+ if (!(obj instanceof Loader)) {
+ return false;
+ }
+
+ final Loader other = (Loader) obj;
+ return equals(mUri, other.mUri)
+ && equals(mReferrerSession, other.mReferrerSession)
+ && equals(mReferrerUri, other.mReferrerUri)
+ && equals(mHeaders, other.mHeaders)
+ && equals(mLoadFlags, other.mLoadFlags)
+ && equals(mIsDataUri, other.mIsDataUri)
+ && equals(mHeaderFilter, other.mHeaderFilter);
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a String containg the URI
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull String uri) {
+ mUri = uri;
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the URI of the resource to load.
+ *
+ * @param uri a {@link Uri} instance
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader uri(final @NonNull Uri uri) {
+ mUri = uri.toString();
+ mIsDataUri = false;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param bytes a <code>byte</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull byte[] bytes, final @Nullable String mimeType) {
+ mUri = createDataUri(bytes, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the data URI of the resource to load.
+ *
+ * @param data a <code>String</code> array containing the data to load.
+ * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g.
+ * "text/plain"
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader data(final @NonNull String data, final @Nullable String mimeType) {
+ mUri = createDataUri(data, mimeType);
+ mIsDataUri = true;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrer a <code>GeckoSession</code> that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull GeckoSession referrer) {
+ mReferrerSession = referrer;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a {@link Uri} that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull Uri referrerUri) {
+ mReferrerUri = referrerUri != null ? referrerUri.toString() : null;
+ return this;
+ }
+
+ /**
+ * Set the referrer for this load.
+ *
+ * @param referrerUri a <code>String</code> containing the URI that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader referrer(final @NonNull String referrerUri) {
+ mReferrerUri = referrerUri;
+ return this;
+ }
+
+ /**
+ * Add headers for this load.
+ *
+ * <p>Note: only CORS safelisted headers are allowed by default. To modify this behavior use
+ * {@link #headerFilter}.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header">
+ * CORS-safelisted request header </a>.
+ *
+ * @param headers a <code>Map</code> containing headers that will be added to this load.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader additionalHeaders(final @NonNull Map<String, String> headers) {
+ final GeckoBundle bundle = new GeckoBundle(headers.size());
+ for (final Map.Entry<String, String> entry : headers.entrySet()) {
+ if (entry.getKey() == null) {
+ // Ignore null keys
+ continue;
+ }
+ bundle.putString(entry.getKey(), entry.getValue());
+ }
+ mHeaders = bundle;
+ return this;
+ }
+
+ /**
+ * Modify the header filter behavior. By default only CORS safelisted headers are allowed.
+ *
+ * @param filter one of the {@link GeckoSession#HEADER_FILTER_CORS_SAFELISTED HEADER_FILTER_*}
+ * constants.
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader headerFilter(final @HeaderFilter int filter) {
+ mHeaderFilter = filter;
+ return this;
+ }
+
+ /**
+ * Set the load flags for this load.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ * that will be used as the referrer
+ * @return this {@link Loader} instance.
+ */
+ @NonNull
+ public Loader flags(final @LoadFlags int flags) {
+ mLoadFlags = flags;
+ return this;
+ }
+ }
+
+ /**
+ * Load page using the {@link Loader} specified.
+ *
+ * @param request Loader for this request.
+ * @see Loader
+ */
+ @AnyThread
+ public void load(final @NonNull Loader request) {
+ if (request.mUri == null) {
+ throw new IllegalArgumentException(
+ "You need to specify at least one between `uri` and `data`.");
+ }
+
+ if (request.mReferrerUri != null && request.mReferrerSession != null) {
+ throw new IllegalArgumentException(
+ "Cannot specify both a referrer session and a referrer URI.");
+ }
+
+ final NavigationDelegate navDelegate = mNavigationHandler.getDelegate();
+ final boolean isDataUriTooLong = !maybeCheckDataUriLength(request);
+ if (navDelegate == null && isDataUriTooLong) {
+ throw new IllegalArgumentException("data URI is too long");
+ }
+
+ final int loadFlags =
+ request.mIsDataUri
+ // If this is a data: load then we need to force allow it.
+ ? request.mLoadFlags | LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+ : request.mLoadFlags;
+
+ // For performance reasons we short-circuit the delegate here
+ // instead of making Gecko call it for direct load calls.
+ final NavigationDelegate.LoadRequest loadRequest =
+ new NavigationDelegate.LoadRequest(
+ request.mUri,
+ null, /* triggerUri */
+ 1, /* geckoTarget: OPEN_CURRENTWINDOW */
+ 0, /* flags */
+ false, /* hasUserGesture */
+ true /* isDirectNavigation */);
+
+ shouldLoadUri(loadRequest, 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.
+ *
+ * <p>Convenience method for
+ *
+ * <pre><code>
+ * session.load(new Loader().uri(uri));
+ * </code></pre>
+ *
+ * @param uri The URI of the resource to load.
+ */
+ @AnyThread
+ public void loadUri(final @NonNull String uri) {
+ load(new Loader().uri(uri));
+ }
+
+ private GeckoResult<AllowOrDeny> shouldLoadUri(
+ final NavigationDelegate.LoadRequest request, final 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<AllowOrDeny> result = new GeckoResult<>(ThreadUtils.getUiHandler());
+
+ ThreadUtils.runOnUiThread(
+ () -> {
+ final GeckoResult<AllowOrDeny> delegateResult = delegate.onLoadRequest(this, request);
+
+ if (delegateResult == null) {
+ result.complete(AllowOrDeny.ALLOW);
+ } else {
+ delegateResult.getOrAccept(
+ allowOrDeny -> result.complete(allowOrDeny),
+ error -> result.completeExceptionally(error));
+ }
+ });
+
+ return result;
+ }
+
+ /** Reload the current URI. */
+ @AnyThread
+ public void reload() {
+ reload(LOAD_FLAGS_NONE);
+ }
+
+ /**
+ * Reload the current URI.
+ *
+ * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*}
+ */
+ @AnyThread
+ public void reload(final @LoadFlags int flags) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putInt("flags", flags);
+ mEventDispatcher.dispatch("GeckoView:Reload", msg);
+ }
+
+ /** Stop loading. */
+ @AnyThread
+ public void stop() {
+ mEventDispatcher.dispatch("GeckoView:Stop", null);
+ }
+
+ /**
+ * Go back in history and assumes the call was based on a user interaction.
+ *
+ * @see #goBack(boolean)
+ */
+ @AnyThread
+ public void goBack() {
+ goBack(true);
+ }
+
+ /**
+ * Go back in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goBack(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoBack", msg);
+ }
+
+ /**
+ * Go forward in history and assumes the call was based on a user interaction.
+ *
+ * @see #goForward(boolean)
+ */
+ @AnyThread
+ public void goForward() {
+ goForward(true);
+ }
+
+ /**
+ * Go forward in history.
+ *
+ * @param userInteraction Whether the action was invoked by a user interaction.
+ */
+ @AnyThread
+ public void goForward(final boolean userInteraction) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putBoolean("userInteraction", userInteraction);
+ mEventDispatcher.dispatch("GeckoView:GoForward", msg);
+ }
+
+ /**
+ * Navigate to an index in browser history; the index of the currently viewed page can be
+ * retrieved from an up-to-date HistoryList by calling {@link
+ * HistoryDelegate.HistoryList#getCurrentIndex()}.
+ *
+ * @param index The index of the location in browser history you want to navigate to.
+ */
+ @AnyThread
+ public void gotoHistoryIndex(final int index) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("index", index);
+ mEventDispatcher.dispatch("GeckoView:GotoHistoryIndex", msg);
+ }
+
+ /**
+ * Returns a WebExtensionController for this GeckoSession. Delegates attached to this controller
+ * will receive events specific to this session.
+ *
+ * @return an instance of {@link WebExtension.SessionController}.
+ */
+ @UiThread
+ public @NonNull WebExtension.SessionController getWebExtensionController() {
+ return mWebExtensionController;
+ }
+
+ /**
+ * Purge history for the session. The session history is used for back and forward history.
+ * Purging the session history means {@link NavigationDelegate#onCanGoBack(GeckoSession, boolean)}
+ * and {@link NavigationDelegate#onCanGoForward(GeckoSession, boolean)} will be false.
+ */
+ @AnyThread
+ public void purgeHistory() {
+ mEventDispatcher.dispatch("GeckoView:PurgeHistory", null);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_FIND_BACKWARDS,
+ FINDER_FIND_LINKS_ONLY,
+ FINDER_FIND_MATCH_CASE,
+ FINDER_FIND_WHOLE_WORD
+ })
+ public @interface FinderFindFlags {}
+
+ /** Go backwards when finding the next match. */
+ public static final int FINDER_FIND_BACKWARDS = 1;
+
+ /** Perform case-sensitive match; default is to perform a case-insensitive match. */
+ public static final int FINDER_FIND_MATCH_CASE = 1 << 1;
+
+ /** Must match entire words; default is to allow matching partial words. */
+ public static final int FINDER_FIND_WHOLE_WORD = 1 << 2;
+
+ /** Limit matches to links on the page. */
+ public static final int FINDER_FIND_LINKS_ONLY = 1 << 3;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ FINDER_DISPLAY_HIGHLIGHT_ALL,
+ FINDER_DISPLAY_DIM_PAGE,
+ FINDER_DISPLAY_DRAW_LINK_OUTLINE
+ })
+ public @interface FinderDisplayFlags {}
+
+ /** Highlight all find-in-page matches. */
+ public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1;
+
+ /** Dim the rest of the page when showing a find-in-page match. */
+ public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1;
+
+ /** Draw outlines around matching links. */
+ public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2;
+
+ /** Represent the result of a find-in-page operation. */
+ @AnyThread
+ public static class FinderResult {
+ /** Whether a match was found. */
+ public final boolean found;
+
+ /** Whether the search wrapped around the top or bottom of the page. */
+ public final boolean wrapped;
+
+ /** Ordinal number of the current match starting from 1, or 0 if no match. */
+ public final int current;
+
+ /** Total number of matches found so far, or -1 if unknown. */
+ public final int total;
+
+ /** Search string. */
+ @NonNull public final String searchString;
+
+ /**
+ * Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS
+ * FINDER_FIND_*} flags.
+ */
+ @FinderFindFlags public final int flags;
+
+ /** URI of the link, if the current match is a link, or null otherwise. */
+ @Nullable public final String linkUri;
+
+ /** Bounds of the current match in client coordinates, or null if unknown. */
+ @Nullable public final RectF clientRect;
+
+ /* package */ FinderResult(@NonNull final GeckoBundle bundle) {
+ found = bundle.getBoolean("found");
+ wrapped = bundle.getBoolean("wrapped");
+ current = bundle.getInt("current", 0);
+ total = bundle.getInt("total", -1);
+ searchString = bundle.getString("searchString");
+ flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags"));
+ linkUri = bundle.getString("linkURL");
+ clientRect = bundle.getRectF("clientRect");
+ }
+
+ /** Empty constructor for tests */
+ protected FinderResult() {
+ found = false;
+ wrapped = false;
+ current = 0;
+ total = 0;
+ flags = 0;
+ searchString = "";
+ linkUri = "";
+ clientRect = null;
+ }
+ }
+
+ /**
+ * Get the SessionFinder instance for this session, to perform find-in-page operations.
+ *
+ * @return SessionFinder instance.
+ */
+ @AnyThread
+ public @NonNull SessionFinder getFinder() {
+ if (mFinder == null) {
+ mFinder = new SessionFinder(getEventDispatcher());
+ }
+ return mFinder;
+ }
+
+ /**
+ * 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<Boolean> 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<Boolean> 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.
+ *
+ * <p>Set this GeckoSession to high priority (PRIORITY_HIGH) whenever the app wants to signal to
+ * GeckoView that this GeckoSession is important to the app. GeckoView will keep the session state
+ * as long as possible. Set this to default priority (PRIORITY_DEFAULT) in any other case.
+ *
+ * @param priorityHint Priority of the geckosession, either high priority or default.
+ */
+ @AnyThread
+ public void setPriorityHint(final @Priority int priorityHint) {
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putInt("priorityHint", priorityHint);
+ mEventDispatcher.dispatch("GeckoView:SetPriorityHint", msg);
+ }
+
+ /** Class representing a saved session state. */
+ @AnyThread
+ public static class SessionState extends AbstractSequentialList<HistoryDelegate.HistoryItem>
+ implements HistoryDelegate.HistoryList, Parcelable {
+ private GeckoBundle mState;
+
+ private class SessionStateItem implements HistoryDelegate.HistoryItem {
+ private final GeckoBundle mItem;
+
+ private SessionStateItem(final @NonNull GeckoBundle item) {
+ mItem = item;
+ }
+
+ @Override /* HistoryItem */
+ public String getUri() {
+ return mItem.getString("url");
+ }
+
+ @Override /* HistoryItem */
+ public String getTitle() {
+ return mItem.getString("title");
+ }
+ }
+
+ private class SessionStateIterator implements ListIterator<HistoryDelegate.HistoryItem> {
+ private final SessionState mState;
+ private int mIndex;
+
+ private SessionStateIterator(final @NonNull SessionState state) {
+ this(state, 0);
+ }
+
+ private SessionStateIterator(final @NonNull SessionState state, final int index) {
+ mIndex = index;
+ mState = state;
+ }
+
+ @Override /* ListIterator */
+ public void add(final HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public boolean hasNext() {
+ final GeckoBundle[] entries = mState.getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return false;
+ }
+
+ if (mIndex >= mState.getHistoryEntries().length) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override /* ListIterator */
+ public boolean hasPrevious() {
+ if (mIndex <= 0) {
+ return false;
+ }
+ return true;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem next() {
+ if (hasNext()) {
+ mIndex++;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex - 1]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int nextIndex() {
+ return mIndex;
+ }
+
+ @Override /* ListIterator */
+ public HistoryDelegate.HistoryItem previous() {
+ if (hasPrevious()) {
+ mIndex--;
+ return new SessionStateItem(mState.getHistoryEntries()[mIndex]);
+ } else {
+ throw new NoSuchElementException();
+ }
+ }
+
+ @Override /* ListIterator */
+ public int previousIndex() {
+ return mIndex - 1;
+ }
+
+ @Override /* ListIterator */
+ public void remove() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override /* ListIterator */
+ public void set(final @NonNull HistoryDelegate.HistoryItem item) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private SessionState() {
+ mState = new GeckoBundle(3);
+ }
+
+ private SessionState(final @NonNull GeckoBundle state) {
+ mState = new GeckoBundle(state);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public SessionState(final @NonNull SessionState state) {
+ mState = new GeckoBundle(state.mState);
+ }
+
+ /* package */ void updateSessionState(final @NonNull GeckoBundle updateData) {
+ if (updateData == null) {
+ Log.w(LOGTAG, "Session state update has no data field.");
+ return;
+ }
+
+ final GeckoBundle history = updateData.getBundle("historychange");
+ final GeckoBundle scroll = updateData.getBundle("scroll");
+ final GeckoBundle formdata = updateData.getBundle("formdata");
+
+ if (history != null) {
+ mState.putBundle("history", history);
+ }
+
+ if (scroll != null) {
+ mState.putBundle("scrolldata", scroll);
+ }
+
+ if (formdata != null) {
+ mState.putBundle("formdata", formdata);
+ }
+
+ return;
+ }
+
+ @Override
+ public int hashCode() {
+ return mState.hashCode();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (other == null || !(other instanceof SessionState)) {
+ return false;
+ }
+
+ final SessionState otherState = (SessionState) other;
+
+ return this.mState.equals(otherState.mState);
+ }
+
+ /**
+ * Creates a new SessionState instance from a value previously returned by {@link #toString()}.
+ *
+ * @param value The serialized SessionState in String form.
+ * @return A new SessionState instance if input is valid; otherwise null.
+ */
+ public static @Nullable SessionState fromString(final @Nullable String value) {
+ final GeckoBundle bundleState;
+ try {
+ bundleState = GeckoBundle.fromJSONObject(new JSONObject(value));
+ } catch (final Exception e) {
+ Log.e(LOGTAG, "String does not represent valid session state.");
+ return null;
+ }
+
+ if (bundleState == null) {
+ return null;
+ }
+
+ return new SessionState(bundleState);
+ }
+
+ @Override
+ public @Nullable String toString() {
+ if (mState == null) {
+ Log.w(LOGTAG, "Can't convert SessionState with null state to string");
+ return null;
+ }
+
+ String res;
+ try {
+ res = mState.toJSONObject().toString();
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert session state to string.");
+ res = null;
+ }
+
+ return res;
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeString(toString());
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ if (source.readString() == null) {
+ Log.w(LOGTAG, "Can't reproduce session state from Parcel");
+ }
+
+ try {
+ mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert string to session state.");
+ mState = null;
+ }
+ }
+
+ public static final Parcelable.Creator<SessionState> CREATOR =
+ new Parcelable.Creator<SessionState>() {
+ @Override
+ public SessionState createFromParcel(final Parcel source) {
+ if (source.readString() == null) {
+ Log.w(LOGTAG, "Can't create session state from Parcel");
+ }
+
+ GeckoBundle res;
+ try {
+ res = GeckoBundle.fromJSONObject(new JSONObject(source.readString()));
+ } catch (final JSONException e) {
+ Log.e(LOGTAG, "Could not convert parcel to session state.");
+ res = null;
+ }
+
+ return new SessionState(res);
+ }
+
+ @Override
+ public SessionState[] newArray(final int size) {
+ return new SessionState[size];
+ }
+ };
+
+ @Override /* AbstractSequentialList */
+ public @NonNull HistoryDelegate.HistoryItem get(final int index) {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null || index < 0 || index >= entries.length) {
+ throw new NoSuchElementException();
+ }
+
+ return new SessionStateItem(entries[index]);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull Iterator<HistoryDelegate.HistoryItem> iterator() {
+ return listIterator(0);
+ }
+
+ @Override /* AbstractSequentialList */
+ public @NonNull ListIterator<HistoryDelegate.HistoryItem> listIterator(final int index) {
+ return new SessionStateIterator(this, index);
+ }
+
+ @Override /* AbstractSequentialList */
+ public int size() {
+ final GeckoBundle[] entries = getHistoryEntries();
+
+ if (entries == null) {
+ Log.w(LOGTAG, "No history entries found.");
+ return 0;
+ }
+
+ return entries.length;
+ }
+
+ @Override /* HistoryList */
+ public int getCurrentIndex() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ throw new IllegalStateException("No history state exists.");
+ }
+
+ return history.getInt("index") + history.getInt("fromIdx");
+ }
+
+ // Some helpers for common code.
+ private GeckoBundle getHistory() {
+ if (mState == null) {
+ return null;
+ }
+
+ return mState.getBundle("history");
+ }
+
+ private GeckoBundle[] getHistoryEntries() {
+ final GeckoBundle history = getHistory();
+
+ if (history == null) {
+ return null;
+ }
+
+ return history.getBundleArray("entries");
+ }
+ }
+
+ private SessionState mStateCache = new SessionState();
+
+ /**
+ * Restore a saved state to this GeckoSession; only data that is saved (history, scroll position,
+ * zoom, and form data) will be restored. These will overwrite the corresponding state of this
+ * GeckoSession.
+ *
+ * @param state A saved session state; this should originate from onSessionStateChange().
+ */
+ @AnyThread
+ public void restoreState(final @NonNull SessionState state) {
+ mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState);
+ }
+
+ /**
+ * Get whether this GeckoSession has form data.
+ *
+ * @return a {@link GeckoResult} result of if there is existing form data.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Boolean> containsFormData() {
+ return mEventDispatcher.queryBoolean("GeckoView:ContainsFormData");
+ }
+
+ // This is the GeckoDisplay acquired via acquireDisplay(), if any.
+ private GeckoDisplay mDisplay;
+
+ /* package */ interface Owner {
+ void onRelease();
+ }
+
+ private static final WeakReference<Owner> NO_OWNER = new WeakReference<>(null);
+ private WeakReference<Owner> mOwner = NO_OWNER;
+
+ @UiThread
+ /* package */ void releaseOwner() {
+ ThreadUtils.assertOnUiThread();
+ mOwner = NO_OWNER;
+ }
+
+ @UiThread
+ /* package */ void setOwner(final Owner owner) {
+ ThreadUtils.assertOnUiThread();
+ final Owner oldOwner = mOwner.get();
+ if (oldOwner != null && owner != oldOwner) {
+ oldOwner.onRelease();
+ }
+ mOwner = new WeakReference<>(owner);
+ }
+
+ /* package */ GeckoDisplay getDisplay() {
+ return mDisplay;
+ }
+
+ /**
+ * Acquire the GeckoDisplay instance for providing the session with a drawing Surface. Be sure to
+ * call {@link GeckoDisplay#surfaceChanged(SurfaceInfo)} on the acquired display if there is
+ * already a valid Surface.
+ *
+ * @return GeckoDisplay instance.
+ * @see #releaseDisplay(GeckoDisplay)
+ */
+ @UiThread
+ public @NonNull GeckoDisplay acquireDisplay() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mDisplay != null) {
+ throw new IllegalStateException("Display already acquired");
+ }
+
+ mDisplay = new GeckoDisplay(this);
+ return mDisplay;
+ }
+
+ /**
+ * Release an acquired GeckoDisplay instance. Be sure to call {@link
+ * GeckoDisplay#surfaceDestroyed()} before releasing the display if it still has a valid Surface.
+ *
+ * @param display Acquired GeckoDisplay instance.
+ * @see #acquireDisplay()
+ */
+ @UiThread
+ public void releaseDisplay(final @NonNull GeckoDisplay display) {
+ ThreadUtils.assertOnUiThread();
+
+ if (display != mDisplay) {
+ throw new IllegalArgumentException("Display not attached");
+ }
+
+ mDisplay = null;
+ }
+
+ @AnyThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @NonNull GeckoSessionSettings getSettings() {
+ return mSettings;
+ }
+
+ /** Exits fullscreen mode */
+ @AnyThread
+ public void exitFullScreen() {
+ mEventDispatcher.dispatch("GeckoViewContent:ExitFullScreen", null);
+ }
+
+ /**
+ * Set the content callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ContentDelegate.
+ */
+ @UiThread
+ public void setContentDelegate(final @Nullable ContentDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mContentHandler.setDelegate(delegate, this);
+ mProcessHangHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content callback handler.
+ *
+ * @return The current content callback handler.
+ */
+ @UiThread
+ public @Nullable ContentDelegate getContentDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mContentHandler.getDelegate();
+ }
+
+ /**
+ * Set the progress callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ProgressDelegate.
+ */
+ @UiThread
+ public void setProgressDelegate(final @Nullable ProgressDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mProgressHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the progress callback handler.
+ *
+ * @return The current progress callback handler.
+ */
+ @UiThread
+ public @Nullable ProgressDelegate getProgressDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mProgressHandler.getDelegate();
+ }
+
+ /**
+ * Set the navigation callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of NavigationDelegate.
+ */
+ @UiThread
+ public void setNavigationDelegate(final @Nullable NavigationDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mNavigationHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the navigation callback handler.
+ *
+ * @return The current navigation callback handler.
+ */
+ @UiThread
+ public @Nullable NavigationDelegate getNavigationDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mNavigationHandler.getDelegate();
+ }
+
+ /**
+ * Set the content scroll callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of ScrollDelegate.
+ */
+ @UiThread
+ public void setScrollDelegate(final @Nullable ScrollDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mScrollHandler.setDelegate(delegate, this);
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public @Nullable ScrollDelegate getScrollDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mScrollHandler.getDelegate();
+ }
+
+ /**
+ * Set the history tracking delegate for this session, replacing the current delegate if one is
+ * set.
+ *
+ * @param delegate The history tracking delegate, or {@code null} to unset.
+ */
+ @AnyThread
+ public void setHistoryDelegate(final @Nullable HistoryDelegate delegate) {
+ mHistoryHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * @return The history tracking delegate for this session.
+ */
+ @AnyThread
+ public @Nullable HistoryDelegate getHistoryDelegate() {
+ return mHistoryHandler.getDelegate();
+ }
+
+ /**
+ * Set the content blocking callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link ContentBlocking.Delegate}.
+ */
+ @AnyThread
+ public void setContentBlockingDelegate(final @Nullable ContentBlocking.Delegate delegate) {
+ mContentBlockingHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the content blocking callback handler.
+ *
+ * @return The current content blocking callback handler.
+ */
+ @AnyThread
+ public @Nullable ContentBlocking.Delegate getContentBlockingDelegate() {
+ return mContentBlockingHandler.getDelegate();
+ }
+
+ /**
+ * Set the current prompt delegate for this GeckoSession.
+ *
+ * @param delegate PromptDelegate instance or null to use the built-in delegate.
+ */
+ @AnyThread
+ public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
+ mPromptDelegate = delegate;
+ }
+
+ /**
+ * Get the current prompt delegate for this GeckoSession.
+ *
+ * @return PromptDelegate instance or null if using built-in delegate.
+ */
+ @AnyThread
+ public @Nullable PromptDelegate getPromptDelegate() {
+ return mPromptDelegate;
+ }
+
+ /**
+ * Set the current selection action delegate for this GeckoSession.
+ *
+ * @param delegate SelectionActionDelegate instance or null to unset.
+ */
+ @UiThread
+ public void setSelectionActionDelegate(final @Nullable SelectionActionDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+
+ if (getSelectionActionDelegate() != null) {
+ // When the delegate is changed or cleared, make sure onHideAction is called
+ // one last time to hide any existing selection action UI. Gecko doesn't keep
+ // track of the old delegate, so we can't rely on Gecko to do that for us.
+ getSelectionActionDelegate()
+ .onHideAction(this, GeckoSession.SelectionActionDelegate.HIDE_REASON_NO_SELECTION);
+ }
+ mSelectionActionDelegate.setDelegate(delegate, this);
+ }
+
+ /**
+ * Set the media callback handler. This will replace the current handler.
+ *
+ * @param delegate An implementation of MediaDelegate.
+ */
+ @AnyThread
+ public void setMediaDelegate(final @Nullable MediaDelegate delegate) {
+ mMediaHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the Media callback handler.
+ *
+ * @return The current Media callback handler.
+ */
+ @AnyThread
+ public @Nullable MediaDelegate getMediaDelegate() {
+ return mMediaHandler.getDelegate();
+ }
+
+ /**
+ * Set the media session delegate. This will replace the current handler.
+ *
+ * @param delegate An implementation of {@link MediaSession.Delegate}.
+ */
+ @AnyThread
+ public void setMediaSessionDelegate(final @Nullable MediaSession.Delegate delegate) {
+ mMediaSessionHandler.setDelegate(delegate, this);
+ }
+
+ /**
+ * Get the media session delegate.
+ *
+ * @return The current media session delegate.
+ */
+ @AnyThread
+ public @Nullable MediaSession.Delegate getMediaSessionDelegate() {
+ return mMediaSessionHandler.getDelegate();
+ }
+
+ /**
+ * Get the current selection action delegate for this GeckoSession.
+ *
+ * @return SelectionActionDelegate instance or null if not set.
+ */
+ @AnyThread
+ public @Nullable SelectionActionDelegate getSelectionActionDelegate() {
+ return mSelectionActionDelegate.getDelegate();
+ }
+
+ @UiThread
+ protected void setShouldPinOnScreen(final boolean pinned) {
+ if (DEBUG) {
+ ThreadUtils.assertOnUiThread();
+ }
+
+ mShouldPinOnScreen = pinned;
+ }
+
+ /* package */ boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+ return mShouldPinOnScreen;
+ }
+
+ @AnyThread
+ /* package */ @NonNull
+ EventDispatcher getEventDispatcher() {
+ return mEventDispatcher;
+ }
+
+ public interface ProgressDelegate {
+ /** Class representing security information for a site. */
+ public class SecurityInformation {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({SECURITY_MODE_UNKNOWN, SECURITY_MODE_IDENTIFIED, SECURITY_MODE_VERIFIED})
+ public @interface SecurityMode {}
+
+ public static final int SECURITY_MODE_UNKNOWN = 0;
+ public static final int SECURITY_MODE_IDENTIFIED = 1;
+ public static final int SECURITY_MODE_VERIFIED = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({CONTENT_UNKNOWN, CONTENT_BLOCKED, CONTENT_LOADED})
+ public @interface ContentType {}
+
+ public static final int CONTENT_UNKNOWN = 0;
+ public static final int CONTENT_BLOCKED = 1;
+ public static final int CONTENT_LOADED = 2;
+
+ /** Indicates whether or not the site is secure. */
+ public final boolean isSecure;
+
+ /** Indicates whether or not the site is a security exception. */
+ public final boolean isException;
+
+ /** Contains the origin of the certificate. */
+ public final @Nullable String origin;
+
+ /** Contains the host associated with the certificate. */
+ public final @NonNull String host;
+
+ /** The server certificate in use, if any. */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Indicates the security level of the site; possible values are SECURITY_MODE_UNKNOWN,
+ * SECURITY_MODE_IDENTIFIED, and SECURITY_MODE_VERIFIED. SECURITY_MODE_IDENTIFIED indicates
+ * domain validation only, while SECURITY_MODE_VERIFIED indicates extended validation.
+ */
+ public final @SecurityMode int securityMode;
+
+ /**
+ * Indicates the presence of passive mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModePassive;
+
+ /**
+ * Indicates the presence of active mixed content; possible values are CONTENT_UNKNOWN,
+ * CONTENT_BLOCKED, and CONTENT_LOADED.
+ */
+ public final @ContentType int mixedModeActive;
+
+ /* package */ SecurityInformation(final GeckoBundle identityData) {
+ final GeckoBundle mode = identityData.getBundle("mode");
+
+ mixedModePassive = mode.getInt("mixed_display");
+ mixedModeActive = mode.getInt("mixed_active");
+
+ securityMode = mode.getInt("identity");
+
+ isSecure = identityData.getBoolean("secure");
+ isException = identityData.getBoolean("securityException");
+ origin = identityData.getString("origin");
+ host = identityData.getString("host");
+
+ X509Certificate decodedCert = null;
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ final String certString = identityData.getString("certificate");
+ if (certString != null) {
+ final byte[] certBytes = Base64.decode(certString, Base64.NO_WRAP);
+ decodedCert =
+ (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes));
+ }
+ } catch (final CertificateException e) {
+ Log.e(LOGTAG, "Failed to decode certificate", e);
+ }
+
+ certificate = decodedCert;
+ }
+
+ /** Empty constructor for tests */
+ protected SecurityInformation() {
+ mixedModePassive = CONTENT_UNKNOWN;
+ mixedModeActive = CONTENT_UNKNOWN;
+ securityMode = SECURITY_MODE_UNKNOWN;
+ isSecure = false;
+ isException = false;
+ origin = "";
+ host = "";
+ certificate = null;
+ }
+ }
+
+ /**
+ * A View has started loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ */
+ @UiThread
+ default void onPageStart(@NonNull final GeckoSession session, @NonNull final String url) {}
+
+ /**
+ * A View has finished loading content from the network.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param success Whether the page loaded successfully or an error occurred.
+ */
+ @UiThread
+ default void onPageStop(@NonNull final GeckoSession session, final boolean success) {}
+
+ /**
+ * Page loading has progressed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param progress Current page load progress value [0, 100].
+ */
+ @UiThread
+ default void onProgressChange(@NonNull final GeckoSession session, final int progress) {}
+
+ /**
+ * The security status has been updated.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param securityInfo The new security information.
+ */
+ @UiThread
+ default void onSecurityChange(
+ @NonNull final GeckoSession session, @NonNull final SecurityInformation securityInfo) {}
+
+ /**
+ * The browser session state has changed. This can happen in response to navigation, scrolling,
+ * or form data changes; the session state passed includes the most up to date information on
+ * all of these.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param sessionState SessionState representing the latest browser state.
+ */
+ @UiThread
+ default void onSessionStateChange(
+ @NonNull final GeckoSession session, @NonNull final SessionState sessionState) {}
+ }
+
+ /** WebResponseInfo contains information about a single web response. */
+ @AnyThread
+ public static class WebResponseInfo {
+ /** The URI of the response. Cannot be null. */
+ @NonNull public final String uri;
+
+ /** The content type (mime type) of the response. May be null. */
+ @Nullable public final String contentType;
+
+ /** The content length of the response. May be 0 if unknokwn. */
+ @Nullable public final long contentLength;
+
+ /** The filename obtained from the content disposition, if any. May be null. */
+ @Nullable public final String filename;
+
+ /* package */ WebResponseInfo(final GeckoBundle message) {
+ uri = message.getString("uri");
+ if (uri == null) {
+ throw new IllegalArgumentException("URI cannot be null");
+ }
+
+ contentType = message.getString("contentType");
+ contentLength = message.getLong("contentLength");
+ filename = message.getString("filename");
+ }
+
+ /** Empty constructor for tests. */
+ protected WebResponseInfo() {
+ uri = "";
+ contentType = "";
+ contentLength = 0;
+ filename = "";
+ }
+ }
+
+ public interface ContentDelegate {
+ /**
+ * A page title was discovered in the content or updated after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param title The title sent from the content.
+ */
+ @UiThread
+ default void onTitleChange(@NonNull final GeckoSession session, @Nullable final String title) {}
+
+ /**
+ * A preview image was discovered in the content after the content loaded.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param previewImageUrl The preview image URL sent from the content.
+ */
+ @UiThread
+ default void onPreviewImage(
+ @NonNull final GeckoSession session, @NonNull final String previewImageUrl) {}
+
+ /**
+ * A page has requested focus. Note that window.focus() in content will not result in this being
+ * called.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onFocusRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to close
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onCloseRequest(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has entered or exited full screen mode. Typically, the implementation would set the
+ * Activity containing the GeckoSession to full screen when the page is in full screen mode.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param fullScreen True if the page is in full screen mode.
+ */
+ @UiThread
+ default void onFullScreen(@NonNull final GeckoSession session, final boolean fullScreen) {}
+
+ /**
+ * A viewport-fit was discovered in the content or updated after the content.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param viewportFit The value of viewport-fit of meta element in content.
+ * @see <a href="https://drafts.csswg.org/css-round-display/#viewport-fit-descriptor">4.1. The
+ * viewport-fit descriptor</a>
+ */
+ @UiThread
+ default void onMetaViewportFitChange(
+ @NonNull final GeckoSession session, @NonNull final String viewportFit) {}
+
+ /** Element details for onContextMenu callbacks. */
+ public static class ContextElement {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ public static final int TYPE_NONE = 0;
+ public static final int TYPE_IMAGE = 1;
+ public static final int TYPE_VIDEO = 2;
+ public static final int TYPE_AUDIO = 3;
+
+ /** The base URI of the element's document. */
+ public final @Nullable String baseUri;
+
+ /** The absolute link URI (href) of the element. */
+ public final @Nullable String linkUri;
+
+ /** The title text of the element. */
+ public final @Nullable String title;
+
+ /** The alternative text (alt) for the element. */
+ public final @Nullable String altText;
+
+ /** The type of the element. One of the {@link ContextElement#TYPE_NONE} flags. */
+ public final @Type int type;
+
+ /** The source URI (src) of the element. Set for (nested) media elements. */
+ public final @Nullable String srcUri;
+
+ /** The text content of the element */
+ public final @Nullable String textContent;
+
+ // TODO: Bug 1595822 make public
+ final List<WebExtension.Menu> 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.
+ *
+ * <p>This callback is invoked whenever the painted content is no longer being displayed. This
+ * can occur in response to the session being paused. After this has fired the compositor may
+ * continue rendering, but may not render the page content. This callback can therefore be used
+ * in conjunction with {@link #onFirstContentfulPaint(GeckoSession)} to determine when there is
+ * valid content being rendered.
+ *
+ * @param session The GeckoSession that had the paint status reset event.
+ */
+ @UiThread
+ default void onPaintStatusReset(@NonNull final GeckoSession session) {}
+
+ /**
+ * A page has requested to change pointer icon.
+ *
+ * <p>If the application wants to control pointer icon, it should override this, then handle it.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param icon The pointer icon sent from the content.
+ */
+ @TargetApi(Build.VERSION_CODES.N)
+ @UiThread
+ default void onPointerIconChange(
+ @NonNull final GeckoSession session, @NonNull final PointerIcon icon) {
+ final View view = session.getTextInput().getView();
+ if (view != null) {
+ view.setPointerIcon(icon);
+ }
+ }
+
+ /**
+ * This is fired when the loaded document has a valid Web App Manifest present.
+ *
+ * <p>The various colors (theme_color, background_color, etc.) present in the manifest have been
+ * transformed into #AARRGGBB format.
+ *
+ * @param session The GeckoSession that contains the Web App Manifest
+ * @param manifest A parsed and validated {@link JSONObject} containing the manifest contents.
+ * @see <a href="https://www.w3.org/TR/appmanifest/">Web App Manifest specification</a>
+ */
+ @UiThread
+ default void onWebAppManifest(
+ @NonNull final GeckoSession session, @NonNull final JSONObject manifest) {}
+
+ /**
+ * A script has exceeded its execution timeout value
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ * @param scriptFileName Filename of the slow script
+ * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to
+ * allow the Slow Script to continue processing. Stop will halt the slow script. Continue
+ * will pause notifications for a period of time before resuming.
+ */
+ @UiThread
+ default @Nullable GeckoResult<SlowScriptResponse> onSlowScript(
+ @NonNull final GeckoSession geckoSession, @NonNull final String scriptFileName) {
+ return null;
+ }
+
+ /**
+ * The app should display its dynamic toolbar, fully expanded to the height that was previously
+ * specified via {@link GeckoView#setDynamicToolbarMaxHeight}.
+ *
+ * @param geckoSession GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onShowDynamicToolbar(@NonNull final GeckoSession geckoSession) {}
+
+ /**
+ * This method is called when a cookie banner was detected.
+ *
+ * <p>Note: this method is called only if the cookie banner setting is such that allows to
+ * handle the banner. For example, if cookiebanners.service.mode=1 (Reject only) but a cookie
+ * banner can only be accepted on the website - the detection in that case won't be reported.
+ * The exception is MODE_DETECT_ONLY mode, when only the detection event is emitted.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerDetected(@NonNull final GeckoSession session) {}
+
+ /**
+ * This method is called when a cookie banner was handled.
+ *
+ * @param session GeckoSession that initiated the callback.
+ */
+ @AnyThread
+ default void onCookieBannerHandled(@NonNull final GeckoSession session) {}
+
+ /**
+ * 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<String> availableActions;
+
+ private final String mActionId;
+
+ private final WeakReference<EventDispatcher> mEventDispatcher;
+
+ /* package */ Selection(
+ final GeckoBundle bundle,
+ final @NonNull @SelectionActionDelegateAction Set<String> actions,
+ final EventDispatcher eventDispatcher) {
+ flags =
+ (bundle.getBoolean("collapsed") ? SelectionActionDelegate.FLAG_IS_COLLAPSED : 0)
+ | (bundle.getBoolean("editable") ? SelectionActionDelegate.FLAG_IS_EDITABLE : 0)
+ | (bundle.getBoolean("password") ? SelectionActionDelegate.FLAG_IS_PASSWORD : 0);
+ text = bundle.getString("selection");
+ 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.
+ *
+ * <p>In response to this callback, applications typically display a toolbar containing the
+ * selection actions. To perform a certain action, check if the action is available with {@link
+ * Selection#isActionAvailable} then either use the relevant helper method or {@link
+ * Selection#execute}
+ *
+ * <p>Once an {@link #onHideAction} call (with particular reasons) or another {@link
+ * #onShowActionRequest} call is received, the previous Selection object is no longer usable.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param selection Current selection attributes and Callback object for performing built-in
+ * actions. May be used multiple times to perform multiple actions at once.
+ */
+ @UiThread
+ default void onShowActionRequest(
+ @NonNull final GeckoSession session, @NonNull final Selection selection) {}
+
+ /** Actions are no longer available due to the user clearing the selection. */
+ final int HIDE_REASON_NO_SELECTION = 0;
+
+ /**
+ * Actions are no longer available due to the user moving the selection out of view. Previous
+ * actions are still available after a callback with this reason.
+ */
+ final int HIDE_REASON_INVISIBLE_SELECTION = 1;
+
+ /**
+ * Actions are no longer available due to the user actively changing the selection. {@link
+ * #onShowActionRequest} may be called again once the user has set a selection, if the new
+ * selection has available actions.
+ */
+ final int HIDE_REASON_ACTIVE_SELECTION = 2;
+
+ /**
+ * Actions are no longer available due to the user actively scrolling the page. {@link
+ * #onShowActionRequest} may be called again once the user has stopped scrolling the page, if
+ * the selection is still visible. Until then, previous actions are still available after a
+ * callback with this reason.
+ */
+ final int HIDE_REASON_ACTIVE_SCROLL = 3;
+
+ /**
+ * Previous actions are no longer available due to the user interacting with the page.
+ * Applications typically hide the action toolbar in response.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param reason The reason that actions are no longer available, as one of the {@link
+ * #HIDE_REASON_NO_SELECTION HIDE_REASON_*} constants.
+ */
+ @UiThread
+ default void onHideAction(
+ @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {}
+
+ /**
+ * Permission for reading clipboard data. See: <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText">Clipboard.readText()</a>
+ */
+ int PERMISSION_CLIPBOARD_READ = 1;
+
+ /** Represents attributes of a clipboard permission. */
+ public class ClipboardPermission {
+ /** The URI associated with this content permission. */
+ public final @NonNull String uri;
+
+ /**
+ * The type of this permission; one of {@link #PERMISSION_CLIPBOARD_READ
+ * PERMISSION_CLIPBOARD_*}.
+ */
+ public final @ClipboardPermissionType int type;
+
+ /**
+ * The last mouse or touch location in screen coordinates when the permission is requested.
+ */
+ public final @Nullable Point screenPoint;
+
+ /** Empty constructor for tests */
+ protected ClipboardPermission() {
+ this.uri = "";
+ this.type = PERMISSION_CLIPBOARD_READ;
+ this.screenPoint = null;
+ }
+
+ private ClipboardPermission(final @NonNull GeckoBundle bundle) {
+ this.uri = bundle.getString("uri");
+ this.type = PERMISSION_CLIPBOARD_READ;
+ this.screenPoint = bundle.getPoint("screenPoint");
+ }
+ }
+
+ /**
+ * Request clipboard permission.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param permission An {@link ClipboardPermission} describing the permission being requested.
+ * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the
+ * permission request for this site.
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ClipboardPermission permission) {
+ return GeckoResult.deny();
+ }
+
+ /**
+ * Dismiss requesting clipboard permission popup or model.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ */
+ @UiThread
+ default void onDismissClipboardPermissionRequest(@NonNull final GeckoSession session) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @StringDef({
+ SelectionActionDelegate.ACTION_HIDE,
+ SelectionActionDelegate.ACTION_CUT,
+ SelectionActionDelegate.ACTION_COPY,
+ SelectionActionDelegate.ACTION_DELETE,
+ SelectionActionDelegate.ACTION_PASTE,
+ SelectionActionDelegate.ACTION_PASTE_AS_PLAIN_TEXT,
+ SelectionActionDelegate.ACTION_SELECT_ALL,
+ SelectionActionDelegate.ACTION_UNSELECT,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_START,
+ SelectionActionDelegate.ACTION_COLLAPSE_TO_END
+ })
+ public @interface SelectionActionDelegateAction {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ SelectionActionDelegate.FLAG_IS_COLLAPSED,
+ SelectionActionDelegate.FLAG_IS_EDITABLE,
+ SelectionActionDelegate.FLAG_IS_PASSWORD
+ })
+ public @interface SelectionActionDelegateFlag {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SelectionActionDelegate.HIDE_REASON_NO_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION,
+ SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL
+ })
+ public @interface SelectionActionDelegateHideReason {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SelectionActionDelegate.PERMISSION_CLIPBOARD_READ,
+ })
+ public @interface ClipboardPermissionType {}
+
+ public interface NavigationDelegate {
+ /**
+ * A view has started loading content from the network.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param url The resource being loaded.
+ * @param perms The permissions currently associated with this url.
+ */
+ @UiThread
+ default void onLocationChange(
+ @NonNull GeckoSession session,
+ @Nullable String url,
+ final @NonNull List<PermissionDelegate.ContentPermission> perms) {}
+
+ /**
+ * The view's ability to go back has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoBack The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoBack(@NonNull final GeckoSession session, final boolean canGoBack) {}
+
+ /**
+ * The view's ability to go forward has changed.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param canGoForward The new value for the ability.
+ */
+ @UiThread
+ default void onCanGoForward(@NonNull final GeckoSession session, final boolean canGoForward) {}
+
+ public static final int TARGET_WINDOW_NONE = 0;
+ public static final int TARGET_WINDOW_CURRENT = 1;
+ public static final int TARGET_WINDOW_NEW = 2;
+
+ // Match with nsIWebNavigation.idl.
+ /** The load request was triggered by an HTTP redirect. */
+ static final int LOAD_REQUEST_IS_REDIRECT = 0x800000;
+
+ /** Load request details. */
+ public static class LoadRequest {
+ /* package */ LoadRequest(
+ @NonNull final String uri,
+ @Nullable final String triggerUri,
+ final int geckoTarget,
+ final int flags,
+ final boolean hasUserGesture,
+ final boolean isDirectNavigation) {
+ this.uri = uri;
+ this.triggerUri = triggerUri;
+ this.target = convertGeckoTarget(geckoTarget);
+ this.isRedirect = (flags & LOAD_REQUEST_IS_REDIRECT) != 0;
+ this.hasUserGesture = hasUserGesture;
+ this.isDirectNavigation = isDirectNavigation;
+ }
+
+ /** Empty constructor for tests. */
+ protected LoadRequest() {
+ uri = "";
+ triggerUri = null;
+ target = TARGET_WINDOW_NONE;
+ isRedirect = false;
+ hasUserGesture = false;
+ isDirectNavigation = false;
+ }
+
+ // This needs to match nsIBrowserDOMWindow.idl
+ private @TargetWindow int convertGeckoTarget(final int geckoTarget) {
+ switch (geckoTarget) {
+ case 0: // OPEN_DEFAULTWINDOW
+ case 1: // OPEN_CURRENTWINDOW
+ return TARGET_WINDOW_CURRENT;
+ default: // OPEN_NEWWINDOW, OPEN_NEWTAB
+ return TARGET_WINDOW_NEW;
+ }
+ }
+
+ /** The URI to be loaded. */
+ public final @NonNull String uri;
+
+ /**
+ * The URI of the origin page that triggered the load request. null for initial loads and
+ * loads originating from data: URIs.
+ */
+ public final @Nullable String triggerUri;
+
+ /**
+ * The target where the window has requested to open. One of {@link #TARGET_WINDOW_NONE
+ * TARGET_WINDOW_*}.
+ */
+ public final @TargetWindow int target;
+
+ /**
+ * True if and only if the request was triggered by an HTTP redirect.
+ *
+ * <p>If the user loads URI "a", which redirects to URI "b", then <code>onLoadRequest</code>
+ * will be called twice, first with uri "a" and <code>isRedirect = false</code>, then with uri
+ * "b" and <code>isRedirect = true</code>.
+ */
+ public final boolean isRedirect;
+
+ /** True if there was an active user gesture when the load was requested. */
+ public final boolean hasUserGesture;
+
+ /**
+ * This load request was initiated by a direct navigation from the application. E.g. when
+ * calling {@link GeckoSession#load}.
+ */
+ public final boolean isDirectNavigation;
+
+ @Override
+ public String toString() {
+ final StringBuilder out = new StringBuilder("LoadRequest { ");
+ out.append("uri: " + uri)
+ .append(", triggerUri: " + triggerUri)
+ .append(", target: " + target)
+ .append(", isRedirect: " + isRedirect)
+ .append(", hasUserGesture: " + hasUserGesture)
+ .append(", fromLoadUri: " + hasUserGesture)
+ .append(" }");
+ return out.toString();
+ }
+ }
+
+ /**
+ * A request to open an URI. This is called before each top-level page load to allow custom
+ * behavior. For example, this can be used to override the behavior of TAGET_WINDOW_NEW
+ * requests, which defaults to requesting a new GeckoSession via onNewSession.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request to load a URI in a non-top-level context.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param request The {@link LoadRequest} containing the request details.
+ * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not
+ * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a
+ * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is
+ * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled).
+ */
+ @UiThread
+ default @Nullable GeckoResult<AllowOrDeny> onSubframeLoadRequest(
+ @NonNull final GeckoSession session, @NonNull final LoadRequest request) {
+ return null;
+ }
+
+ /**
+ * A request has been made to open a new session. The URI is provided only for informational
+ * purposes. Do not call GeckoSession.load here. Additionally, the returned GeckoSession must be
+ * a newly-created one.
+ *
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI to be loaded.
+ * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which
+ * case the request for a new window by web content will fail. e.g., <code>window.open()
+ * </code> will return null. The implementation of onNewSession is responsible for
+ * maintaining a reference to the returned object, to prevent it from being garbage
+ * collected.
+ */
+ @UiThread
+ default @Nullable GeckoResult<GeckoSession> onNewSession(
+ @NonNull final GeckoSession session, @NonNull final String uri) {
+ return null;
+ }
+
+ /**
+ * @param session The GeckoSession that initiated the callback.
+ * @param uri The URI that failed to load.
+ * @param error A WebRequestError containing details about the error
+ * @return A URI to display as an error. Returning null will halt the load entirely. The
+ * following special methods are made available to the URI: -
+ * document.addCertException(isTemporary), returns Promise -
+ * document.getFailedCertSecurityInfo(), returns FailedCertSecurityInfo -
+ * document.getNetErrorInfo(), returns NetErrorInfo document.reloadWithHttpsOnlyException()
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/FailedCertSecurityInfo.webidl">FailedCertSecurityInfo
+ * IDL</a>
+ * @see <a
+ * href="https://searchfox.org/mozilla-central/source/dom/webidl/NetErrorInfo.webidl">NetErrorInfo
+ * IDL</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<String> onLoadError(
+ @NonNull final GeckoSession session,
+ @Nullable final String uri,
+ @NonNull final WebRequestError error) {
+ return null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NavigationDelegate.TARGET_WINDOW_NONE,
+ NavigationDelegate.TARGET_WINDOW_CURRENT,
+ NavigationDelegate.TARGET_WINDOW_NEW
+ })
+ public @interface TargetWindow {}
+
+ /**
+ * GeckoSession applications implement this interface to handle prompts triggered by content in
+ * the GeckoSession, such as alerts, authentication dialogs, and select list pickers.
+ */
+ public interface PromptDelegate {
+ /** PromptResponse is an opaque class created upon confirming or dismissing a prompt. */
+ public class PromptResponse {
+ private final BasePrompt mPrompt;
+
+ /* package */ PromptResponse(@NonNull final BasePrompt prompt) {
+ mPrompt = prompt;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (mPrompt == null) {
+ throw new RuntimeException("Trying to confirm/dismiss a null prompt.");
+ }
+ mPrompt.dispatch(callback);
+ }
+ }
+
+ interface PromptInstanceDelegate {
+ /**
+ * Called when this prompt has been dismissed by the system.
+ *
+ * <p>This can happen e.g. when the page navigates away and the content of the prompt is not
+ * relevant anymore.
+ *
+ * <p>When this method is called, you should hide the prompt UI elements.
+ *
+ * @param prompt the prompt that should be dismissed.
+ */
+ @UiThread
+ default void onPromptDismiss(final @NonNull BasePrompt prompt) {}
+
+ /**
+ * Called when this prompt has been updated.
+ *
+ * <p>This is called if inner &lt;option&gt; elements are updated when using &lt;select&gt;
+ * element.
+ *
+ * <p>When this method is called, you should update the prompt UI elements.
+ *
+ * @param prompt the new prompt that should be updated.
+ */
+ @UiThread
+ default void onPromptUpdate(final @NonNull BasePrompt prompt) {}
+ }
+
+ // Prompt classes.
+ public class BasePrompt {
+ private boolean mIsCompleted;
+ private boolean mIsConfirmed;
+ private GeckoBundle mResult;
+ private final WeakReference<Observer> mObserver;
+ private PromptInstanceDelegate mDelegate;
+
+ protected interface Observer {
+ @AnyThread
+ default void onPromptCompleted(@NonNull BasePrompt prompt) {}
+ }
+
+ private void complete() {
+ mIsCompleted = true;
+ final Observer observer = mObserver.get();
+ if (observer != null) {
+ observer.onPromptCompleted(this);
+ }
+ }
+
+ /** The title of this prompt; may be null. */
+ public final @Nullable String title;
+
+ /* package */ String id;
+
+ private BasePrompt(
+ @NonNull final String id, @Nullable final String title, final Observer observer) {
+ this.title = title;
+ this.id = id;
+ mIsConfirmed = false;
+ mIsCompleted = false;
+ mObserver = new WeakReference<>(observer);
+ }
+
+ @UiThread
+ protected @NonNull PromptResponse confirm() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ mIsConfirmed = true;
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * This dismisses the prompt without sending any meaningful information back to content.
+ *
+ * @return A {@link PromptResponse} with which you can complete the {@link GeckoResult} that
+ * corresponds to this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ if (mIsCompleted) {
+ throw new RuntimeException("Cannot confirm/dismiss a Prompt twice.");
+ }
+
+ complete();
+ return new PromptResponse(this);
+ }
+
+ /**
+ * Set the delegate for this prompt.
+ *
+ * @param delegate the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable PromptInstanceDelegate delegate) {
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the delegate for this prompt.
+ *
+ * @return the {@link PromptInstanceDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public PromptInstanceDelegate getDelegate() {
+ return mDelegate;
+ }
+
+ /* package */ GeckoBundle ensureResult() {
+ if (mResult == null) {
+ // Usually result object contains two items.
+ mResult = new GeckoBundle(2);
+ }
+ return mResult;
+ }
+
+ /**
+ * This returns true if the prompt has already been confirmed or dismissed.
+ *
+ * @return A boolean which is true if the prompt has been confirmed or dismissed, and false
+ * otherwise.
+ */
+ @UiThread
+ public boolean isComplete() {
+ return mIsCompleted;
+ }
+
+ /* package */ void dispatch(@NonNull final EventCallback callback) {
+ if (!mIsCompleted) {
+ throw new RuntimeException("Trying to dispatch an incomplete prompt.");
+ }
+
+ if (!mIsConfirmed) {
+ callback.sendSuccess(null);
+ } else {
+ callback.sendSuccess(mResult);
+ }
+ }
+ }
+
+ /**
+ * BeforeUnloadPrompt represents the onbeforeunload prompt. See
+ * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ */
+ class BeforeUnloadPrompt extends BasePrompt {
+ protected BeforeUnloadPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the navigation should be allowed to continue or not.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * RepostConfirmPrompt represents a prompt shown whenever the browser needs to resubmit POST
+ * data (e.g. due to page refresh).
+ */
+ class RepostConfirmPrompt extends BasePrompt {
+ protected RepostConfirmPrompt(@NonNull final String id, @NonNull final Observer observer) {
+ super(id, null, observer);
+ }
+
+ /**
+ * Confirms the prompt.
+ *
+ * @param allowOrDeny whether the browser should allow resubmitting data.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) {
+ ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AlertPrompt contains the information necessary to represent a JavaScript alert() call from
+ * content; it can only be dismissed, not confirmed.
+ */
+ public class AlertPrompt extends BasePrompt {
+ /** The message to be displayed with this alert; may be null. */
+ public final @Nullable String message;
+
+ protected AlertPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+ }
+
+ /**
+ * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from
+ * content.
+ */
+ public class ButtonPrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.POSITIVE, Type.NEGATIVE})
+ public @interface ButtonType {}
+
+ public static class Type {
+ /** Index of positive response button (eg, "Yes", "OK") */
+ public static final int POSITIVE = 0;
+
+ /** Index of negative response button (eg, "No", "Cancel") */
+ public static final int NEGATIVE = 2;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ protected ButtonPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ }
+
+ /**
+ * Confirms this prompt, returning the selected button to content.
+ *
+ * @param selection An int representing the selected button, must be one of {@link Type}.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ButtonType final int selection) {
+ ensureResult().putInt("button", selection);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * TextPrompt contains the information necessary to represent a Javascript prompt() call from
+ * content.
+ */
+ public class TextPrompt extends BasePrompt {
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The default value for the text field; may be null. */
+ public final @Nullable String defaultValue;
+
+ protected TextPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @Nullable final String defaultValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.defaultValue = defaultValue;
+ }
+
+ /**
+ * Confirms this prompt, returning the input text to content.
+ *
+ * @param text A String containing the text input given by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String text) {
+ ensureResult().putString("text", text);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * AuthPrompt contains the information necessary to represent an HTML authorization prompt
+ * generated by content.
+ */
+ public class AuthPrompt extends BasePrompt {
+ public static class AuthOptions {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Flags.HOST,
+ Flags.PROXY,
+ Flags.ONLY_PASSWORD,
+ Flags.PREVIOUS_FAILED,
+ Flags.CROSS_ORIGIN_SUB_RESOURCE
+ })
+ public @interface AuthFlag {}
+
+ /** Auth prompt flags. */
+ public static class Flags {
+ /** The auth prompt is for a network host. */
+ public static final int HOST = 1 << 0;
+
+ /** The auth prompt is for a proxy. */
+ public static final int PROXY = 1 << 1;
+
+ /** The auth prompt should only request a password. */
+ public static final int ONLY_PASSWORD = 1 << 3;
+
+ /** The auth prompt is the result of a previous failed login. */
+ public static final int PREVIOUS_FAILED = 1 << 4;
+
+ /** The auth prompt is for a cross-origin sub-resource. */
+ public static final int CROSS_ORIGIN_SUB_RESOURCE = 1 << 5;
+
+ protected Flags() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE})
+ public @interface AuthLevel {}
+
+ /** Auth prompt levels. */
+ public static class Level {
+ /** The auth request is unencrypted or the encryption status is unknown. */
+ public static final int NONE = 0;
+
+ /** The auth request only encrypts password but not data. */
+ public static final int PW_ENCRYPTED = 1;
+
+ /** The auth request encrypts both password and data. */
+ public static final int SECURE = 2;
+
+ protected Level() {}
+ }
+
+ /** An int bit-field of {@link Flags}. */
+ public @AuthFlag final int flags;
+
+ /** A string containing the URI for the auth request or null if unknown. */
+ public @Nullable final String uri;
+
+ /** An int, one of {@link Level}, indicating level of encryption. */
+ public @AuthLevel final int level;
+
+ /** A string containing the initial username or null if password-only. */
+ public @Nullable final String username;
+
+ /** A string containing the initial password. */
+ public @Nullable final String password;
+
+ /* package */ AuthOptions(final GeckoBundle options) {
+ flags = options.getInt("flags");
+ uri = options.getString("uri");
+ level = options.getInt("level");
+ username = options.getString("username");
+ password = options.getString("password");
+ }
+
+ /** Empty constructor for tests */
+ protected AuthOptions() {
+ flags = 0;
+ uri = "";
+ level = Level.NONE;
+ username = "";
+ password = "";
+ }
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** The {@link AuthOptions} that describe the type of authorization prompt. */
+ public final @NonNull AuthOptions authOptions;
+
+ protected AuthPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @NonNull final AuthOptions authOptions,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.authOptions = authOptions;
+ }
+
+ /**
+ * Confirms this prompt with just a password, returning the password to content.
+ *
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String password) {
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a username and password, returning both to content.
+ *
+ * @param username A String containing the username input by the user.
+ * @param password A String containing the password input by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final String username, @NonNull final String password) {
+ ensureResult().putString("username", username);
+ ensureResult().putString("password", password);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * ChoicePrompt contains the information necessary to display a menu or list prompt generated by
+ * content.
+ */
+ public class ChoicePrompt extends BasePrompt {
+ public static class Choice {
+ /**
+ * A boolean indicating if the item is disabled. Item should not be selectable if this is
+ * true.
+ */
+ public final boolean disabled;
+
+ /**
+ * A String giving the URI of the item icon, or null if none exists (only valid for menus)
+ */
+ public final @Nullable String icon;
+
+ /** A String giving the ID of the item or group */
+ public final @NonNull String id;
+
+ /** A Choice array of sub-items in a group, or null if not a group */
+ public final @Nullable Choice[] items;
+
+ /** A string giving the label for displaying the item or group */
+ public final @NonNull String label;
+
+ /** A boolean indicating if the item should be pre-selected (pre-checked for menu items) */
+ public final boolean selected;
+
+ /** A boolean indicating if the item should be a menu separator (only valid for menus) */
+ public final boolean separator;
+
+ /* package */ Choice(final GeckoBundle choice) {
+ disabled = choice.getBoolean("disabled");
+ icon = choice.getString("icon");
+ id = choice.getString("id");
+ label = choice.getString("label");
+ selected = choice.getBoolean("selected");
+ separator = choice.getBoolean("separator");
+
+ final GeckoBundle[] choices = choice.getBundleArray("items");
+ if (choices == null) {
+ items = null;
+ } else {
+ items = new Choice[choices.length];
+ for (int i = 0; i < choices.length; i++) {
+ items[i] = new Choice(choices[i]);
+ }
+ }
+ }
+
+ /** Empty constructor for tests. */
+ protected Choice() {
+ disabled = false;
+ icon = "";
+ id = "";
+ label = "";
+ selected = false;
+ separator = false;
+ items = null;
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.MENU, Type.SINGLE, Type.MULTIPLE})
+ public @interface ChoiceType {}
+
+ public static class Type {
+ /** Display choices in a menu that dismisses as soon as an item is chosen. */
+ public static final int MENU = 1;
+
+ /** Display choices in a list that allows a single selection. */
+ public static final int SINGLE = 2;
+
+ /** Display choices in a list that allows multiple selections. */
+ public static final int MULTIPLE = 3;
+
+ protected Type() {}
+ }
+
+ /** The message to be displayed with this prompt; may be null. */
+ public final @Nullable String message;
+
+ /** One of {@link Type}. */
+ public final @ChoiceType int type;
+
+ /** An array of {@link Choice} representing possible choices. */
+ public final @NonNull Choice[] choices;
+
+ protected ChoicePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String message,
+ @ChoiceType final int type,
+ @NonNull final Choice[] choices,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.message = message;
+ this.type = type;
+ this.choices = choices;
+ }
+
+ /**
+ * Confirms this prompt with the string id of a single choice.
+ *
+ * @param selectedId The string ID of the selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String selectedId) {
+ return confirm(new String[] {selectedId});
+ }
+
+ /**
+ * Confirms this prompt with the string ids of multiple choices
+ *
+ * @param selectedIds The string IDs of the selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String[] selectedIds) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedIds == null || selectedIds.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+ ensureResult().putStringArray("choices", selectedIds);
+ return super.confirm();
+ }
+
+ /**
+ * Confirms this prompt with a single choice.
+ *
+ * @param selectedChoice The selected choice.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice selectedChoice) {
+ return confirm(selectedChoice == null ? null : selectedChoice.id);
+ }
+
+ /**
+ * Confirms this prompt with multiple choices.
+ *
+ * @param selectedChoices The selected choices.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final Choice[] selectedChoices) {
+ if ((Type.MENU == type || Type.SINGLE == type)
+ && (selectedChoices == null || selectedChoices.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ if (selectedChoices == null) {
+ return confirm((String[]) null);
+ }
+
+ final String[] ids = new String[selectedChoices.length];
+ for (int i = 0; i < ids.length; i++) {
+ ids[i] = (selectedChoices[i] == null) ? null : selectedChoices[i].id;
+ }
+
+ return confirm(ids);
+ }
+ }
+
+ /**
+ * ColorPrompt contains the information necessary to represent a prompt for color input
+ * generated by content.
+ */
+ public class ColorPrompt extends BasePrompt {
+ /** The default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** The predefined values by &lt;datalist&gt; element */
+ public final @Nullable String[] predefinedValues;
+
+ protected ColorPrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String defaultValue,
+ @Nullable final String[] predefinedValues,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.defaultValue = defaultValue;
+ this.predefinedValues = predefinedValues;
+ }
+
+ /**
+ * Confirms the prompt and passes the color value back to content.
+ *
+ * @param color A String representing the color to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String color) {
+ ensureResult().putString("color", color);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * DateTimePrompt contains the information necessary to represent a prompt for date and/or time
+ * input generated by content.
+ */
+ public class DateTimePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.DATE, Type.MONTH, Type.WEEK, Type.TIME, Type.DATETIME_LOCAL})
+ public @interface DatetimeType {}
+
+ public static class Type {
+ /** Prompt for year, month, and day. */
+ public static final int DATE = 1;
+
+ /** Prompt for year and month. */
+ public static final int MONTH = 2;
+
+ /** Prompt for year and week. */
+ public static final int WEEK = 3;
+
+ /** Prompt for hour and minute. */
+ public static final int TIME = 4;
+
+ /** Prompt for year, month, day, hour, and minute, without timezone. */
+ public static final int DATETIME_LOCAL = 5;
+
+ protected Type() {}
+ }
+
+ /** One of {@link Type} indicating the type of prompt. */
+ public final @DatetimeType int type;
+
+ /** A String representing the default value supplied by content. */
+ public final @Nullable String defaultValue;
+
+ /** A String representing the minimum value allowed by content. */
+ public final @Nullable String minValue;
+
+ /** A String representing the maximum value allowed by content. */
+ public final @Nullable String maxValue;
+
+ /** A String representing the step value allowed by content. */
+ public final @Nullable String stepValue;
+
+ /** For testing. */
+ private DateTimePrompt() {
+ // Initialize final members
+ super("", null, null);
+ this.type = Type.DATE;
+ this.defaultValue = null;
+ this.minValue = null;
+ this.maxValue = null;
+ this.stepValue = null;
+ }
+
+ /* package */ DateTimePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @DatetimeType final int type,
+ @Nullable final String defaultValue,
+ @Nullable final String minValue,
+ @Nullable final String maxValue,
+ @Nullable final String stepValue,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.defaultValue = defaultValue;
+ this.minValue = minValue;
+ this.maxValue = maxValue;
+ this.stepValue = stepValue;
+ }
+
+ /**
+ * Confirms the prompt and passes the date and/or time value back to content.
+ *
+ * @param datetime A String representing the date and time to be returned to content.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final String datetime) {
+ ensureResult().putString("datetime", datetime);
+ return super.confirm();
+ }
+ }
+
+ /**
+ * FilePrompt contains the information necessary to represent a prompt for a file or files
+ * generated by content.
+ */
+ public class FilePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Type.SINGLE, Type.MULTIPLE})
+ public @interface FileType {}
+
+ /** Types of file prompts. */
+ public static class Type {
+ /** Prompt for a single file. */
+ public static final int SINGLE = 1;
+
+ /** Prompt for multiple files. */
+ public static final int MULTIPLE = 2;
+
+ protected Type() {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Capture.NONE, Capture.ANY, Capture.USER, Capture.ENVIRONMENT})
+ public @interface CaptureType {}
+
+ /** Possible capture attribute values. */
+ public static class Capture {
+ // These values should match the corresponding values in nsIFilePicker.idl
+ /** No capture attribute has been supplied by content. */
+ public static final int NONE = 0;
+
+ /** The capture attribute was supplied with a missing or invalid value. */
+ public static final int ANY = 1;
+
+ /** The "user" capture attribute has been supplied by content. */
+ public static final int USER = 2;
+
+ /** The "environment" capture attribute has been supplied by content. */
+ public static final int ENVIRONMENT = 3;
+
+ protected Capture() {}
+ }
+
+ /** One of {@link Type} indicating the prompt type. */
+ public final @FileType int type;
+
+ /**
+ * An array of Strings giving the MIME types specified by the "accept" attribute, if any are
+ * specified.
+ */
+ public final @Nullable String[] mimeTypes;
+
+ /** One of {@link Capture} indicating the capture attribute supplied by content. */
+ public final @CaptureType int capture;
+
+ protected FilePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @FileType final int type,
+ @CaptureType final int capture,
+ @Nullable final String[] mimeTypes,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.type = type;
+ this.capture = capture;
+ this.mimeTypes = mimeTypes;
+ }
+
+ /**
+ * Confirms the prompt and passes the file URI back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uri The URI of the file chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri uri) {
+ return confirm(context, new Uri[] {uri});
+ }
+
+ /**
+ * Confirms the prompt and passes the file URIs back to content.
+ *
+ * @param context An Application context for parsing URIs.
+ * @param uris The URIs of the files chosen by the user.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(
+ @NonNull final Context context, @NonNull final Uri[] uris) {
+ if (Type.SINGLE == type && (uris == null || uris.length != 1)) {
+ throw new IllegalArgumentException();
+ }
+
+ final String[] paths = new String[uris != null ? uris.length : 0];
+ for (int i = 0; i < paths.length; i++) {
+ paths[i] = getFile(context, uris[i]);
+ if (paths[i] == null) {
+ Log.e(LOGTAG, "Only file URIs are supported: " + uris[i]);
+ }
+ }
+ ensureResult().putStringArray("files", paths);
+
+ return super.confirm();
+ }
+
+ private static String getFile(final @NonNull Context context, final @NonNull Uri uri) {
+ if (uri == null) {
+ return null;
+ }
+ if ("file".equals(uri.getScheme())) {
+ return uri.getPath();
+ }
+ final ContentResolver cr = context.getContentResolver();
+ final Cursor cur =
+ cr.query(
+ uri,
+ new String[] {"_data"}, /* selection */
+ null,
+ /* args */ null, /* sort */
+ null);
+ if (cur == null) {
+ return null;
+ }
+ try {
+ final int idx = cur.getColumnIndex("_data");
+ if (idx < 0 || !cur.moveToFirst()) {
+ return null;
+ }
+ do {
+ try {
+ final String path = cur.getString(idx);
+ if (path != null && !path.isEmpty()) {
+ return path;
+ }
+ } catch (final Exception e) {
+ }
+ } while (cur.moveToNext());
+ } finally {
+ cur.close();
+ }
+ return null;
+ }
+ }
+
+ /** PopupPrompt contains the information necessary to represent a popup blocking request. */
+ public class PopupPrompt extends BasePrompt {
+ /** The target URI for the popup; may be null. */
+ public final @Nullable String targetUri;
+
+ protected PopupPrompt(
+ @NonNull final String id,
+ @Nullable final String targetUri,
+ @NonNull final Observer observer) {
+ super(id, null, observer);
+ this.targetUri = targetUri;
+ }
+
+ /**
+ * Confirms the prompt and either allows or blocks the popup.
+ *
+ * @param response An {@link AllowOrDeny} specifying whether to allow or deny the popup.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@NonNull final AllowOrDeny response) {
+ boolean res = false;
+ if (AllowOrDeny.ALLOW == response) {
+ res = true;
+ }
+ ensureResult().putBoolean("response", res);
+ return super.confirm();
+ }
+ }
+
+ /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */
+ public class SharePrompt extends BasePrompt {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({Result.SUCCESS, Result.FAILURE, Result.ABORT})
+ public @interface ShareResult {}
+
+ /** Possible results to a {@link SharePrompt}. */
+ public static class Result {
+ /** The user shared with another app successfully. */
+ public static final int SUCCESS = 0;
+
+ /** The user attempted to share with another app, but it failed. */
+ public static final int FAILURE = 1;
+
+ /** The user aborted the share. */
+ public static final int ABORT = 2;
+
+ protected Result() {}
+ }
+
+ /** The text for the share request. */
+ public final @Nullable String text;
+
+ /** The uri for the share request. */
+ public final @Nullable String uri;
+
+ protected SharePrompt(
+ @NonNull final String id,
+ @Nullable final String title,
+ @Nullable final String text,
+ @Nullable final String uri,
+ @NonNull final Observer observer) {
+ super(id, title, observer);
+ this.text = text;
+ this.uri = uri;
+ }
+
+ /**
+ * Confirms the prompt and either blocks or allows the share request.
+ *
+ * @param response One of {@link Result} specifying the outcome of the share attempt.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(@ShareResult final int response) {
+ ensureResult().putInt("response", response);
+ return super.confirm();
+ }
+
+ /**
+ * Dismisses the prompt and returns {@link Result#ABORT} to web content.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ ensureResult().putInt("response", Result.ABORT);
+ return super.dismiss();
+ }
+ }
+
+ /** Request containing information required to resolve Autocomplete prompt requests. */
+ public class AutocompleteRequest<T extends Autocomplete.Option<?>> extends BasePrompt {
+ /**
+ * The Autocomplete options for this request. This can contain a single or multiple entries.
+ */
+ public final @NonNull T[] options;
+
+ protected AutocompleteRequest(
+ final @NonNull String id, final @NonNull T[] options, final Observer observer) {
+ super(id, null, observer);
+ this.options = options;
+ }
+
+ /**
+ * Confirm the request by responding with a selection. See the PromptDelegate callbacks for
+ * specifics.
+ *
+ * @param selection The {@link Autocomplete.Option} used to confirm the request.
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse confirm(final @NonNull Autocomplete.Option<?> selection) {
+ ensureResult().putBundle("selection", selection.toBundle());
+ return super.confirm();
+ }
+
+ /**
+ * Dismiss the request. See the PromptDelegate callbacks for specifics.
+ *
+ * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult}
+ * associated with this prompt.
+ */
+ @UiThread
+ public @NonNull PromptResponse dismiss() {
+ return super.dismiss();
+ }
+ }
+
+ // Delegate functions.
+ /**
+ * Display an alert prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AlertPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAlertPrompt(
+ @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a onbeforeunload prompt.
+ *
+ * <p>See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
+ * See {@link BeforeUnloadPrompt}
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link BeforeUnloadPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation or {@link AllowOrDeny#DENY} otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onBeforeUnloadPrompt(
+ @NonNull final GeckoSession session, @NonNull final BeforeUnloadPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a POST resubmission confirmation prompt.
+ *
+ * <p>This prompt will trigger whenever refreshing or navigating to a page needs resubmitting
+ * POST data that has been submitted already.
+ *
+ * @param session GeckoSession that triggered the prompt
+ * @param prompt the {@link RepostConfirmPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed
+ * to continue with the navigation and resubmit the POST data or {@link AllowOrDeny#DENY}
+ * otherwise.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onRepostConfirmPrompt(
+ @NonNull final GeckoSession session, @NonNull final RepostConfirmPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a button prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ButtonPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onButtonPrompt(
+ @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a text prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link TextPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onTextPrompt(
+ @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display an authorization prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link AuthPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAuthPrompt(
+ @NonNull final GeckoSession session, @NonNull final AuthPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a list/menu prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ChoicePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onChoicePrompt(
+ @NonNull final GeckoSession session, @NonNull final ChoicePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a color prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link ColorPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onColorPrompt(
+ @NonNull final GeckoSession session, @NonNull final ColorPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a date/time prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link DateTimePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onDateTimePrompt(
+ @NonNull final GeckoSession session, @NonNull final DateTimePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a file prompt.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link FilePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onFilePrompt(
+ @NonNull final GeckoSession session, @NonNull final FilePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a popup request prompt; this occurs when content attempts to open a new window in a
+ * way that doesn't appear to be the result of user input.
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link PopupPrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onPopupPrompt(
+ @NonNull final GeckoSession session, @NonNull final PopupPrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Display a share request prompt; this occurs when content attempts to use the WebShare API.
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share
+ *
+ * @param session GeckoSession that triggered the prompt.
+ * @param prompt The {@link SharePrompt} that describes the prompt.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all
+ * necessary information to resolve the prompt.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onSharePrompt(
+ @NonNull final GeckoSession session, @NonNull final SharePrompt prompt) {
+ return null;
+ }
+
+ /**
+ * Handle a login save prompt request. This is triggered by the user entering new or modified
+ * login credentials into a login form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onLoginSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created login entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address save prompt request. This is triggered by the user entering new or modified
+ * address credentials into a address form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onAddressSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created address entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card save prompt request. This is triggered by the user entering new or
+ * modified credit card credentials into a form.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}.
+ * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link
+ * Autocomplete.StorageDelegate#onCreditCardSave} request to save the given selection. The
+ * confirmed selection may be an entry out of the request's options, a modified option, or a
+ * freshly created credit card entry.
+ * <p>Dismiss the request to deny the saving request.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSave(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSaveOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a login selection prompt request. This is triggered by the user focusing on a login
+ * username field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * login forms with the given selection details. The confirmed selection may be an entry out
+ * of the request's options, a modified option, or a freshly created login entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onLoginSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.LoginSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a credit card selection prompt request. This is triggered by the user focusing on a
+ * credit card input field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * credit card forms with the given selection details. The confirmed selection may be an
+ * entry out of the request's options, a modified option, or a freshly created credit card
+ * entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onCreditCardSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.CreditCardSelectOption> request) {
+ return null;
+ }
+
+ /**
+ * Handle a address selection prompt request. This is triggered by the user focusing on a
+ * address field.
+ *
+ * @param session The {@link GeckoSession} that triggered the request.
+ * @param request The {@link AutocompleteRequest} containing the request details.
+ * @return A {@link GeckoResult} resolving to a {@link PromptResponse}
+ * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the
+ * address forms with the given selection details. The confirmed selection may be an entry
+ * out of the request's options, a modified option, or a freshly created address entry.
+ * <p>Dismiss the request to deny autocompletion for the detected form.
+ */
+ @UiThread
+ default @Nullable GeckoResult<PromptResponse> onAddressSelect(
+ @NonNull final GeckoSession session,
+ @NonNull final AutocompleteRequest<Autocomplete.AddressSelectOption> request) {
+ return null;
+ }
+ }
+
+ /** GeckoSession applications implement this interface to handle content scroll events. */
+ public interface ScrollDelegate {
+ /**
+ * The scroll position of the content has changed.
+ *
+ * @param session GeckoSession that initiated the callback.
+ * @param scrollX The new horizontal scroll position in pixels.
+ * @param scrollY The new vertical scroll position in pixels.
+ */
+ @UiThread
+ default void onScrollChanged(
+ @NonNull final GeckoSession session, final int scrollX, final int scrollY) {}
+ }
+
+ /**
+ * Get the PanZoomController instance for this session.
+ *
+ * @return PanZoomController instance.
+ */
+ @UiThread
+ public @NonNull PanZoomController getPanZoomController() {
+ ThreadUtils.assertOnUiThread();
+
+ return mPanZoomController;
+ }
+
+ /**
+ * Get the OverscrollEdgeEffect instance for this session.
+ *
+ * @return OverscrollEdgeEffect instance.
+ */
+ @UiThread
+ public @NonNull OverscrollEdgeEffect getOverscrollEdgeEffect() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mOverscroll == null) {
+ mOverscroll = new OverscrollEdgeEffect();
+ }
+ return mOverscroll;
+ }
+
+ /**
+ * Get the CompositorController instance for this session.
+ *
+ * @return CompositorController instance.
+ */
+ @UiThread
+ public @NonNull CompositorController getCompositorController() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mController == null) {
+ mController = new CompositorController(this);
+ if (mCompositorReady) {
+ mController.onCompositorReady();
+ }
+ }
+ return mController;
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.setScale(mViewportZoom, mViewportZoom);
+ if (mClientTop != mTop) {
+ matrix.postTranslate(0, mClientTop - mTop);
+ }
+ }
+
+ /**
+ * Get a matrix for transforming from client coordinates to screen coordinates. The client
+ * coordinates are in CSS pixels and are relative to the viewport origin; their relation to screen
+ * coordinates does not depend on the current scroll position.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToSurfaceMatrix(Matrix)
+ * @see #getPageToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getClientToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to screen coordinates. The page coordinates
+ * are in CSS pixels and are relative to the page origin; their relation to screen coordinates
+ * depends on the current scroll position of the outermost frame.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToSurfaceMatrix(Matrix)
+ * @see #getClientToScreenMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToScreenMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getPageToSurfaceMatrix(matrix);
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get a matrix for transforming from page coordinates to surface coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getPageToScreenMatrix(Matrix)
+ * @see #getClientToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ public void getPageToSurfaceMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ getClientToSurfaceMatrix(matrix);
+ matrix.postTranslate(-mViewportLeft, -mViewportTop);
+ }
+
+ /**
+ * Get a matrix for transforming from layout device client coordinates to screen coordinates.
+ *
+ * @param matrix Matrix to be replaced by the transformation matrix.
+ * @see #getClientToScreenMatrix(Matrix)
+ * @see #getPageToSurfaceMatrix(Matrix)
+ */
+ @UiThread
+ /* package */ void getClientToScreenOffsetMatrix(@NonNull final Matrix matrix) {
+ ThreadUtils.assertOnUiThread();
+
+ matrix.postTranslate(mLeft, mTop);
+ }
+
+ /**
+ * Get 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.
+ *
+ * <p>When denying an Android app permission, the response is not stored by GeckoView. It is the
+ * responsibility of the consumer to store the response state and therefore prevent further
+ * requests from being presented to the user.
+ */
+ public interface PermissionDelegate {
+ /**
+ * Permission for using the geolocation API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Geolocation
+ */
+ int PERMISSION_GEOLOCATION = 0;
+
+ /**
+ * Permission for using the notifications API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/notification
+ */
+ int PERMISSION_DESKTOP_NOTIFICATION = 1;
+
+ /**
+ * Permission for using the storage API. See:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Storage_API
+ */
+ int PERMISSION_PERSISTENT_STORAGE = 2;
+
+ /** Permission for using the WebXR API. See: https://www.w3.org/TR/webxr */
+ int PERMISSION_XR = 3;
+
+ /** Permission for allowing autoplay of inaudible (silent) video. */
+ int PERMISSION_AUTOPLAY_INAUDIBLE = 4;
+
+ /** Permission for allowing autoplay of audible video. */
+ int PERMISSION_AUTOPLAY_AUDIBLE = 5;
+
+ /** Permission for accessing system media keys used to decode DRM media. */
+ int PERMISSION_MEDIA_KEY_SYSTEM_ACCESS = 6;
+
+ /**
+ * Permission for trackers to operate on the page -- disables all tracking protection features
+ * for a given site.
+ */
+ int PERMISSION_TRACKING = 7;
+
+ /**
+ * Permission for third party frames to access first party cookies and storage. May be granted
+ * heuristically in some cases.
+ */
+ int PERMISSION_STORAGE_ACCESS = 8;
+
+ /**
+ * Represents a content permission -- including the type of permission, the present value of the
+ * permission, the URL the permission pertains to, and other information.
+ */
+ class ContentPermission {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({VALUE_PROMPT, VALUE_DENY, VALUE_ALLOW})
+ public @interface Value {}
+
+ /** The corresponding permission is currently set to default/prompt behavior. */
+ public static final int VALUE_PROMPT = 3;
+
+ /** The corresponding permission is currently set to deny. */
+ public static final int VALUE_DENY = 2;
+
+ /** The corresponding permission is currently set to allow. */
+ public static final int VALUE_ALLOW = 1;
+
+ /** The URI associated with this content permission. */
+ public final @NonNull String uri;
+
+ /**
+ * The third party origin associated with the request; currently only used for storage access
+ * permission.
+ */
+ public final @Nullable String thirdPartyOrigin;
+
+ /**
+ * A boolean indicating whether this content permission is associated with private browsing.
+ */
+ public final boolean privateMode;
+
+ /** The type of this permission; one of {@link #PERMISSION_GEOLOCATION PERMISSION_*}. */
+ public final int permission;
+
+ /** The value of the permission; one of {@link #VALUE_PROMPT VALUE_}. */
+ public final @Value int value;
+
+ /**
+ * The context ID associated with the permission if any.
+ *
+ * @see GeckoSessionSettings.Builder#contextId
+ */
+ public final @Nullable String contextId;
+
+ private final String mPrincipal;
+
+ protected ContentPermission() {
+ this.uri = "";
+ this.thirdPartyOrigin = null;
+ this.privateMode = false;
+ this.permission = PERMISSION_GEOLOCATION;
+ this.value = VALUE_ALLOW;
+ this.mPrincipal = "";
+ this.contextId = null;
+ }
+
+ private ContentPermission(final @NonNull GeckoBundle bundle) {
+ this.uri = bundle.getString("uri");
+ this.mPrincipal = bundle.getString("principal");
+ this.privateMode = bundle.getBoolean("privateMode");
+
+ final String permission = bundle.getString("perm");
+ this.permission = convertType(permission);
+ if (permission.startsWith("3rdPartyStorage^")) {
+ // Storage access permissions are stored with the key "3rdPartyStorage^https://foo.com"
+ // where the third party origin is "https://foo.com".
+ this.thirdPartyOrigin = permission.substring(16);
+ } else {
+ this.thirdPartyOrigin = bundle.getString("thirdPartyOrigin");
+ }
+
+ this.value = bundle.getInt("value");
+ this.contextId =
+ StorageController.retrieveUnsafeSessionContextId(bundle.getString("contextId"));
+ }
+
+ /**
+ * Converts a JSONObject to a ContentPermission -- should only be used on the output of {@link
+ * #toJson()}.
+ *
+ * @param perm A JSONObject representing a ContentPermission, output by {@link #toJson()}.
+ * @return The corresponding ContentPermission.
+ */
+ @AnyThread
+ public static @Nullable ContentPermission fromJson(final @NonNull JSONObject perm) {
+ ContentPermission res = null;
+ try {
+ res = new ContentPermission(GeckoBundle.fromJSONObject(perm));
+ } catch (final JSONException e) {
+ Log.w(LOGTAG, "Failed to create ContentPermission; invalid JSONObject.", e);
+ }
+ return res;
+ }
+
+ /**
+ * Converts a ContentPermission to a JSONObject that can be converted back to a
+ * ContentPermission by {@link #fromJson(JSONObject)}.
+ *
+ * @return A JSONObject representing this ContentPermission. Modifying any of the fields may
+ * result in undefined behavior when converted back to a ContentPermission and used.
+ * @throws JSONException if the conversion fails for any reason.
+ */
+ @AnyThread
+ public @NonNull JSONObject toJson() throws JSONException {
+ return toGeckoBundle().toJSONObject();
+ }
+
+ private static int convertType(final @NonNull String type) {
+ if ("geolocation".equals(type)) {
+ return PERMISSION_GEOLOCATION;
+ } else if ("desktop-notification".equals(type)) {
+ return PERMISSION_DESKTOP_NOTIFICATION;
+ } else if ("persistent-storage".equals(type)) {
+ return PERMISSION_PERSISTENT_STORAGE;
+ } else if ("xr".equals(type)) {
+ return PERMISSION_XR;
+ } else if ("autoplay-media-inaudible".equals(type)) {
+ return PERMISSION_AUTOPLAY_INAUDIBLE;
+ } else if ("autoplay-media-audible".equals(type)) {
+ return PERMISSION_AUTOPLAY_AUDIBLE;
+ } else if ("media-key-system-access".equals(type)) {
+ return PERMISSION_MEDIA_KEY_SYSTEM_ACCESS;
+ } else if ("trackingprotection".equals(type) || "trackingprotection-pb".equals(type)) {
+ return PERMISSION_TRACKING;
+ } else if ("storage-access".equals(type) || type.startsWith("3rdPartyStorage^")) {
+ return PERMISSION_STORAGE_ACCESS;
+ } else {
+ return -1;
+ }
+ }
+
+ // This also gets used in StorageController, so it's package rather than private.
+ /* package */ static String convertType(final int type, final boolean privateMode) {
+ switch (type) {
+ case PERMISSION_GEOLOCATION:
+ return "geolocation";
+ case PERMISSION_DESKTOP_NOTIFICATION:
+ return "desktop-notification";
+ case PERMISSION_PERSISTENT_STORAGE:
+ return "persistent-storage";
+ case PERMISSION_XR:
+ return "xr";
+ case PERMISSION_AUTOPLAY_INAUDIBLE:
+ return "autoplay-media-inaudible";
+ case PERMISSION_AUTOPLAY_AUDIBLE:
+ return "autoplay-media-audible";
+ case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS:
+ return "media-key-system-access";
+ case PERMISSION_TRACKING:
+ return privateMode ? "trackingprotection-pb" : "trackingprotection";
+ case PERMISSION_STORAGE_ACCESS:
+ return "storage-access";
+ default:
+ return "";
+ }
+ }
+
+ /* package */ static @NonNull ArrayList<ContentPermission> fromBundleArray(
+ final @NonNull GeckoBundle[] bundleArray) {
+ final ArrayList<ContentPermission> res = new ArrayList<ContentPermission>();
+ if (bundleArray == null) {
+ return res;
+ }
+
+ for (final GeckoBundle bundle : bundleArray) {
+ final ContentPermission temp = new ContentPermission(bundle);
+ if (temp.permission == -1 || temp.value < 1 || temp.value > 3) {
+ continue;
+ }
+ res.add(temp);
+ }
+ return res;
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toGeckoBundle() {
+ final GeckoBundle res = new GeckoBundle(7);
+ res.putString("uri", uri);
+ res.putString("thirdPartyOrigin", thirdPartyOrigin);
+ res.putString("principal", mPrincipal);
+ res.putBoolean("privateMode", privateMode);
+ res.putString("perm", convertType(permission, privateMode));
+ res.putInt("value", value);
+ res.putString("contextId", contextId);
+ return res;
+ }
+ }
+
+ /** Callback interface for notifying the result of a permission request. */
+ interface Callback {
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * either grant() or reject() for every request.
+ */
+ @UiThread
+ default void grant() {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * either grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request Android app permissions.
+ *
+ * @param session GeckoSession instance requesting the permissions.
+ * @param permissions List of permissions to request; possible values are,
+ * android.Manifest.permission.ACCESS_COARSE_LOCATION
+ * android.Manifest.permission.ACCESS_FINE_LOCATION android.Manifest.permission.CAMERA
+ * android.Manifest.permission.RECORD_AUDIO
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onAndroidPermissionsRequest(
+ @NonNull final GeckoSession session,
+ @Nullable final String[] permissions,
+ @NonNull final Callback callback) {
+ callback.reject();
+ }
+
+ /**
+ * Request content permission.
+ *
+ * <p>Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted
+ * for a site, it cannot be revoked. If the permission has previously been granted, it is the
+ * responsibility of the consuming app to remember the permission and prevent the prompt from
+ * being redisplayed to the user.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param perm An {@link ContentPermission} describing the permission being requested and its
+ * current status.
+ * @return A {@link GeckoResult} resolving to one of {@link ContentPermission#VALUE_PROMPT
+ * VALUE_*}, determining the response to the permission request and updating the permissions
+ * for this site.
+ */
+ @UiThread
+ default @Nullable GeckoResult<Integer> onContentPermissionRequest(
+ @NonNull final GeckoSession session, @NonNull ContentPermission perm) {
+ return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT);
+ }
+
+ class MediaSource {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SOURCE_CAMERA, SOURCE_SCREEN,
+ SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE,
+ SOURCE_OTHER
+ })
+ public @interface Source {}
+
+ /** Constant to indicate that camera will be recorded. */
+ public static final int SOURCE_CAMERA = 0;
+
+ /** Constant to indicate that screen will be recorded. */
+ public static final int SOURCE_SCREEN = 1;
+
+ /** Constant to indicate that microphone will be recorded. */
+ public static final int SOURCE_MICROPHONE = 2;
+
+ /** Constant to indicate that device audio playback will be recorded. */
+ public static final int SOURCE_AUDIOCAPTURE = 3;
+
+ /** Constant to indicate a media source that does not fall under the other categories. */
+ public static final int SOURCE_OTHER = 4;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_VIDEO, TYPE_AUDIO})
+ public @interface Type {}
+
+ /** The media type is video. */
+ public static final int TYPE_VIDEO = 0;
+
+ /** The media type is audio. */
+ public static final int TYPE_AUDIO = 1;
+
+ /** A string giving a unique source identifier. */
+ public final @NonNull String id;
+
+ /**
+ * A string giving the name of the video source from the system (for example, "Camera 0,
+ * Facing back, Orientation 90"). May be empty.
+ */
+ public final @Nullable String name;
+
+ /**
+ * An int indicating the media source type. Possible values for a video source are:
+ * SOURCE_CAMERA, SOURCE_SCREEN, and SOURCE_OTHER. Possible values for an audio source are:
+ * SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, and SOURCE_OTHER.
+ */
+ public final @Source int source;
+
+ /** An int giving the type of media, must be either TYPE_VIDEO or TYPE_AUDIO. */
+ public final @Type int type;
+
+ private static @Source int getSourceFromString(final String src) {
+ // The strings here should match those in MediaSourceEnum in MediaStreamTrack.webidl
+ if ("camera".equals(src)) {
+ return SOURCE_CAMERA;
+ } else if ("screen".equals(src) || "window".equals(src) || "browser".equals(src)) {
+ return SOURCE_SCREEN;
+ } else if ("microphone".equals(src)) {
+ return SOURCE_MICROPHONE;
+ } else if ("audioCapture".equals(src)) {
+ return SOURCE_AUDIOCAPTURE;
+ } else if ("other".equals(src) || "application".equals(src)) {
+ return SOURCE_OTHER;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + src + " is not a valid media source string");
+ }
+ }
+
+ private static @Type int getTypeFromString(final String type) {
+ // The strings here should match the possible types in MediaDevice::MediaDevice in
+ // MediaManager.cpp
+ if ("videoinput".equals(type)) {
+ return TYPE_VIDEO;
+ } else if ("audioinput".equals(type)) {
+ return TYPE_AUDIO;
+ } else {
+ throw new IllegalArgumentException(
+ "String: " + type + " is not a valid media type string");
+ }
+ }
+
+ /* package */ MediaSource(final GeckoBundle media) {
+ id = media.getString("id");
+ name = media.getString("name");
+ source = getSourceFromString(media.getString("mediaSource"));
+ type = getTypeFromString(media.getString("type"));
+ }
+
+ /** Empty constructor for tests. */
+ protected MediaSource() {
+ id = null;
+ name = null;
+ source = SOURCE_CAMERA;
+ type = TYPE_VIDEO;
+ }
+ }
+
+ /**
+ * Callback interface for notifying the result of a media permission request, including which
+ * media source(s) to use.
+ */
+ interface MediaCallback {
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video "id" value from the bundle for the video source to use, or null when video is
+ * not requested.
+ * @param audio "id" value from the bundle for the audio source to use, or null when audio is
+ * not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable String video, final @Nullable String audio) {}
+
+ /**
+ * Called by the implementation after permissions are granted; the implementation must call
+ * one of grant() or reject() for every request.
+ *
+ * @param video MediaSource for the video source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when video is not requested.
+ * @param audio MediaSource for the audio source to use (must be an original MediaSource
+ * object that was passed to the implementation); or null when audio is not requested.
+ */
+ @UiThread
+ default void grant(final @Nullable MediaSource video, final @Nullable MediaSource audio) {}
+
+ /**
+ * Called by the implementation when permissions are not granted; the implementation must call
+ * one of grant() or reject() for every request.
+ */
+ @UiThread
+ default void reject() {}
+ }
+
+ /**
+ * Request content media permissions, including request for which video and/or audio source to
+ * use.
+ *
+ * <p>Media permissions will still be requested if the associated device permissions have been
+ * denied if there are video or audio sources in that category that can still be accessed. It is
+ * the responsibility of consumers to ensure that media permission requests are not displayed in
+ * this case.
+ *
+ * @param session GeckoSession instance requesting the permission.
+ * @param uri The URI of the content requesting the permission.
+ * @param video List of video sources, or null if not requesting video.
+ * @param audio List of audio sources, or null if not requesting audio.
+ * @param callback Callback interface.
+ */
+ @UiThread
+ default void onMediaPermissionRequest(
+ @NonNull final GeckoSession session,
+ @NonNull final String uri,
+ @Nullable final MediaSource[] video,
+ @Nullable final MediaSource[] audio,
+ @NonNull final MediaCallback callback) {
+ callback.reject();
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ PermissionDelegate.PERMISSION_GEOLOCATION,
+ PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION,
+ PermissionDelegate.PERMISSION_PERSISTENT_STORAGE,
+ PermissionDelegate.PERMISSION_XR,
+ PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE,
+ PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE,
+ PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS,
+ PermissionDelegate.PERMISSION_TRACKING,
+ PermissionDelegate.PERMISSION_STORAGE_ACCESS
+ })
+ public @interface Permission {}
+
+ /**
+ * Interface that SessionTextInput uses for performing operations such as opening and closing the
+ * software keyboard. If the delegate is not set, these operations are forwarded to the system
+ * {@link android.view.inputmethod.InputMethodManager} automatically.
+ */
+ public interface TextInputDelegate {
+ /** Restarting input due to an input field gaining focus. */
+ int RESTART_REASON_FOCUS = 0;
+
+ /** Restarting input due to an input field losing focus. */
+ int RESTART_REASON_BLUR = 1;
+
+ /**
+ * Restarting input due to the content of the input field changing. For example, the input field
+ * type may have changed, or the current composition may have been committed outside of the
+ * input method.
+ */
+ int RESTART_REASON_CONTENT_CHANGE = 2;
+
+ /**
+ * Reset the input method, and discard any existing states such as the current composition or
+ * current autocompletion. Because the current focused editor may have changed, as part of the
+ * reset, a custom input method would normally call {@link
+ * SessionTextInput#onCreateInputConnection} to update its knowledge of the focused editor. Note
+ * that {@code restartInput} should be used to detect changes in focus, rather than {@link
+ * #showSoftInput} or {@link #hideSoftInput}, because focus changes are not always accompanied
+ * by requests to show or hide the soft input. This method is always called, even in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param reason Reason for the reset.
+ */
+ @UiThread
+ default void restartInput(
+ @NonNull final GeckoSession session, @RestartReason final int reason) {}
+
+ /**
+ * Display the soft input. May be called consecutively, even if the soft input is already shown.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #hideSoftInput
+ */
+ @UiThread
+ default void showSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Hide the soft input. May be called consecutively, even if the soft input is already hidden.
+ * This method is always called, even in viewless mode.
+ *
+ * @param session Session instance.
+ * @see #showSoftInput
+ */
+ @UiThread
+ default void hideSoftInput(@NonNull final GeckoSession session) {}
+
+ /**
+ * Update the soft input on the current selection. This method is <i>not</i> called in viewless
+ * mode.
+ *
+ * @param session Session instance.
+ * @param selStart Start offset of the selection.
+ * @param selEnd End offset of the selection.
+ * @param compositionStart Composition start offset, or -1 if there is no composition.
+ * @param compositionEnd Composition end offset, or -1 if there is no composition.
+ */
+ @UiThread
+ default void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {}
+
+ /**
+ * Update the soft input on the current extracted text, as requested through {@link
+ * android.view.inputmethod.InputConnection#getExtractedText}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param request The extract text request.
+ * @param text The extracted text.
+ */
+ @UiThread
+ default void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {}
+
+ /**
+ * Update the cursor-anchor information as requested through {@link
+ * android.view.inputmethod.InputConnection#requestCursorUpdates}. Consequently, this method is
+ * <i>not</i> called in viewless mode.
+ *
+ * @param session Session instance.
+ * @param info Cursor-anchor information.
+ */
+ @UiThread
+ default void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ TextInputDelegate.RESTART_REASON_FOCUS,
+ TextInputDelegate.RESTART_REASON_BLUR,
+ TextInputDelegate.RESTART_REASON_CONTENT_CHANGE
+ })
+ public @interface RestartReason {}
+
+ /* package */ void onSurfaceChanged(final @NonNull SurfaceInfo surfaceInfo) {
+ ThreadUtils.assertOnUiThread();
+
+ mWidth = surfaceInfo.mWidth;
+ mHeight = surfaceInfo.mHeight;
+ 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<HistoryItem> {
+ /**
+ * Get the current index in browser history.
+ *
+ * @return An int representing the current index in browser history.
+ */
+ @AnyThread
+ default int getCurrentIndex() {
+ throw new UnsupportedOperationException(
+ "HistoryList.getCurrentIndex() called on invalid object.");
+ }
+ }
+
+ // These flags are similar to those in `IHistory::LoadFlags`, but we use
+ // different values to decouple GeckoView from Gecko changes. These
+ // should be kept in sync with `GeckoViewHistory::GeckoViewVisitFlags`.
+
+ /** The URL was visited a top-level window. */
+ final int VISIT_TOP_LEVEL = 1 << 0;
+
+ /** The URL is the target of a temporary redirect. */
+ final int VISIT_REDIRECT_TEMPORARY = 1 << 1;
+
+ /** The URL is the target of a permanent redirect. */
+ final int VISIT_REDIRECT_PERMANENT = 1 << 2;
+
+ /** The URL is temporarily redirected to another URL. */
+ final int VISIT_REDIRECT_SOURCE = 1 << 3;
+
+ /** The URL is permanently redirected to another URL. */
+ final int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4;
+
+ /** The URL failed to load due to a client or server error. */
+ final int VISIT_UNRECOVERABLE_ERROR = 1 << 5;
+
+ /**
+ * Records a visit to a page.
+ *
+ * @param session The session where the URL was visited.
+ * @param url The visited URL.
+ * @param lastVisitedURL The last visited URL in this session, to detect redirects and reloads.
+ * @param flags Additional flags for this visit, including redirect and error statuses. This is
+ * a bitmask of one or more {@link #VISIT_TOP_LEVEL VISIT_*} flags, OR-ed together.
+ * @return A {@link GeckoResult} completed with a boolean indicating whether to highlight links
+ * for the new URL as visited ({@code true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<Boolean> onVisited(
+ @NonNull final GeckoSession session,
+ @NonNull final String url,
+ @Nullable final String lastVisitedURL,
+ @VisitFlags final int flags) {
+ return null;
+ }
+
+ /**
+ * Returns the visited statuses for links on a page. This is used to highlight links as visited
+ * or unvisited, for example.
+ *
+ * @param session The session requesting the visited statuses.
+ * @param urls A list of URLs to check.
+ * @return A {@link GeckoResult} completed with a list of booleans corresponding to the URLs in
+ * {@code urls}, and indicating whether to highlight links for each URL as visited ({@code
+ * true}) or unvisited ({@code false}).
+ */
+ @UiThread
+ default @Nullable GeckoResult<boolean[]> getVisited(
+ @NonNull final GeckoSession session, @NonNull final String[] urls) {
+ return null;
+ }
+
+ @UiThread
+ @SuppressWarnings("checkstyle:javadocmethod")
+ default void onHistoryStateChange(
+ @NonNull final GeckoSession session, @NonNull final HistoryList historyList) {}
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ HistoryDelegate.VISIT_TOP_LEVEL,
+ HistoryDelegate.VISIT_REDIRECT_TEMPORARY,
+ HistoryDelegate.VISIT_REDIRECT_PERMANENT,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE,
+ HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT,
+ HistoryDelegate.VISIT_UNRECOVERABLE_ERROR
+ })
+ public @interface VisitFlags {}
+
+ private Autofill.Support getAutofillSupport() {
+ return mAutofillSupport;
+ }
+
+ /**
+ * Sets the autofill delegate for this session.
+ *
+ * @param delegate An instance of {@link Autofill.Delegate}.
+ */
+ @UiThread
+ public void setAutofillDelegate(final @Nullable Autofill.Delegate delegate) {
+ getAutofillSupport().setDelegate(delegate);
+ }
+
+ /**
+ * @return The current {@link Autofill.Delegate} for this session, if any.
+ */
+ @UiThread
+ public @Nullable Autofill.Delegate getAutofillDelegate() {
+ return getAutofillSupport().getDelegate();
+ }
+
+ /**
+ * Provides an autofill structure similar to {@link
+ * View#onProvideAutofillVirtualStructure(ViewStructure, int)} , but does not rely on {@link
+ * ViewStructure} to build the tree. This is useful for apps that want to provide autofill
+ * functionality without using the Android autofill system or requiring API 26.
+ *
+ * @return The elements available for autofill.
+ */
+ @UiThread
+ public @NonNull Autofill.Session getAutofillSession() {
+ return getAutofillSupport().getAutofillSession();
+ }
+
+ /**
+ * Saves a PDF of the currently displayed page.
+ *
+ * @return A GeckoResult with an InputStream containing the PDF. The result could
+ * CompleteExceptionally with a {@link GeckoPrintException}s, if there are any issues while
+ * generating the PDF.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<InputStream> saveAsPdf() {
+ 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<InputStream> saveAsPdfByBrowsingContext(
+ final @Nullable Long browsingContextId) {
+ final GeckoResult<InputStream> geckoResult = new GeckoResult<>();
+ final GeckoSession self = this;
+ this.isPdfJs()
+ .then(
+ new GeckoResult.OnValueListener<Boolean, Void>() {
+ @Override
+ public GeckoResult<Void> 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.
+ *
+ * <p>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<Boolean> 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<Delegate> implements BundleEventListener {
+
+ private static final String LOGTAG = "GeckoSessionHandler";
+ private static final boolean DEBUG = false;
+
+ private final String mModuleName;
+ private final String[] mEvents;
+ private Delegate mDelegate;
+ private boolean mRegisteredListeners;
+
+ /* package */ GeckoSessionHandler(
+ final String module, final GeckoSession session, final String[] events) {
+ this(module, session, events, new String[] {});
+ }
+
+ /* package */ GeckoSessionHandler(
+ final String module,
+ final GeckoSession session,
+ final String[] events,
+ final String[] defaultEvents) {
+ session.handlersCount++;
+
+ mModuleName = module;
+ mEvents = events;
+
+ // Default events are always active
+ session.getEventDispatcher().registerUiThreadListener(this, defaultEvents);
+ }
+
+ public Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ public void setDelegate(final Delegate delegate, final GeckoSession session) {
+ if (mDelegate == delegate) {
+ return;
+ }
+
+ mDelegate = delegate;
+
+ if (!mRegisteredListeners && delegate != null) {
+ session.getEventDispatcher().registerUiThreadListener(this, mEvents);
+ mRegisteredListeners = true;
+ }
+
+ // If session is not open, we will update module state during session opening.
+ if (!session.isOpen()) {
+ return;
+ }
+
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("module", mModuleName);
+ msg.putBoolean("enabled", isEnabled());
+ session.getEventDispatcher().dispatch("GeckoView:UpdateModuleState", msg);
+ }
+
+ public String getName() {
+ return mModuleName;
+ }
+
+ public boolean isEnabled() {
+ return mDelegate != null;
+ }
+
+ @Override
+ @UiThread
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, mModuleName + " handleMessage: event = " + event);
+ }
+
+ if (mDelegate != null) {
+ handleMessage(mDelegate, event, message, callback);
+ } else {
+ handleDefaultMessage(event, message, callback);
+ }
+ }
+
+ protected abstract void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback);
+
+ protected void handleDefaultMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (callback != null) {
+ callback.sendError("No delegate registered");
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java
new file mode 100644
index 0000000000..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.
+ *
+ * <p>Warning: Storage data is collected persistently for each context, to delete context data,
+ * call {@link StorageController#clearDataForSessionContext} for the given context.
+ *
+ * @param value The custom context ID. The default ID is null, which removes isolation for this
+ * instance.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder contextId(final @Nullable String value) {
+ mSettings.setContextId(value);
+ return this;
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param flag A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder useTrackingProtection(final boolean flag) {
+ mSettings.setUseTrackingProtection(flag);
+ return this;
+ }
+
+ /**
+ * Set the user agent mode.
+ *
+ * @param mode The mode to set the user agent to. Use one or more of the {@link
+ * GeckoSessionSettings#USER_AGENT_MODE_MOBILE GeckoSessionSettings.USER_AGENT_MODE_*}
+ * flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder userAgentMode(@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 &lt;meta&gt; viewport
+ * tag. All other pages will be rendered using a special desktop mode viewport, which has a width
+ * of 980 CSS px.
+ */
+ public static final int VIEWPORT_MODE_MOBILE = 0;
+
+ /**
+ * All pages will be rendered using the special desktop mode viewport, which has a width of 980
+ * CSS px, regardless of whether the page has a &lt;meta&gt; viewport tag specified or not.
+ */
+ public static final int VIEWPORT_MODE_DESKTOP = 1;
+
+ public static class Key<T> {
+ /* package */ final String name;
+ /* package */ final boolean initOnly;
+ /* package */ final Collection<T> values;
+
+ /* package */ Key(final String name) {
+ this(name, /* initOnly */ false, /* values */ null);
+ }
+
+ /* package */ Key(final String name, final boolean initOnly, final Collection<T> values) {
+ this.name = name;
+ this.initOnly = initOnly;
+ this.values = values;
+ }
+ }
+
+ /**
+ * Key to set the chrome window URI, or null to use default URI. Read-only once session is open.
+ */
+ private static final Key<String> CHROME_URI =
+ new Key<String>("chromeUri", /* initOnly */ true, /* values */ null);
+
+ /** Key to set the window screen ID, or 0 to use default ID. Read-only once session is open. */
+ private static final Key<Integer> SCREEN_ID =
+ new Key<Integer>("screenId", /* initOnly */ true, /* values */ null);
+
+ /** Key to enable and disable tracking protection. */
+ private static final Key<Boolean> USE_TRACKING_PROTECTION =
+ new Key<Boolean>("useTrackingProtection");
+
+ /** Key to enable and disable private mode browsing. Read-only once session is open. */
+ private static final Key<Boolean> USE_PRIVATE_MODE =
+ new Key<Boolean>("usePrivateMode", /* initOnly */ true, /* values */ null);
+
+ /** Key to specify which user agent mode we should use. */
+ private static final Key<Integer> USER_AGENT_MODE =
+ new Key<Integer>(
+ "userAgentMode", /* initOnly */
+ false,
+ Arrays.asList(USER_AGENT_MODE_MOBILE, USER_AGENT_MODE_DESKTOP, USER_AGENT_MODE_VR));
+
+ /**
+ * Key to specify the user agent override string. Set value to null to use the user agent
+ * specified by USER_AGENT_MODE.
+ */
+ private static final Key<String> USER_AGENT_OVERRIDE =
+ new Key<String>("userAgentOverride", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify which viewport mode we should use. */
+ private static final Key<Integer> VIEWPORT_MODE =
+ new Key<Integer>(
+ "viewportMode", /* initOnly */
+ false,
+ Arrays.asList(VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP));
+
+ /** Key to specify which display-mode we should use. */
+ private static final Key<Integer> DISPLAY_MODE =
+ new Key<Integer>(
+ "displayMode", /* initOnly */
+ false,
+ Arrays.asList(
+ DISPLAY_MODE_BROWSER, DISPLAY_MODE_MINIMAL_UI,
+ DISPLAY_MODE_STANDALONE, DISPLAY_MODE_FULLSCREEN));
+
+ /** Key to specify if media should be suspended when the session is inactive. */
+ private static final Key<Boolean> SUSPEND_MEDIA_WHEN_INACTIVE =
+ new Key<Boolean>("suspendMediaWhenInactive", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify if JavaScript should be allowed on this session. */
+ private static final Key<Boolean> ALLOW_JAVASCRIPT =
+ new Key<Boolean>("allowJavascript", /* initOnly */ false, /* values */ null);
+
+ /** Key to specify if entire accessible tree should be exposed with no caching. */
+ private static final Key<Boolean> FULL_ACCESSIBILITY_TREE =
+ new Key<Boolean>("fullAccessibilityTree", /* initOnly */ false, /* values */ null);
+
+ /**
+ * Key to specify if this GeckoSession is a Popup or not. Popup sessions can paint over other
+ * sessions and are not exposed to the tabs WebExtension API.
+ */
+ private static final Key<Boolean> IS_POPUP =
+ new Key<Boolean>("isPopup", /* initOnly */ false, /* values */ null);
+
+ /** Internal Gecko key to specify the session context ID. Derived from `UNSAFE_CONTEXT_ID`. */
+ private static final Key<String> CONTEXT_ID =
+ new Key<String>("sessionContextId", /* initOnly */ true, /* values */ null);
+
+ /** User-provided key to specify the session context ID. */
+ private static final Key<String> UNSAFE_CONTEXT_ID =
+ new Key<String>("unsafeSessionContextId", /* initOnly */ true, /* values */ null);
+
+ private final GeckoSession mSession;
+ private final GeckoBundle mBundle;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings() {
+ this(null, null);
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoSessionSettings(final @NonNull GeckoSessionSettings settings) {
+ this(settings, null);
+ }
+
+ /* package */ GeckoSessionSettings(
+ final @Nullable GeckoSessionSettings settings, final @Nullable GeckoSession session) {
+ mSession = session;
+
+ if (settings != null) {
+ mBundle = new GeckoBundle(settings.mBundle);
+ return;
+ }
+
+ mBundle = new GeckoBundle();
+ mBundle.putString(CHROME_URI.name, null);
+ mBundle.putInt(SCREEN_ID.name, 0);
+ mBundle.putBoolean(USE_TRACKING_PROTECTION.name, false);
+ mBundle.putBoolean(USE_PRIVATE_MODE.name, false);
+ mBundle.putBoolean(SUSPEND_MEDIA_WHEN_INACTIVE.name, false);
+ mBundle.putBoolean(ALLOW_JAVASCRIPT.name, true);
+ mBundle.putBoolean(FULL_ACCESSIBILITY_TREE.name, false);
+ mBundle.putBoolean(IS_POPUP.name, false);
+ mBundle.putInt(USER_AGENT_MODE.name, USER_AGENT_MODE_MOBILE);
+ mBundle.putString(USER_AGENT_OVERRIDE.name, null);
+ mBundle.putInt(VIEWPORT_MODE.name, VIEWPORT_MODE_MOBILE);
+ mBundle.putInt(DISPLAY_MODE.name, DISPLAY_MODE_BROWSER);
+ mBundle.putString(CONTEXT_ID.name, null);
+ mBundle.putString(UNSAFE_CONTEXT_ID.name, null);
+ }
+
+ /**
+ * Set whether tracking protection should be enabled.
+ *
+ * @param value A flag determining whether tracking protection should be enabled. Default is
+ * false.
+ */
+ public void setUseTrackingProtection(final boolean value) {
+ setBoolean(USE_TRACKING_PROTECTION, value);
+ }
+
+ /**
+ * Set the privacy mode for this instance.
+ *
+ * @param value A flag determining whether Private Mode should be enabled. Default is false.
+ */
+ private void setUsePrivateMode(final boolean value) {
+ setBoolean(USE_PRIVATE_MODE, value);
+ }
+
+ /**
+ * Set whether to suspend the playing of media when the session is inactive.
+ *
+ * @param value A flag determining whether media should be suspended. Default is false.
+ */
+ public void setSuspendMediaWhenInactive(final boolean value) {
+ setBoolean(SUSPEND_MEDIA_WHEN_INACTIVE, value);
+ }
+
+ /**
+ * Set whether JavaScript support should be enabled.
+ *
+ * @param value A flag determining whether JavaScript should be enabled. Default is true.
+ */
+ public void setAllowJavascript(final boolean value) {
+ setBoolean(ALLOW_JAVASCRIPT, value);
+ }
+
+ /**
+ * Set whether the entire accessible tree should be exposed with no caching.
+ *
+ * @param value A flag determining full accessibility tree should be exposed. Default is false.
+ */
+ public void setFullAccessibilityTree(final boolean value) {
+ setBoolean(FULL_ACCESSIBILITY_TREE, value);
+ }
+
+ /* package */ void setIsPopup(final boolean value) {
+ setBoolean(IS_POPUP, value);
+ }
+
+ private void setBoolean(final Key<Boolean> key, final boolean value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putBoolean(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Whether tracking protection is enabled.
+ *
+ * @return true if tracking protection is enabled, false if not.
+ */
+ public boolean getUseTrackingProtection() {
+ return getBoolean(USE_TRACKING_PROTECTION);
+ }
+
+ /**
+ * Whether private mode is enabled.
+ *
+ * @return true if private mode is enabled, false if not.
+ */
+ public boolean getUsePrivateMode() {
+ return getBoolean(USE_PRIVATE_MODE);
+ }
+
+ /**
+ * The context ID for this session.
+ *
+ * @return The context ID for this session.
+ */
+ public @Nullable String getContextId() {
+ // Return the user-provided unsafe string.
+ return getString(UNSAFE_CONTEXT_ID);
+ }
+
+ /**
+ * Whether media will be suspended when the session is inactice.
+ *
+ * @return true if media will be suspended, false if not.
+ */
+ public boolean getSuspendMediaWhenInactive() {
+ return getBoolean(SUSPEND_MEDIA_WHEN_INACTIVE);
+ }
+
+ /**
+ * Whether javascript execution is allowed.
+ *
+ * @return true if javascript execution is allowed, false if not.
+ */
+ public boolean getAllowJavascript() {
+ return getBoolean(ALLOW_JAVASCRIPT);
+ }
+
+ /**
+ * Whether entire accessible tree is exposed with no caching.
+ *
+ * @return true if accessibility tree is exposed, false if not.
+ */
+ public boolean getFullAccessibilityTree() {
+ return getBoolean(FULL_ACCESSIBILITY_TREE);
+ }
+
+ /* package */ boolean getIsPopup() {
+ return getBoolean(IS_POPUP);
+ }
+
+ private boolean getBoolean(final Key<Boolean> key) {
+ synchronized (mBundle) {
+ return mBundle.getBoolean(key.name);
+ }
+ }
+
+ /**
+ * Set the screen id.
+ *
+ * @param value The screen id.
+ */
+ private void setScreenId(final int value) {
+ setInt(SCREEN_ID, value);
+ }
+
+ /**
+ * Specify which user agent mode we should use
+ *
+ * @param value One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public void setUserAgentMode(@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<Integer> key, final int value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putInt(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the window screen ID. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the window screen ID. 0 is the default ID.
+ */
+ public int getScreenId() {
+ return getInt(SCREEN_ID);
+ }
+
+ /**
+ * The current user agent Mode
+ *
+ * @return One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE
+ * GeckoSessionSettings.USER_AGENT_MODE_*} flags.
+ */
+ public @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<Integer> key) {
+ synchronized (mBundle) {
+ return mBundle.getInt(key.name);
+ }
+ }
+
+ /**
+ * Set the chrome URI.
+ *
+ * @param value The URI to set the Chrome URI to.
+ */
+ private void setChromeUri(final @NonNull String value) {
+ setString(CHROME_URI, value);
+ }
+
+ /**
+ * Specify the user agent override string. Set value to null to use the user agent specified by
+ * USER_AGENT_MODE.
+ *
+ * @param value The string to override the user agent with.
+ */
+ public void setUserAgentOverride(final @Nullable String value) {
+ setString(USER_AGENT_OVERRIDE, value);
+ }
+
+ private void setContextId(final @Nullable String value) {
+ setString(UNSAFE_CONTEXT_ID, value);
+ setString(CONTEXT_ID, StorageController.createSafeSessionContextId(value));
+ }
+
+ private void setString(final Key<String> key, final String value) {
+ synchronized (mBundle) {
+ if (valueChangedLocked(key, value)) {
+ mBundle.putString(key.name, value);
+ dispatchUpdate();
+ }
+ }
+ }
+
+ /**
+ * Set the chrome window URI. Read-only once session is open. Use the {@link Builder} to set on
+ * session open.
+ *
+ * @return Key to set the chrome window URI, or null to use default URI.
+ */
+ public @Nullable String getChromeUri() {
+ return getString(CHROME_URI);
+ }
+
+ /**
+ * The user agent override string.
+ *
+ * @return The current user agent string or null if the agent is specified by {@link
+ * GeckoSessionSettings#USER_AGENT_MODE}
+ */
+ public @Nullable String getUserAgentOverride() {
+ return getString(USER_AGENT_OVERRIDE);
+ }
+
+ private String getString(final Key<String> key) {
+ synchronized (mBundle) {
+ return mBundle.getString(key.name);
+ }
+ }
+
+ /* package */ @NonNull
+ GeckoBundle toBundle() {
+ return new GeckoBundle(mBundle);
+ }
+
+ @Override
+ public String toString() {
+ return mBundle.toString();
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ return other instanceof GeckoSessionSettings
+ && mBundle.equals(((GeckoSessionSettings) other).mBundle);
+ }
+
+ @Override
+ public int hashCode() {
+ return mBundle.hashCode();
+ }
+
+ private <T> boolean valueChangedLocked(final Key<T> key, final T value) {
+ if (key.initOnly && mSession != null) {
+ throw new IllegalStateException("Read-only property");
+ } else if (key.values != null && !key.values.contains(value)) {
+ throw new IllegalArgumentException("Invalid value");
+ }
+
+ final Object old = mBundle.get(key.name);
+ return (old != value) && (old == null || !old.equals(value));
+ }
+
+ private void dispatchUpdate() {
+ if (mSession != null) {
+ mSession.getEventDispatcher().dispatch("GeckoView:UpdateSettings", toBundle());
+ }
+ }
+
+ @Override // Parcelable
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ public void writeToParcel(final @NonNull Parcel out, final int flags) {
+ mBundle.writeToParcel(out, flags);
+ }
+
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ mBundle.readFromParcel(source);
+ }
+
+ public static final Parcelable.Creator<GeckoSessionSettings> CREATOR =
+ new Parcelable.Creator<GeckoSessionSettings>() {
+ @Override
+ public GeckoSessionSettings createFromParcel(final Parcel in) {
+ final GeckoSessionSettings settings = new GeckoSessionSettings();
+ settings.readFromParcel(in);
+ return settings;
+ }
+
+ @Override
+ public GeckoSessionSettings[] newArray(final int size) {
+ return new GeckoSessionSettings[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
new file mode 100644
index 0000000000..754414a0ea
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java
@@ -0,0 +1,42 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * Interface for registering the external VR context with WebVR. The context must be registered
+ * before Gecko is started. This API is not intended for external consumption. To see an example of
+ * how it is used please see the <a href="https://github.com/MozillaReality/FirefoxReality"
+ * target="_blank">Firefox Reality browser</a>.
+ *
+ * @see <a href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">External VR Context</a>
+ */
+public class GeckoVRManager {
+ private static long mExternalContext;
+
+ private GeckoVRManager() {}
+
+ @WrapForJNI
+ private static synchronized long getExternalContext() {
+ return mExternalContext;
+ }
+
+ /**
+ * Sets the external VR context. The external VR context is defined <a
+ * href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h"
+ * target="_blank">here</a>.
+ *
+ * @param externalContext A pointer to the external VR context.
+ */
+ @AnyThread
+ public static synchronized void setExternalContext(final long externalContext) {
+ mExternalContext = externalContext;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java
new file mode 100644
index 0000000000..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<Autofill.Session> mAutofillSession = new WeakReference<>(null);
+
+ // Whether this GeckoView instance has a session that is no longer valid, e.g. because the session
+ // associated to this GeckoView was attached to a different GeckoView instance.
+ private boolean mIsSessionPoisoned = false;
+
+ private boolean mStateSaved;
+
+ private @Nullable SurfaceViewWrapper mSurfaceWrapper;
+
+ private boolean mIsResettingFocus;
+
+ private boolean mAutofillEnabled = true;
+
+ private GeckoSession.SelectionActionDelegate mSelectionActionDelegate;
+ private Autofill.Delegate mAutofillDelegate;
+ private @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<Bitmap> capturePixels() {
+ if (mDisplay == null) {
+ return GeckoResult.fromException(
+ new IllegalStateException("Display must be created before pixels can be captured"));
+ }
+
+ return mDisplay.capturePixels();
+ }
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context) {
+ super(context);
+ init();
+ }
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public GeckoView(final Context context, final AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ private static Activity getActivityFromContext(final Context outerContext) {
+ Context context = outerContext;
+ while (context instanceof ContextWrapper) {
+ if (context instanceof Activity) {
+ return (Activity) context;
+ }
+ context = ((ContextWrapper) context).getBaseContext();
+ }
+ return null;
+ }
+
+ private void init() {
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
+
+ // We are adding descendants to this LayerView, but we don't want the
+ // descendants to affect the way LayerView retains its focus.
+ setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS);
+
+ // This will stop PropertyAnimator from creating a drawing cache (i.e. a
+ // bitmap) from a SurfaceView, which is just not possible (the bitmap will be
+ // transparent).
+ setWillNotCacheDrawing(false);
+
+ mSurfaceWrapper = new SurfaceViewWrapper(getContext());
+ mSurfaceWrapper.setBackgroundColor(Color.WHITE);
+ addView(
+ mSurfaceWrapper.getView(),
+ new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+
+ mSurfaceWrapper.setListener(mDisplay);
+
+ final Activity activity = getActivityFromContext(getContext());
+ if (activity != null) {
+ mSelectionActionDelegate = new BasicSelectionActionDelegate(activity);
+ }
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ mAutofillDelegate = new AndroidAutofillDelegate();
+ } else {
+ // We don't support Autofill on SDK < 26
+ mAutofillDelegate = new Autofill.Delegate() {};
+ }
+ 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}.
+ *
+ * <p>This option offers the best performance at the price of not being able to animate GeckoView.
+ */
+ public static final int BACKEND_SURFACE_VIEW = 1;
+
+ /**
+ * This GeckoView instance will be backed by a {@link TextureView}.
+ *
+ * <p>This option offers worse performance compared to {@link #BACKEND_SURFACE_VIEW} but allows
+ * you to animate GeckoView or to paint a GeckoView on top of another GeckoView.
+ */
+ public static final int BACKEND_TEXTURE_VIEW = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({BACKEND_SURFACE_VIEW, BACKEND_TEXTURE_VIEW})
+ public @interface ViewBackend {}
+
+ /**
+ * Set which view should be used by this GeckoView instance to display content.
+ *
+ * <p>By default, GeckoView will use a {@link SurfaceView}.
+ *
+ * @param backend Any of {@link #BACKEND_SURFACE_VIEW BACKEND_*}.
+ */
+ public void setViewBackend(final @ViewBackend int backend) {
+ removeView(mSurfaceWrapper.getView());
+
+ if (backend == BACKEND_SURFACE_VIEW) {
+ mSurfaceWrapper.useSurfaceView(getContext());
+ } else if (backend == BACKEND_TEXTURE_VIEW) {
+ mSurfaceWrapper.useTextureView(getContext());
+ }
+
+ addView(mSurfaceWrapper.getView());
+
+ if (mSession != null) {
+ mSession.getMagnifier().setView(mSurfaceWrapper.getView());
+ }
+ }
+
+ /**
+ * Return whether the view should be pinned on the screen. When pinned, the view should not be
+ * moved on the screen due to animation, scrolling, etc. A common reason for the view being pinned
+ * is when the user is dragging a selection caret inside the view; normal user interaction would
+ * be disrupted in that case if the view was moved on screen.
+ *
+ * @return True if view should be pinned on the screen.
+ */
+ public boolean shouldPinOnScreen() {
+ ThreadUtils.assertOnUiThread();
+
+ return mDisplay.shouldPinOnScreen();
+ }
+
+ /**
+ * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion
+ * of the view. Tells gecko where to put bottom fixed elements so they are fully visible.
+ *
+ * <p>Optional call. The display's visible vertical space has changed. Must be called on the
+ * application main thread.
+ *
+ * @param clippingHeight The height of the bottom clipped space in screen pixels.
+ */
+ public void setVerticalClipping(final int clippingHeight) {
+ ThreadUtils.assertOnUiThread();
+
+ mDisplay.setVerticalClipping(clippingHeight);
+ }
+
+ /**
+ * Set the maximum height of the dynamic toolbar(s).
+ *
+ * <p>If there are two or more dynamic toolbars, the height value should be the total amount of
+ * the height of each dynamic toolbar.
+ *
+ * @param height The the maximum height of the dynamic toolbar(s).
+ */
+ public void setDynamicToolbarMaxHeight(final int height) {
+ mDisplay.setDynamicToolbarMaxHeight(height);
+ }
+
+ /* package */ void setActive(final boolean active) {
+ if (mSession != null) {
+ mSession.setActive(active);
+ }
+ }
+
+ // TODO: Bug 1670805 this should really be configurable
+ // Default dark color for about:blank, keep it in sync with PresShell.cpp
+ static final int DEFAULT_DARK_COLOR = 0xFF2A2A2E;
+
+ private int defaultColor() {
+ // If the app set a default color, just use that
+ if (mLastCoverColor != null) {
+ return mLastCoverColor;
+ }
+
+ if (mSession == null || !mSession.isOpen()) {
+ return Color.WHITE;
+ }
+
+ // ... otherwise use the prefers-color-scheme color
+ return mSession.getRuntime().usesDarkTheme() ? DEFAULT_DARK_COLOR : Color.WHITE;
+ }
+
+ /**
+ * Unsets the current session from this instance and returns it, if any. You must call this before
+ * {@link #setSession(GeckoSession)} if there is already an open session set for this instance.
+ *
+ * <p>Note: this method does not close the session and the session remains active. The caller is
+ * responsible for calling {@link GeckoSession#close()} when appropriate.
+ *
+ * @return The {@link GeckoSession} that was set for this instance. May be null.
+ */
+ @UiThread
+ public @Nullable GeckoSession releaseSession() {
+ ThreadUtils.assertOnUiThread();
+
+ if (mSession == null) {
+ return null;
+ }
+
+ final GeckoSession session = mSession;
+ mSession.releaseDisplay(mDisplay.release());
+ mSession.getOverscrollEdgeEffect().setInvalidationCallback(null);
+ mSession.getOverscrollEdgeEffect().setSession(null);
+ mSession.getCompositorController().setFirstPaintCallback(null);
+
+ if (mSession.getAccessibility().getView() == this) {
+ mSession.getAccessibility().setView(null);
+ }
+
+ if (mSession.getTextInput().getView() == this) {
+ mSession.getTextInput().setView(null);
+ }
+
+ if (mSession.getSelectionActionDelegate() == mSelectionActionDelegate) {
+ mSession.setSelectionActionDelegate(null);
+ }
+
+ if (mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ }
+
+ if (mSession.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.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure.
+ *
+ * @param event A {@link MotionEvent}
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}.
+ */
+ public @NonNull GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+ requestFocus();
+ }
+
+ if (mSession == null) {
+ return GeckoResult.fromValue(
+ new PanZoomController.InputResultDetail(
+ PanZoomController.INPUT_RESULT_UNHANDLED,
+ PanZoomController.SCROLLABLE_FLAG_NONE,
+ PanZoomController.OVERSCROLL_FLAG_NONE));
+ }
+
+ // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be
+ // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop.
+ return mSession.getPanZoomController().onTouchEventForDetailResult(event);
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(final MotionEvent event) {
+ if (AndroidGamepadManager.handleMotionEvent(event)) {
+ return true;
+ }
+
+ if (mSession == null) {
+ return true;
+ }
+
+ if (mSession.getAccessibility().onMotionEvent(event)) {
+ return true;
+ }
+
+ mSession.getPanZoomController().onMotionEvent(event);
+ return true;
+ }
+
+ @Override
+ public void onProvideAutofillVirtualStructure(final ViewStructure structure, final int flags) {
+ if (mSession == null) {
+ return;
+ }
+
+ final Autofill.Session autofillSession = mSession.getAutofillSession();
+
+ // Let's store the session here in case we need to autofill it later
+ mAutofillSession = new WeakReference<>(autofillSession);
+ autofillSession.fillViewStructure(this, structure, flags);
+ }
+
+ @Override
+ @TargetApi(26)
+ public void autofill(@NonNull final SparseArray<AutofillValue> values) {
+ // Note: we can't use mSession.getAutofillSession() because the app might have swapped
+ // the session under us between the onProvideAutofillVirtualStructure and this call
+ // so mSession could refer to a different session or we might not have a session at all.
+ final Autofill.Session session = mAutofillSession.get();
+ if (session == null) {
+ return;
+ }
+ final SparseArray<CharSequence> strValues = new SparseArray<>(values.size());
+ for (int i = 0; i < values.size(); i++) {
+ final AutofillValue value = values.valueAt(i);
+ if (value.isText()) {
+ // Only text is currently supported.
+ strValues.put(values.keyAt(i), value.getTextValue());
+ }
+ }
+ session.autofill(strValues);
+ }
+
+ @Override
+ public boolean isVisibleToUserForAutofill(final int virtualId) {
+ // If autofill service works with compatibility mode,
+ // View.isVisibleToUserForAutofill walks through the accessibility nodes.
+ // This override avoids it.
+ return true;
+ }
+
+ /**
+ * Request a {@link Bitmap} of the visible portion of the web page currently being rendered.
+ *
+ * <p>See {@link GeckoDisplay#capturePixels} for more details.
+ *
+ * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and
+ * size information of the currently visible rendered web page.
+ */
+ @UiThread
+ public @NonNull GeckoResult<Bitmap> capturePixels() {
+ return mDisplay.capturePixels();
+ }
+
+ /**
+ * Sets whether or not this View participates in Android autofill.
+ *
+ * <p>When enabled, this will set an {@link Autofill.Delegate} on the {@link GeckoSession} for
+ * this instance.
+ *
+ * @param enabled Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public void setAutofillEnabled(final boolean enabled) {
+ mAutofillEnabled = enabled;
+
+ if (mSession != null) {
+ if (!enabled && mSession.getAutofillDelegate() == mAutofillDelegate) {
+ mSession.setAutofillDelegate(null);
+ } else if (enabled) {
+ mSession.setAutofillDelegate(mAutofillDelegate);
+ }
+ }
+ }
+
+ /**
+ * @return Whether or not Android autofill is enabled for this view.
+ */
+ @TargetApi(26)
+ public boolean getAutofillEnabled() {
+ return mAutofillEnabled;
+ }
+
+ @TargetApi(26)
+ private class AndroidAutofillDelegate implements Autofill.Delegate {
+ AutofillManager mAutofillManager;
+ boolean mDisabled = false;
+
+ private void ensureAutofillManager() {
+ if (mDisabled || mAutofillManager != null) {
+ // Nothing to do
+ return;
+ }
+
+ mAutofillManager = GeckoView.this.getContext().getSystemService(AutofillManager.class);
+ if (mAutofillManager == null) {
+ // If we can't get a reference to the autofill manager, we cannot run the autofill service
+ mDisabled = true;
+ }
+ }
+
+ private Rect displayRectForId(
+ @NonNull final GeckoSession session, @Nullable final Autofill.Node node) {
+ if (node == null) {
+ return new Rect(0, 0, 0, 0);
+ }
+
+ if (!node.getScreenRect().isEmpty()) {
+ return node.getScreenRect();
+ }
+
+ final Matrix matrix = new Matrix();
+ final RectF rectF = new RectF(node.getDimensions());
+ session.getPageToScreenMatrix(matrix);
+ matrix.mapRect(rectF);
+
+ final Rect screenRect = new Rect();
+ rectF.roundOut(screenRect);
+ return screenRect;
+ }
+
+ @Override
+ public void onNodeBlur(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node prev,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, data.getId());
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewExited: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeAdd(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ if (!mSession.getAutofillSession().isVisible(node)) {
+ return;
+ }
+ final Autofill.Node focused = mSession.getAutofillSession().getFocused();
+ // We must have a focused node because |node| is visible
+ Objects.requireNonNull(focused);
+
+ final Autofill.NodeData focusedData = mSession.getAutofillSession().dataFor(focused);
+ Objects.requireNonNull(focusedData);
+
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewExited(GeckoView.this, focusedData.getId());
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, focusedData.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(
+ LOGTAG,
+ "Failed to call AutofillManager.notifyViewExited or AutofillManager.notifyViewEntered: ",
+ e);
+ }
+ }
+
+ @Override
+ public void onNodeFocus(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node focused,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyViewEntered(
+ GeckoView.this, data.getId(), displayRectForId(session, focused));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewEntered: ", e);
+ }
+ }
+
+ @Override
+ public void onNodeRemove(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {}
+
+ @Override
+ public void onNodeUpdate(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.notifyValueChanged(
+ GeckoView.this, data.getId(), AutofillValue.forText(data.getValue()));
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.notifyValueChanged: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCancel(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionCommit(
+ final @NonNull GeckoSession session,
+ final @NonNull Autofill.Node node,
+ final @NonNull Autofill.NodeData data) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ mAutofillManager.commit();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.commit: ", e);
+ }
+ }
+
+ @Override
+ public void onSessionStart(final @NonNull GeckoSession session) {
+ ensureAutofillManager();
+ if (mAutofillManager == null) {
+ return;
+ }
+ try {
+ // This line seems necessary for auto-fill to work on the initial page.
+ mAutofillManager.cancel();
+ } catch (final SecurityException e) {
+ Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e);
+ }
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * <p>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<InputStream> 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<Boolean> onPrintWithStatus(@NonNull final InputStream pdfStream) {
+ final GeckoResult<Boolean> isDialogFinished = new GeckoResult<Boolean>();
+ 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<Boolean> 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<Boolean> 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.
+ *
+ * <p>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:
+ *
+ * <pre>
+ * final GeckoWebExecutor executor = new GeckoWebExecutor();
+ *
+ * final GeckoResult&lt;WebResponse&gt; result = executor.fetch(
+ * new WebRequest.Builder("https://example.org/json")
+ * .header("Accept", "application/json")
+ * .build());
+ *
+ * result.then(response -&gt; {
+ * // Do something with response
+ * });
+ * </pre>
+ */
+@AnyThread
+public class GeckoWebExecutor {
+ // We don't use this right now because we access GeckoThread directly, but
+ // it's future-proofing for a world where we allow multiple GeckoRuntimes.
+ private final GeckoRuntime mRuntime;
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Fetch")
+ private static native void nativeFetch(
+ WebRequest request, int flags, GeckoResult<WebResponse> result);
+
+ @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve")
+ private static native void nativeResolve(String host, GeckoResult<InetAddress[]> result);
+
+ @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult")
+ private static ByteBuffer createByteBuffer(final int capacity) {
+ return ByteBuffer.allocateDirect(capacity);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ FETCH_FLAGS_NONE,
+ FETCH_FLAGS_ANONYMOUS,
+ FETCH_FLAGS_NO_REDIRECTS,
+ FETCH_FLAGS_PRIVATE,
+ FETCH_FLAGS_STREAM_FAILURE_TEST,
+ })
+ public @interface FetchFlags {}
+
+ /** No special treatment. */
+ public static final int FETCH_FLAGS_NONE = 0;
+
+ /** Don't send cookies or other user data along with the request. */
+ @WrapForJNI public static final int FETCH_FLAGS_ANONYMOUS = 1;
+
+ /** Don't automatically follow redirects. */
+ @WrapForJNI public static final int FETCH_FLAGS_NO_REDIRECTS = 1 << 1;
+
+ // There was supposed to be another flag, which we then decided not to implement.
+ // That's the reason there's no value 1 << 2, and it can absolutely be used :)
+
+ /** Associates this download with the current private browsing session */
+ @WrapForJNI public static final int FETCH_FLAGS_PRIVATE = 1 << 3;
+
+ /** This flag causes a read error in the {@link WebResponse} body. Useful for testing. */
+ @WrapForJNI public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1 << 10;
+
+ /**
+ * Create a new GeckoWebExecutor instance.
+ *
+ * @param runtime A GeckoRuntime instance
+ */
+ public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) {
+ mRuntime = runtime;
+ }
+
+ /**
+ * Send the given {@link WebRequest}.
+ *
+ * @param request A {@link WebRequest} instance
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(final @NonNull WebRequest request) {
+ return fetch(request, FETCH_FLAGS_NONE);
+ }
+
+ /**
+ * Send the given {@link WebRequest} with specified flags.
+ *
+ * @param request A {@link WebRequest} instance
+ * @param flags The specified flags. One or more of the {@link #FETCH_FLAGS_NONE FETCH_*} flags.
+ * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the
+ * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a
+ * {@link WebRequestError}.
+ * @throws IllegalArgumentException if request is null or otherwise unusable.
+ */
+ public @NonNull GeckoResult<WebResponse> fetch(
+ final @NonNull WebRequest request, final @FetchFlags int flags) {
+ if (request.body != null && !request.body.isDirect()) {
+ throw new IllegalArgumentException("Request body must be a direct ByteBuffer");
+ }
+
+ if (request.cacheMode < WebRequest.CACHE_MODE_FIRST
+ || request.cacheMode > WebRequest.CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+
+ final String uri = request.uri.toLowerCase(Locale.ROOT);
+ // We don't need to fully validate the URI here, just a sanity check
+ if (!uri.startsWith("http") && !uri.startsWith("blob")) {
+ throw new IllegalArgumentException(
+ "Unsupported URI scheme: " + (uri.length() > 10 ? uri.substring(0, 10) : uri));
+ }
+
+ final GeckoResult<WebResponse> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeFetch(request, flags, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeFetch",
+ WebRequest.class,
+ request,
+ flags,
+ GeckoResult.class,
+ result);
+ }
+
+ return result;
+ }
+
+ /**
+ * Resolves the specified host name.
+ *
+ * @param host An Internet host name, e.g. mozilla.org.
+ * @return A {@link GeckoResult} which will be fulfilled with a {@link List} of {@link
+ * InetAddress}. In case of failure, the {@link GeckoResult} will be completed exceptionally
+ * with a {@link java.net.UnknownHostException}.
+ */
+ public @NonNull GeckoResult<InetAddress[]> resolve(final @NonNull String host) {
+ final GeckoResult<InetAddress[]> result = new GeckoResult<>();
+
+ if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) {
+ nativeResolve(host, result);
+ } else {
+ GeckoThread.queueNativeCallUntil(
+ GeckoThread.State.PROFILE_READY,
+ this,
+ "nativeResolve",
+ String.class,
+ host,
+ GeckoResult.class,
+ result);
+ }
+ return result;
+ }
+
+ /**
+ * This causes a speculative connection to be made to the host in the specified URI. This is
+ * useful if an app thinks it may be making a request to that host in the near future. If no
+ * request is made, the connection will be cleaned up after an unspecified amount of time.
+ *
+ * @param uri A URI String.
+ */
+ public void speculativeConnect(final @NonNull String uri) {
+ GeckoThread.speculativeConnect(uri);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
new file mode 100644
index 0000000000..34bf6b0161
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java
@@ -0,0 +1,54 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.graphics.Bitmap;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ImageResource;
+
+/** Represents an Web API image resource as used in web app manifests and media session metadata. */
+@AnyThread
+public class Image {
+ private final ImageResource.Collection mCollection;
+
+ /* package */ Image(final ImageResource.Collection collection) {
+ mCollection = collection;
+ }
+
+ /* package */ static Image fromSizeSrcBundle(final GeckoBundle bundle) {
+ return new Image(ImageResource.Collection.fromSizeSrcBundle(bundle));
+ }
+
+ /**
+ * Get the best version of this image for size <code>size</code>. Embedders are encouraged to
+ * cache the result of this method keyed with this instance.
+ *
+ * @param size pixel size at which this image will be displayed at.
+ * @return A {@link GeckoResult} that resolves to the bitmap when ready. Will resolve
+ * exceptionally to {@link ImageProcessingException} if the image cannot be processed.
+ */
+ @NonNull
+ public GeckoResult<Bitmap> getBitmap(final int size) {
+ return mCollection.getBitmap(size);
+ }
+
+ /** Thrown whenever an image cannot be processed by {@link #getBitmap} */
+ @WrapForJNI
+ public static class ImageProcessingException extends RuntimeException {
+ /**
+ * Build an instance of this class.
+ *
+ * @param message description of the error.
+ */
+ public ImageProcessingException(final String message) {
+ super(message);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java
new file mode 100644
index 0000000000..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 <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session
+ * API</a>
+ */
+@UiThread
+public class MediaSession {
+ private static final String LOGTAG = "MediaSession";
+ private static final boolean DEBUG = false;
+
+ private final GeckoSession mSession;
+ private boolean mIsActive;
+
+ protected MediaSession(final GeckoSession session) {
+ mSession = session;
+ }
+
+ /**
+ * Get whether the media session is active. Only active media sessions can be controlled.
+ *
+ * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link
+ * Delegate#onDeactivated} respectively.
+ *
+ * @see MediaSession.Delegate#onActivated
+ * @see MediaSession.Delegate#onDeactivated
+ * @return True if this media session is active, false otherwise.
+ */
+ public boolean isActive() {
+ return mIsActive;
+ }
+
+ /* package */ void setActive(final boolean active) {
+ mIsActive = active;
+ }
+
+ /** Pause playback for the media session. */
+ public void pause() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "pause");
+ }
+ mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null);
+ }
+
+ /** Stop playback for the media session. */
+ public void stop() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "stop");
+ }
+ mSession.getEventDispatcher().dispatch(STOP_EVENT, null);
+ }
+
+ /** Start playback for the media session. */
+ public void play() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "play");
+ }
+ mSession.getEventDispatcher().dispatch(PLAY_EVENT, null);
+ }
+
+ /**
+ * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use
+ * fast seeking for the last or only call in a sequence.
+ *
+ * @param time The time in seconds to move the playback time to.
+ * @param fast Whether fast seeking should be used.
+ */
+ public void seekTo(final double time, final boolean fast) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast);
+ }
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putDouble("time", time);
+ bundle.putBoolean("fast", fast);
+ mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle);
+ }
+
+ /** Seek forward by a sensible number of seconds. */
+ public void seekForward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekForward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle);
+ }
+
+ /** Seek backward by a sensible number of seconds. */
+ public void seekBackward() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "seekBackward");
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putDouble("offset", 0.0);
+ mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle);
+ }
+
+ /**
+ * Select and play the next track. Move playback to the next item in the playlist when supported.
+ */
+ public void nextTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "nextTrack");
+ }
+ mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null);
+ }
+
+ /**
+ * Select and play the previous track. Move playback to the previous item in the playlist when
+ * supported.
+ */
+ public void previousTrack() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "previousTrack");
+ }
+ mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null);
+ }
+
+ /** Skip the advertisement that is currently playing. */
+ public void skipAd() {
+ if (DEBUG) {
+ Log.d(LOGTAG, "skipAd");
+ }
+ mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null);
+ }
+
+ /**
+ * Set whether audio should be muted. Muting audio is supported by default and does not require
+ * the media session to be active.
+ *
+ * @param mute True if audio for this media session should be muted.
+ */
+ public void muteAudio(final boolean mute) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "muteAudio=" + mute);
+ }
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("mute", mute);
+ mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle);
+ }
+
+ /** Implement this delegate to receive media session events. */
+ @UiThread
+ public interface Delegate {
+ /**
+ * Notify that the given media session has become active. It is always the first event
+ * dispatched for a new or previously deactivated media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onActivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that the given media session has become inactive. Inactive media sessions can not be
+ * controlled.
+ *
+ * <p>TODO: Add settings links to control behavior.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onDeactivated(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated metadata. Metadata may be provided by content via the DOM API or by
+ * GeckoView when not availble.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param meta The updated metadata.
+ */
+ default void onMetadata(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final Metadata meta) {}
+
+ /**
+ * Notify on updated supported features. Unsupported actions will have no effect.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param features A combination of {@link Feature}.
+ */
+ default void onFeatures(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @MSFeature final long features) {}
+
+ /**
+ * Notify that playback has started for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPlay(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has paused for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onPause(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify that playback has stopped for the given media session.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ */
+ default void onStop(
+ @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {}
+
+ /**
+ * Notify on updated position state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param state An instance of {@link PositionState}.
+ */
+ default void onPositionState(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ @NonNull final PositionState state) {}
+
+ /**
+ * Notify on changed fullscreen state.
+ *
+ * @param session The associated GeckoSession.
+ * @param mediaSession The media session for the given GeckoSession.
+ * @param enabled True when this media session in in fullscreen mode.
+ * @param meta An instance of {@link ElementMetadata}, if enabled.
+ */
+ default void onFullscreen(
+ @NonNull final GeckoSession session,
+ @NonNull final MediaSession mediaSession,
+ final boolean enabled,
+ @Nullable final ElementMetadata meta) {}
+ }
+
+ /** The representation of a media element's metadata. */
+ public static class ElementMetadata {
+ /** The media source URI. */
+ public final @Nullable String source;
+
+ /** The duration of the media in seconds. 0.0 if unknown. */
+ public final double duration;
+
+ /** The width of the video in device pixels. 0 if unknown. */
+ public final long width;
+
+ /** The height of the video in device pixels. 0 if unknown. */
+ public final long height;
+
+ /** The number of audio tracks contained in this element. */
+ public final int audioTrackCount;
+
+ /** The number of video tracks contained in this element. */
+ public final int videoTrackCount;
+
+ /**
+ * ElementMetadata constructor.
+ *
+ * @param source The media URI.
+ * @param duration The media duration in seconds.
+ * @param width The video width in device pixels.
+ * @param height The video height in device pixels.
+ * @param audioTrackCount The audio track count.
+ * @param videoTrackCount The video track count.
+ */
+ public ElementMetadata(
+ @Nullable final String source,
+ final double duration,
+ final long width,
+ final long height,
+ final int audioTrackCount,
+ final int videoTrackCount) {
+ this.source = source;
+ this.duration = duration;
+ this.width = width;
+ this.height = height;
+ this.audioTrackCount = audioTrackCount;
+ this.videoTrackCount = videoTrackCount;
+ }
+
+ /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) {
+ // Sync with MediaUtils.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<MediaSession.Delegate> {
+
+ private final GeckoSession mSession;
+ private final MediaSession mMediaSession;
+
+ public Handler(final GeckoSession session) {
+ super(
+ "GeckoViewMediaControl",
+ session,
+ new String[] {
+ ACTIVATED_EVENT,
+ DEACTIVATED_EVENT,
+ METADATA_EVENT,
+ FULLSCREEN_EVENT,
+ POSITION_STATE_EVENT,
+ PLAYBACK_NONE_EVENT,
+ PLAYBACK_PAUSED_EVENT,
+ PLAYBACK_PLAYING_EVENT,
+ FEATURES_EVENT,
+ });
+ mSession = session;
+ mMediaSession = new MediaSession(session);
+ }
+
+ @Override
+ public void handleMessage(
+ final Delegate delegate,
+ final String event,
+ final GeckoBundle message,
+ final EventCallback callback) {
+ if (DEBUG) {
+ Log.d(LOGTAG, "handleMessage " + event);
+ }
+
+ if (ACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(true);
+ delegate.onActivated(mSession, mMediaSession);
+ } else if (DEACTIVATED_EVENT.equals(event)) {
+ mMediaSession.setActive(false);
+ delegate.onDeactivated(mSession, mMediaSession);
+ } else if (METADATA_EVENT.equals(event)) {
+ final Metadata meta = Metadata.fromBundle(message.getBundle("metadata"));
+ delegate.onMetadata(mSession, mMediaSession, meta);
+ } else if (POSITION_STATE_EVENT.equals(event)) {
+ final PositionState state = PositionState.fromBundle(message.getBundle("state"));
+ delegate.onPositionState(mSession, mMediaSession, state);
+ } else if (PLAYBACK_NONE_EVENT.equals(event)) {
+ delegate.onStop(mSession, mMediaSession);
+ } else if (PLAYBACK_PAUSED_EVENT.equals(event)) {
+ delegate.onPause(mSession, mMediaSession);
+ } else if (PLAYBACK_PLAYING_EVENT.equals(event)) {
+ delegate.onPlay(mSession, mMediaSession);
+ } else if (FEATURES_EVENT.equals(event)) {
+ final long features = Feature.fromBundle(message.getBundle("features"));
+ delegate.onFeatures(mSession, mMediaSession, features);
+ } else if (FULLSCREEN_EVENT.equals(event)) {
+ 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<AllowOrDeny> onOrientationLock(@NonNull final int aOrientation) {
+ return null;
+ }
+
+ /** Called whenever the orientation should be unlocked. */
+ @Nullable
+ default void onOrientationUnlock() {}
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java
new file mode 100644
index 0000000000..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.
+ *
+ * <p>For example, a {@link PanZoomController.InputResultDetail#handledResult} value of {@link
+ * PanZoomController#INPUT_RESULT_HANDLED} and {@link
+ * PanZoomController.InputResultDetail#overscrollDirections} of {@link
+ * PanZoomController#OVERSCROLL_FLAG_NONE} indicates that the event was consumed for a panning or
+ * zooming operation and that the website does not expect the browser to react to the touch event
+ * (say, by triggering the pull-to-refresh feature) even though the scroll container reached to
+ * the edge.
+ */
+ @WrapForJNI
+ public static class InputResultDetail {
+ protected InputResultDetail(
+ final @InputResult int handledResult,
+ final @ScrollableDirections int scrollableDirections,
+ final @OverscrollDirections int overscrollDirections) {
+ mHandledResult = handledResult;
+ mScrollableDirections = scrollableDirections;
+ mOverscrollDirections = overscrollDirections;
+ }
+
+ /**
+ * @return One of the {@link #INPUT_RESULT_UNHANDLED INPUT_RESULT_*} indicating how the event
+ * was handled.
+ */
+ @AnyThread
+ public @InputResult int handledResult() {
+ return mHandledResult;
+ }
+
+ /**
+ * @return an OR-ed value of {@link #SCROLLABLE_FLAG_NONE SCROLLABLE_FLAG_*} indicating which
+ * directions can be scrollable.
+ */
+ @AnyThread
+ public @ScrollableDirections int scrollableDirections() {
+ return mScrollableDirections;
+ }
+
+ /**
+ * @return an OR-ed value of {@link #OVERSCROLL_FLAG_NONE OVERSCROLL_FLAG_*} indicating which
+ * directions can be over-scrollable.
+ */
+ @AnyThread
+ public @OverscrollDirections int overscrollDirections() {
+ return mOverscrollDirections;
+ }
+
+ private final @InputResult int mHandledResult;
+ private final @ScrollableDirections int mScrollableDirections;
+ private final @OverscrollDirections int mOverscrollDirections;
+ }
+
+ private SynthesizedEventState mPointerState;
+
+ private ArrayList<Pair<Integer, MotionEvent>> mQueuedEvents;
+
+ private boolean mSynthesizedEvent = false;
+
+ @WrapForJNI
+ private static class MotionEventData {
+ public final int action;
+ public final int actionIndex;
+ public final long time;
+ public final int metaState;
+ public final int pointerId[];
+ public final int historySize;
+ public final long historicalTime[];
+ public final float historicalX[];
+ public final float historicalY[];
+ public final float historicalOrientation[];
+ public final float historicalPressure[];
+ public final float historicalToolMajor[];
+ public final float historicalToolMinor[];
+ public final float x[];
+ public final float y[];
+ public final float orientation[];
+ public final float pressure[];
+ public final float toolMajor[];
+ public final float toolMinor[];
+
+ public MotionEventData(final MotionEvent event) {
+ final int count = event.getPointerCount();
+ action = event.getActionMasked();
+ actionIndex = event.getActionIndex();
+ time = event.getEventTime();
+ metaState = event.getMetaState();
+ historySize = event.getHistorySize();
+ historicalTime = new long[historySize];
+ historicalX = new float[historySize * count];
+ historicalY = new float[historySize * count];
+ historicalOrientation = new float[historySize * count];
+ historicalPressure = new float[historySize * count];
+ historicalToolMajor = new float[historySize * count];
+ historicalToolMinor = new float[historySize * count];
+ pointerId = new int[count];
+ x = new float[count];
+ y = new float[count];
+ orientation = new float[count];
+ pressure = new float[count];
+ toolMajor = new float[count];
+ toolMinor = new float[count];
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ historicalTime[historyIndex] = event.getHistoricalEventTime(historyIndex);
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ for (int i = 0; i < count; i++) {
+ pointerId[i] = event.getPointerId(i);
+
+ for (int historyIndex = 0; historyIndex < historySize; historyIndex++) {
+ event.getHistoricalPointerCoords(i, historyIndex, coords);
+
+ final int historicalI = historyIndex * count + i;
+ historicalX[historicalI] = coords.x;
+ historicalY[historicalI] = coords.y;
+
+ historicalOrientation[historicalI] = coords.orientation;
+ historicalPressure[historicalI] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ historicalToolMajor[historicalI] = coords.toolMajor;
+ historicalToolMinor[historicalI] = coords.toolMinor;
+ }
+
+ event.getPointerCoords(i, coords);
+
+ x[i] = coords.x;
+ y[i] = coords.y;
+
+ orientation[i] = coords.orientation;
+ pressure[i] = coords.pressure;
+
+ // If we are converting to CSS pixels, we should adjust the radii as well.
+ toolMajor[i] = coords.toolMajor;
+ toolMinor[i] = coords.toolMinor;
+ }
+ }
+ }
+
+ /* package */ final class NativeProvider extends JNIObject {
+ @Override // JNIObject
+ protected void disposeNative() {
+ // Disposal happens in native code.
+ throw new UnsupportedOperationException();
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private native void handleMotionEvent(
+ MotionEventData eventData,
+ float screenX,
+ float screenY,
+ GeckoResult<InputResultDetail> result);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleScrollEvent(
+ long time, int metaState, float x, float y, float hScroll, float vScroll);
+
+ @WrapForJNI(calledFrom = "ui")
+ private native @InputResult int handleMouseEvent(
+ int action, long time, int metaState, float x, float y, int buttons);
+
+ @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread.
+ private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled);
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeTouchPoint(
+ final int pointerId,
+ final int eventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation) {
+ if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) {
+ throw new IllegalArgumentException("Pointer ID reserved for mouse");
+ }
+ synthesizeNativePointer(
+ InputDevice.SOURCE_TOUCHSCREEN,
+ pointerId,
+ eventType,
+ clientX,
+ clientY,
+ pressure,
+ orientation,
+ 0);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void synthesizeNativeMouseEvent(
+ final int eventType, final int clientX, final int clientY, final int button) {
+ synthesizeNativePointer(
+ InputDevice.SOURCE_MOUSE,
+ PointerInfo.RESERVED_MOUSE_POINTER_ID,
+ eventType,
+ clientX,
+ clientY,
+ 0,
+ 0,
+ button);
+ }
+
+ @WrapForJNI(calledFrom = "ui")
+ private void setAttached(final boolean attached) {
+ if (attached) {
+ mAttached = true;
+ flushEventQueue();
+ } else if (mAttached) {
+ mAttached = false;
+ enableEventQueue();
+ }
+ }
+ }
+
+ /* package */ final NativeProvider mNative = new NativeProvider();
+
+ private void handleMotionEvent(final MotionEvent event) {
+ handleMotionEvent(event, null);
+ }
+
+ private void handleMotionEvent(
+ final MotionEvent event, final GeckoResult<InputResultDetail> result) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event));
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_HANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final int action = event.getActionMasked();
+
+ if (action == MotionEvent.ACTION_DOWN) {
+ mLastDownTime = event.getDownTime();
+ } else if (mLastDownTime != event.getDownTime()) {
+ if (result != null) {
+ result.complete(
+ new InputResultDetail(
+ INPUT_RESULT_UNHANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+ return;
+ }
+
+ final float screenX = event.getRawX() - event.getX();
+ final float screenY = event.getRawY() - event.getY();
+
+ // Take this opportunity to update screen origin of session. This gets
+ // dispatched to the gecko thread, so we also pass the new screen x/y directly to apz.
+ // If this is a synthesized touch, the screen offset is bogus so ignore it.
+ if (!mSynthesizedEvent) {
+ mSession.onScreenOriginChanged((int) screenX, (int) screenY);
+ }
+
+ final MotionEventData data = new MotionEventData(event);
+ mNative.handleMotionEvent(data, screenX, screenY, result);
+ }
+
+ private @InputResult int handleScrollEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_SCROLL, event));
+ return INPUT_RESULT_HANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for scroll events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * mPointerScrollFactor;
+ final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * mPointerScrollFactor;
+
+ return mNative.handleScrollEvent(
+ event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll);
+ }
+
+ private @InputResult int handleMouseEvent(final MotionEvent event) {
+ if (!mAttached) {
+ mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOUSE, event));
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final int count = event.getPointerCount();
+
+ if (count <= 0) {
+ return INPUT_RESULT_UNHANDLED;
+ }
+
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ event.getPointerCoords(0, coords);
+
+ // Translate surface origin to client origin for mouse events.
+ mSession.getSurfaceBounds(mTempRect);
+ final float x = coords.x - mTempRect.left;
+ final float y = coords.y - mTempRect.top;
+
+ return mNative.handleMouseEvent(
+ event.getActionMasked(),
+ event.getEventTime(),
+ event.getMetaState(),
+ x,
+ y,
+ event.getButtonState());
+ }
+
+ protected PanZoomController(final GeckoSession session) {
+ mSession = session;
+ enableEventQueue();
+ }
+
+ private boolean treatMouseAsTouch() {
+ if (sTreatMouseAsTouch == null) {
+ final Context c = GeckoAppShell.getApplicationContext();
+ if (c == null) {
+ // This might happen if the GeckoRuntime has not been initialized yet.
+ return false;
+ }
+ final UiModeManager m = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE);
+ // on TV devices, treat mouse as touch. everywhere else, don't
+ sTreatMouseAsTouch = (m.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION);
+ }
+
+ return sTreatMouseAsTouch;
+ }
+
+ /**
+ * Set the current scroll factor. The scroll factor is the maximum scroll amount that one scroll
+ * event may generate, in device pixels.
+ *
+ * @param factor Scroll factor.
+ */
+ public void setScrollFactor(final float factor) {
+ ThreadUtils.assertOnUiThread();
+ mPointerScrollFactor = factor;
+ }
+
+ /**
+ * Get the current scroll factor.
+ *
+ * @return Scroll factor.
+ */
+ public float getScrollFactor() {
+ ThreadUtils.assertOnUiThread();
+ return mPointerScrollFactor;
+ }
+
+ /**
+ * This is a workaround for touch pad on Android app by Chrome OS. Android app on Chrome OS fires
+ * weird motion event by two finger scroll. See https://crbug.com/704051
+ */
+ private boolean mayTouchpadScroll(final @NonNull MotionEvent event) {
+ final int action = event.getActionMasked();
+ return event.getButtonState() == 0
+ && (action == MotionEvent.ACTION_DOWN
+ || (mLastDownTime == event.getDownTime()
+ && (action == MotionEvent.ACTION_MOVE
+ || action == MotionEvent.ACTION_UP
+ || action == MotionEvent.ACTION_CANCEL)));
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onTouchEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ handleMouseEvent(event);
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather
+ * than as "mouse". Pointer coordinates should be relative to the display surface.
+ *
+ * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited
+ * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and
+ * unnecessary GC pressure. Instead, prefer to call {@link #onTouchEvent(MotionEvent)}.
+ *
+ * @param event MotionEvent to process.
+ * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}).
+ */
+ public @NonNull GeckoResult<InputResultDetail> onTouchEventForDetailResult(
+ final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (!treatMouseAsTouch()
+ && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE
+ && !mayTouchpadScroll(event)) {
+ return GeckoResult.fromValue(
+ new InputResultDetail(
+ handleMouseEvent(event), SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE));
+ }
+
+ final GeckoResult<InputResultDetail> result = new GeckoResult<>();
+ handleMotionEvent(event, result);
+ return result;
+ }
+
+ /**
+ * Process a touch event through the pan-zoom controller. Treat any mouse events as "mouse" rather
+ * than as "touch". Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMouseEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return;
+ }
+ handleMotionEvent(event);
+ }
+
+ @Override
+ protected void finalize() throws Throwable {
+ mNative.setAttached(false);
+ }
+
+ /**
+ * Process a non-touch motion event through the pan-zoom controller. Currently, hover and scroll
+ * events are supported. Pointer coordinates should be relative to the display surface.
+ *
+ * @param event MotionEvent to process.
+ */
+ public void onMotionEvent(final @NonNull MotionEvent event) {
+ ThreadUtils.assertOnUiThread();
+
+ final int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_SCROLL) {
+ if (event.getDownTime() >= mLastDownTime) {
+ mLastDownTime = event.getDownTime();
+ } else if ((InputDevice.getDevice(event.getDeviceId()) != null)
+ && (InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD)
+ == InputDevice.SOURCE_TOUCHPAD) {
+ return;
+ }
+ handleScrollEvent(event);
+ } else if ((action == MotionEvent.ACTION_HOVER_MOVE)
+ || (action == MotionEvent.ACTION_HOVER_ENTER)
+ || (action == MotionEvent.ACTION_HOVER_EXIT)) {
+ handleMouseEvent(event);
+ }
+ }
+
+ private void enableEventQueue() {
+ if (mQueuedEvents != null) {
+ throw new IllegalStateException("Already have an event queue");
+ }
+ mQueuedEvents = new ArrayList<>();
+ }
+
+ private void flushEventQueue() {
+ if (mQueuedEvents == null) {
+ return;
+ }
+
+ final ArrayList<Pair<Integer, MotionEvent>> events = mQueuedEvents;
+ mQueuedEvents = null;
+ for (final Pair<Integer, MotionEvent> pair : events) {
+ switch (pair.first) {
+ case EVENT_SOURCE_MOTION:
+ handleMotionEvent(pair.second);
+ break;
+ case EVENT_SOURCE_SCROLL:
+ handleScrollEvent(pair.second);
+ break;
+ case EVENT_SOURCE_MOUSE:
+ handleMouseEvent(pair.second);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Set whether Gecko should generate long-press events.
+ *
+ * @param isLongpressEnabled True if Gecko should generate long-press events.
+ */
+ public void setIsLongpressEnabled(final boolean isLongpressEnabled) {
+ ThreadUtils.assertOnUiThread();
+
+ if (mAttached) {
+ mNative.nativeSetIsLongpressEnabled(isLongpressEnabled);
+ }
+ }
+
+ private static class PointerInfo {
+ // We reserve one pointer ID for the mouse, so that tests don't have
+ // to worry about tracking pointer IDs if they just want to test mouse
+ // event synthesization. If somebody tries to use this ID for a
+ // synthesized touch event we'll throw an exception.
+ public static final int RESERVED_MOUSE_POINTER_ID = 100000;
+
+ public int pointerId;
+ public int source;
+ public int surfaceX;
+ public int surfaceY;
+ public double pressure;
+ public int orientation;
+ public int buttonState;
+
+ public MotionEvent.PointerCoords getCoords() {
+ final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.orientation = orientation;
+ coords.pressure = (float) pressure;
+ coords.x = surfaceX;
+ coords.y = surfaceY;
+ return coords;
+ }
+ }
+
+ private static class SynthesizedEventState {
+ public final ArrayList<PointerInfo> pointers;
+ public long downTime;
+
+ SynthesizedEventState() {
+ pointers = new ArrayList<PointerInfo>();
+ }
+
+ int getPointerIndex(final int pointerId) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).pointerId == pointerId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ int addPointer(final int pointerId, final int source) {
+ final PointerInfo info = new PointerInfo();
+ info.pointerId = pointerId;
+ info.source = source;
+ pointers.add(info);
+ return pointers.size() - 1;
+ }
+
+ int getPointerCount(final int source) {
+ int count = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ int getPointerButtonState(final int source) {
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ return pointers.get(i).buttonState;
+ }
+ }
+ return 0;
+ }
+
+ MotionEvent.PointerProperties[] getPointerProperties(final int source) {
+ final MotionEvent.PointerProperties[] props =
+ new MotionEvent.PointerProperties[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ final MotionEvent.PointerProperties p = new MotionEvent.PointerProperties();
+ p.id = pointers.get(i).pointerId;
+ switch (source) {
+ case InputDevice.SOURCE_TOUCHSCREEN:
+ p.toolType = MotionEvent.TOOL_TYPE_FINGER;
+ break;
+ case InputDevice.SOURCE_MOUSE:
+ p.toolType = MotionEvent.TOOL_TYPE_MOUSE;
+ break;
+ }
+ props[index++] = p;
+ }
+ }
+ return props;
+ }
+
+ MotionEvent.PointerCoords[] getPointerCoords(final int source) {
+ final MotionEvent.PointerCoords[] coords =
+ new MotionEvent.PointerCoords[getPointerCount(source)];
+ int index = 0;
+ for (int i = 0; i < pointers.size(); i++) {
+ if (pointers.get(i).source == source) {
+ coords[index++] = pointers.get(i).getCoords();
+ }
+ }
+ return coords;
+ }
+ }
+
+ private void synthesizeNativePointer(
+ final int source,
+ final int pointerId,
+ final int originalEventType,
+ final int clientX,
+ final int clientY,
+ final double pressure,
+ final int orientation,
+ final int button) {
+ if (mPointerState == null) {
+ mPointerState = new SynthesizedEventState();
+ }
+
+ // Find the pointer if it already exists
+ int pointerIndex = mPointerState.getPointerIndex(pointerId);
+
+ // Event-specific handling
+ int eventType = originalEventType;
+ switch (originalEventType) {
+ case MotionEvent.ACTION_POINTER_UP:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-up for invalid pointer");
+ return;
+ }
+ if (mPointerState.pointers.size() == 1) {
+ // Last pointer is going up
+ eventType = MotionEvent.ACTION_UP;
+ }
+ break;
+ case MotionEvent.ACTION_CANCEL:
+ if (pointerIndex < 0) {
+ Log.w(LOGTAG, "Pointer-cancel for invalid pointer");
+ return;
+ }
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ if (pointerIndex < 0) {
+ // Adding a new pointer
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ if (pointerIndex == 0) {
+ // first pointer
+ eventType = MotionEvent.ACTION_DOWN;
+ mPointerState.downTime = SystemClock.uptimeMillis();
+ }
+ } else {
+ // We're moving an existing pointer
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ case MotionEvent.ACTION_HOVER_MOVE:
+ if (pointerIndex < 0) {
+ // Mouse-move a pointer without it going "down". However
+ // in order to send the right MotionEvent without a lot of
+ // duplicated code, we add the pointer to mPointerState,
+ // and then remove it at the bottom of this function.
+ pointerIndex = mPointerState.addPointer(pointerId, source);
+ } else {
+ // We're moving an existing mouse pointer that went down.
+ eventType = MotionEvent.ACTION_MOVE;
+ }
+ break;
+ }
+
+ // Translate client origin to surface origin.
+ mSession.getSurfaceBounds(mTempRect);
+ final int surfaceX = clientX + mTempRect.left;
+ final int surfaceY = clientY + mTempRect.top;
+
+ // Update the pointer with the new info
+ final PointerInfo info = mPointerState.pointers.get(pointerIndex);
+ info.surfaceX = surfaceX;
+ info.surfaceY = surfaceY;
+ info.pressure = pressure;
+ info.orientation = orientation;
+ if (source == InputDevice.SOURCE_MOUSE) {
+ if (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE) {
+ info.buttonState |= button;
+ } else if (eventType == MotionEvent.ACTION_UP) {
+ info.buttonState &= button;
+ }
+ }
+
+ // Dispatch the event
+ int action = 0;
+ if (eventType == MotionEvent.ACTION_POINTER_DOWN
+ || eventType == MotionEvent.ACTION_POINTER_UP) {
+ // for pointer-down and pointer-up events we need to add the
+ // index of the relevant pointer.
+ action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
+ action &= MotionEvent.ACTION_POINTER_INDEX_MASK;
+ }
+ action |= (eventType & MotionEvent.ACTION_MASK);
+ final MotionEvent event =
+ MotionEvent.obtain(
+ /*downTime*/ mPointerState.downTime,
+ /*eventTime*/ SystemClock.uptimeMillis(),
+ /*action*/ action,
+ /*pointerCount*/ mPointerState.getPointerCount(source),
+ /*pointerProperties*/ mPointerState.getPointerProperties(source),
+ /*pointerCoords*/ mPointerState.getPointerCoords(source),
+ /*metaState*/ 0,
+ /*buttonState*/ mPointerState.getPointerButtonState(source),
+ /*xPrecision*/ 0,
+ /*yPrecision*/ 0,
+ /*deviceId*/ 0,
+ /*edgeFlags*/ 0,
+ /*source*/ source,
+ /*flags*/ 0);
+
+ mSynthesizedEvent = true;
+ onTouchEvent(event);
+ mSynthesizedEvent = false;
+
+ // Forget about removed pointers
+ if (eventType == MotionEvent.ACTION_POINTER_UP
+ || eventType == MotionEvent.ACTION_UP
+ || eventType == MotionEvent.ACTION_CANCEL
+ || eventType == MotionEvent.ACTION_HOVER_MOVE) {
+ mPointerState.pointers.remove(pointerIndex);
+ }
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position. Uses {@link
+ * #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollBy(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body by an offset from the current scroll position.
+ *
+ * @param width {@link ScreenLength} offset to scroll along X axis.
+ * @param height {@link ScreenLength} offset to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollBy(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg);
+ }
+
+ /**
+ * Scroll the document body to an absolute position. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ */
+ @UiThread
+ public void scrollTo(final @NonNull ScreenLength width, final @NonNull ScreenLength height) {
+ scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /**
+ * Scroll the document body to an absolute position.
+ *
+ * @param width {@link ScreenLength} position to scroll along X axis.
+ * @param height {@link ScreenLength} position to scroll along Y axis.
+ * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link
+ * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content.
+ */
+ @UiThread
+ public void scrollTo(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = buildScrollMessage(width, height, behavior);
+ mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg);
+ }
+
+ /** Scroll to the top left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToTop() {
+ scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ /** Scroll to the bottom left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */
+ @UiThread
+ public void scrollToBottom() {
+ scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH);
+ }
+
+ private GeckoBundle buildScrollMessage(
+ final @NonNull ScreenLength width,
+ final @NonNull ScreenLength height,
+ final @ScrollBehaviorType int behavior) {
+ final GeckoBundle msg = new GeckoBundle();
+ msg.putDouble("widthValue", width.getValue());
+ msg.putInt("widthType", width.getType());
+ msg.putDouble("heightValue", height.getValue());
+ msg.putInt("heightType", height.getType());
+ msg.putInt("behavior", behavior);
+ return msg;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
new file mode 100644
index 0000000000..7feb7d88ae
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java
@@ -0,0 +1,19 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+
+class ParcelableUtils {
+ public static void writeBoolean(final Parcel out, final boolean val) {
+ out.writeByte((byte) (val ? 1 : 0));
+ }
+
+ public static boolean readBoolean(final Parcel source) {
+ return source.readByte() == 1;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
new file mode 100644
index 0000000000..9e655c5eb7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java
@@ -0,0 +1,182 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoJavaSampler;
+
+/**
+ * ProfilerController is used to manage GeckoProfiler related features.
+ *
+ * <p>If you want to add a profiler marker to mark a point in time (without a duration) you can
+ * directly use <code>profilerController.addMarker("marker name")</code>. Or if you want to provide
+ * more information, you can use <code>
+ * profilerController.addMarker("marker name", "extra information")</code> If you want to add a
+ * profiler marker with a duration (with start and end time) you can use it like this: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code> Or you can capture start and end time in somewhere, then add the marker in somewhere
+ * else: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure (or end time can be collected in a callback)...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime);
+ * </code> Here's an <code>addMarker</code> example with all the possible parameters: <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * Double endTime = profilerController.getProfilerTime();
+ *
+ * ...somewhere else in the codebase...
+ * profilerController.addMarker("name", startTime, endTime, "extra information");
+ * </code> <code>isProfilerActive</code> method is handy when you want to get more information to
+ * add inside the marker, but you think it's going to be computationally heavy (and useless) when
+ * profiler is not running:
+ *
+ * <pre>
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * if (profilerController.isProfilerActive()) {
+ * String info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive();
+ * profilerController.addMarker("name", startTime, info);
+ * }
+ * </code>
+ * </pre>
+ *
+ * FIXME(bug 1618560): Currently only works in the main thread.
+ */
+@UiThread
+public class ProfilerController {
+ private static final String LOGTAG = "ProfilerController";
+
+ /**
+ * Returns true if profiler is active and it's allowed the add markers. It's useful when it's
+ * computationally heavy to get startTime or the additional text for the marker. That code can be
+ * wrapped with isProfilerActive if check to reduce the overhead of it.
+ *
+ * @return true if profiler is active and safe to add a new marker.
+ */
+ public boolean isProfilerActive() {
+ return GeckoJavaSampler.isProfilerActive();
+ }
+
+ /**
+ * Get the profiler time to be able to mark the start of the marker events. can be used like this:
+ * <code>
+ * Double startTime = profilerController.getProfilerTime();
+ * ...some code you want to measure...
+ * profilerController.addMarker("name", startTime);
+ * </code>
+ *
+ * @return profiler time as double or null if the profiler is not active.
+ */
+ public @Nullable Double getProfilerTime() {
+ return GeckoJavaSampler.tryToGetProfilerTime();
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. No-op if profiler is not
+ * active.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final Double aEndTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, aEndTime, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(
+ @NonNull final String aMarkerName,
+ @Nullable final Double aStartTime,
+ @Nullable final String aText) {
+ GeckoJavaSampler.addMarker(aMarkerName, aStartTime, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aStartTime Start time as Double. It can be null if you want to mark a point of time.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final Double aStartTime) {
+ addMarker(aMarkerName, aStartTime, null, null);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ * @param aText An optional string field for more information about the marker.
+ */
+ public void addMarker(@NonNull final String aMarkerName, @Nullable final String aText) {
+ addMarker(aMarkerName, null, null, aText);
+ }
+
+ /**
+ * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added
+ * automatically with the current profiler time when the function is called. No-op if profiler is
+ * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for
+ * convenience.
+ *
+ * @param aMarkerName Name of the event as a string.
+ */
+ public void addMarker(@NonNull final String aMarkerName) {
+ addMarker(aMarkerName, null, null, null);
+ }
+
+ /**
+ * Start the Gecko profiler with the given settings. This is used by embedders which want to
+ * control the profiler from the embedding app. This allows them to provide an easier access point
+ * to profiling, as an alternative to the traditional way of using a desktop Firefox instance
+ * connected via USB + adb.
+ *
+ * @param aFilters The list of threads to profile, as an array of string of thread names filters.
+ * Each filter is used as a case-insensitive substring match against the actual thread names.
+ * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array.
+ */
+ public void startProfiler(
+ @NonNull final String[] aFilters, @NonNull final String[] aFeaturesArr) {
+ GeckoJavaSampler.startProfiler(aFilters, aFeaturesArr);
+ }
+
+ /**
+ * Stop the profiler and capture the recorded profile. This method is asynchronous.
+ *
+ * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer
+ * containing a gzip-compressed payload (with gzip header) of the profile JSON.
+ */
+ public @NonNull GeckoResult<byte[]> stopProfiler() {
+ return GeckoJavaSampler.stopProfiler();
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
new file mode 100644
index 0000000000..72a07c218e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java
@@ -0,0 +1,646 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import java.util.HashMap;
+import java.util.Map;
+import org.json.JSONException;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.Autocomplete.AddressSaveOption;
+import org.mozilla.geckoview.Autocomplete.AddressSelectOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption;
+import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption;
+import org.mozilla.geckoview.Autocomplete.LoginSaveOption;
+import org.mozilla.geckoview.Autocomplete.LoginSelectOption;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt.AuthOptions;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt.Observer;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ButtonPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.ColorPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PopupPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.RepostConfirmPrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt;
+import org.mozilla.geckoview.GeckoSession.PromptDelegate.TextPrompt;
+
+/* package */ class PromptController {
+ private static final String LOGTAG = "Prompts";
+
+ private static class PromptStorage implements BasePrompt.Observer {
+ private final Map<String, BasePrompt> mPrompts = new HashMap<>();
+
+ public void addPrompt(final String id, final BasePrompt prompt) {
+ if (mPrompts.containsKey(id)) {
+ Log.e(LOGTAG, "Prompt already exists! id=" + id);
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Prompt already exists! id=" + id);
+ }
+ }
+ mPrompts.put(id, prompt);
+ }
+
+ @Override
+ public void onPromptCompleted(final BasePrompt prompt) {
+ // No need to notify this delegate since the prompt has been completed already.
+ mPrompts.remove(prompt.id);
+ }
+
+ public void dismiss(final String id) {
+ final BasePrompt prompt = mPrompts.get(id);
+ if (prompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = prompt.getDelegate();
+ if (delegate != null) {
+ delegate.onPromptDismiss(prompt);
+ }
+ mPrompts.remove(prompt.id);
+ }
+
+ public boolean contains(final String id) {
+ return mPrompts.containsKey(id);
+ }
+
+ public void update(final BasePrompt prompt) {
+ final BasePrompt previousPrompt = mPrompts.get(prompt.id);
+ if (previousPrompt == null) {
+ return;
+ }
+ final PromptInstanceDelegate delegate = previousPrompt.getDelegate();
+ if (delegate == null) {
+ return;
+ }
+ prompt.setDelegate(delegate);
+ delegate.onPromptUpdate(prompt);
+ mPrompts.put(prompt.id, prompt);
+ }
+ }
+
+ final PromptStorage mStorage = new PromptStorage();
+
+ public void dismissPrompt(final String id) {
+ mStorage.dismiss(id);
+ }
+
+ public void updatePrompt(final GeckoBundle message) {
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ // Invalid prompt message type to update the prompt.
+ return;
+ }
+ final BasePrompt prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ // Invalid prompt message to update the prompt.
+ return;
+ }
+ if (!mStorage.contains(prompt.id)) {
+ // Invalid prompt id to update the prompt. Dismissed?
+ return;
+ }
+
+ mStorage.update(prompt);
+ }
+
+ public void handleEvent(
+ final GeckoSession session, final GeckoBundle message, final EventCallback callback) {
+ Log.d(LOGTAG, "handleEvent " + message.getString("type"));
+ final PromptDelegate delegate = session.getPromptDelegate();
+ if (delegate == null) {
+ // Default behavior is same as calling dismiss() on callback.
+ callback.sendSuccess(null);
+ return;
+ }
+
+ final String type = message.getString("type");
+ final PromptHandler<?> handler = sPromptHandlers.handlerFor(type);
+ if (handler == null) {
+ callback.sendError("Invalid type: " + type);
+ return;
+ }
+ final GeckoResult<PromptResponse> res = getResponse(message, session, delegate, handler);
+
+ if (res == null) {
+ // Adhere to default behavior if the delegate returns null.
+ callback.sendSuccess(null);
+ } else {
+ res.accept(
+ value -> value.dispatch(callback),
+ exception -> callback.sendError("Failed to get prompt response."));
+ }
+ }
+
+ private <PromptType extends BasePrompt> GeckoResult<PromptResponse> getResponse(
+ final GeckoBundle message,
+ final GeckoSession session,
+ final PromptDelegate delegate,
+ final PromptHandler<PromptType> handler) {
+ final PromptType prompt = handler.newPrompt(message, mStorage);
+ if (prompt == null) {
+ try {
+ Log.e(LOGTAG, "Invalid prompt: " + message.toJSONObject().toString());
+ } catch (final JSONException ex) {
+ Log.e(LOGTAG, "Invalid prompt, invalid data", ex);
+ }
+
+ return GeckoResult.fromException(new IllegalArgumentException("Invalid prompt data."));
+ }
+
+ mStorage.addPrompt(prompt.id, prompt);
+ return handler.callDelegate(prompt, session, delegate);
+ }
+
+ private interface PromptHandler<PromptType extends BasePrompt> {
+ PromptType newPrompt(GeckoBundle info, Observer observer);
+
+ GeckoResult<PromptResponse> callDelegate(
+ PromptType prompt, GeckoSession session, PromptDelegate delegate);
+ }
+
+ private static final class AlertHandler implements PromptHandler<AlertPrompt> {
+ @Override
+ public AlertPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AlertPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AlertPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAlertPrompt(session, prompt);
+ }
+ }
+
+ private static final class BeforeUnloadHandler implements PromptHandler<BeforeUnloadPrompt> {
+ @Override
+ public BeforeUnloadPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new BeforeUnloadPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final BeforeUnloadPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onBeforeUnloadPrompt(session, prompt);
+ }
+ }
+
+ private static final class ButtonHandler implements PromptHandler<ButtonPrompt> {
+ @Override
+ public ButtonPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ButtonPrompt(
+ info.getString("id"), info.getString("title"), info.getString("msg"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ButtonPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onButtonPrompt(session, prompt);
+ }
+ }
+
+ private static final class TextHandler implements PromptHandler<TextPrompt> {
+ @Override
+ public TextPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new TextPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ info.getString("value"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final TextPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onTextPrompt(session, prompt);
+ }
+ }
+
+ private static final class AuthHandler implements PromptHandler<AuthPrompt> {
+ @Override
+ public AuthPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new AuthPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ new AuthOptions(info.getBundle("options")),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AuthPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onAuthPrompt(session, prompt);
+ }
+ }
+
+ private static final class ChoiceHandler implements PromptHandler<ChoicePrompt> {
+ @Override
+ public ChoicePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final int intMode;
+ final String mode = info.getString("mode");
+ if ("menu".equals(mode)) {
+ intMode = ChoicePrompt.Type.MENU;
+ } else if ("single".equals(mode)) {
+ intMode = ChoicePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = ChoicePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final GeckoBundle[] choiceBundles = info.getBundleArray("choices");
+ final ChoicePrompt.Choice[] choices;
+ if (choiceBundles == null || choiceBundles.length == 0) {
+ choices = new ChoicePrompt.Choice[0];
+ } else {
+ choices = new ChoicePrompt.Choice[choiceBundles.length];
+ for (int i = 0; i < choiceBundles.length; i++) {
+ choices[i] = new ChoicePrompt.Choice(choiceBundles[i]);
+ }
+ }
+
+ return new ChoicePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("msg"),
+ intMode,
+ choices,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ChoicePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onChoicePrompt(session, prompt);
+ }
+ }
+
+ private static final class ColorHandler implements PromptHandler<ColorPrompt> {
+ @Override
+ public ColorPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new ColorPrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("value"),
+ info.getStringArray("predefinedValues"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final ColorPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onColorPrompt(session, prompt);
+ }
+ }
+
+ private static final class DateTimeHandler implements PromptHandler<DateTimePrompt> {
+ @Override
+ public DateTimePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("date".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATE;
+ } else if ("month".equals(mode)) {
+ intMode = DateTimePrompt.Type.MONTH;
+ } else if ("week".equals(mode)) {
+ intMode = DateTimePrompt.Type.WEEK;
+ } else if ("time".equals(mode)) {
+ intMode = DateTimePrompt.Type.TIME;
+ } else if ("datetime-local".equals(mode)) {
+ intMode = DateTimePrompt.Type.DATETIME_LOCAL;
+ } else {
+ return null;
+ }
+
+ final String defaultValue = info.getString("value");
+ final String minValue = info.getString("min");
+ final String maxValue = info.getString("max");
+ final String stepValue = info.getString("step");
+ return new DateTimePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ intMode,
+ defaultValue,
+ minValue,
+ maxValue,
+ stepValue,
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final DateTimePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onDateTimePrompt(session, prompt);
+ }
+ }
+
+ private static final class FileHandler implements PromptHandler<FilePrompt> {
+ @Override
+ public FilePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ final String mode = info.getString("mode");
+ final int intMode;
+ if ("single".equals(mode)) {
+ intMode = FilePrompt.Type.SINGLE;
+ } else if ("multiple".equals(mode)) {
+ intMode = FilePrompt.Type.MULTIPLE;
+ } else {
+ return null;
+ }
+
+ final String[] mimeTypes = info.getStringArray("mimeTypes");
+ final int capture = info.getInt("capture");
+ return new FilePrompt(
+ info.getString("id"), info.getString("title"), intMode, capture, mimeTypes, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final FilePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onFilePrompt(session, prompt);
+ }
+ }
+
+ private static final class PopupHandler implements PromptHandler<PopupPrompt> {
+ @Override
+ public PopupPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new PopupPrompt(info.getString("id"), info.getString("targetUri"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final PopupPrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onPopupPrompt(session, prompt);
+ }
+ }
+
+ private static final class RepostHandler implements PromptHandler<RepostConfirmPrompt> {
+ @Override
+ public RepostConfirmPrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new RepostConfirmPrompt(info.getString("id"), observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final RepostConfirmPrompt prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onRepostConfirmPrompt(session, prompt);
+ }
+ }
+
+ private static final class ShareHandler implements PromptHandler<SharePrompt> {
+ @Override
+ public SharePrompt newPrompt(final GeckoBundle info, final Observer observer) {
+ return new SharePrompt(
+ info.getString("id"),
+ info.getString("title"),
+ info.getString("text"),
+ info.getString("uri"),
+ observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final SharePrompt prompt, final GeckoSession session, final PromptDelegate delegate) {
+ return delegate.onSharePrompt(session, prompt);
+ }
+ }
+
+ private static final class LoginSaveHandler
+ implements PromptHandler<AutocompleteRequest<LoginSaveOption>> {
+ @Override
+ public AutocompleteRequest<LoginSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] loginBundles = info.getBundleArray("logins");
+
+ if (loginBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSaveOption[] options =
+ new Autocomplete.LoginSaveOption[loginBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.LoginSaveOption(new Autocomplete.LoginEntry(loginBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSave(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSaveHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSaveOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final int hint = info.getInt("hint");
+ final GeckoBundle[] creditCardBundles = info.getBundleArray("creditCards");
+
+ if (creditCardBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSaveOption[] options =
+ new Autocomplete.CreditCardSaveOption[creditCardBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.CreditCardSaveOption(
+ new Autocomplete.CreditCard(creditCardBundles[i]), hint);
+ }
+
+ return new PromptDelegate.AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSave(session, prompt);
+ }
+ }
+
+ private static final class AddressSaveHandler
+ implements PromptHandler<AutocompleteRequest<AddressSaveOption>> {
+ @Override
+ public AutocompleteRequest<AddressSaveOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] addressBundles = info.getBundleArray("addresses");
+
+ if (addressBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSaveOption[] options =
+ new Autocomplete.AddressSaveOption[addressBundles.length];
+
+ final int hint = info.getInt("hint");
+ for (int i = 0; i < options.length; ++i) {
+ options[i] =
+ new Autocomplete.AddressSaveOption(new Autocomplete.Address(addressBundles[i]), hint);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSaveOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSave(session, prompt);
+ }
+ }
+
+ private static final class LoginSelectHandler
+ implements PromptHandler<AutocompleteRequest<LoginSelectOption>> {
+ @Override
+ public AutocompleteRequest<LoginSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.LoginSelectOption[] options =
+ new Autocomplete.LoginSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.LoginSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<LoginSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onLoginSelect(session, prompt);
+ }
+ }
+
+ private static final class CreditCardSelectHandler
+ implements PromptHandler<AutocompleteRequest<CreditCardSelectOption>> {
+ @Override
+ public AutocompleteRequest<CreditCardSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.CreditCardSelectOption[] options =
+ new Autocomplete.CreditCardSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.CreditCardSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<CreditCardSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onCreditCardSelect(session, prompt);
+ }
+ }
+
+ private static final class AddressSelectHandler
+ implements PromptHandler<AutocompleteRequest<AddressSelectOption>> {
+ @Override
+ public AutocompleteRequest<AddressSelectOption> newPrompt(
+ final GeckoBundle info, final Observer observer) {
+ final GeckoBundle[] optionBundles = info.getBundleArray("options");
+
+ if (optionBundles == null) {
+ return null;
+ }
+
+ final Autocomplete.AddressSelectOption[] options =
+ new Autocomplete.AddressSelectOption[optionBundles.length];
+
+ for (int i = 0; i < options.length; ++i) {
+ options[i] = Autocomplete.AddressSelectOption.fromBundle(optionBundles[i]);
+ }
+
+ return new AutocompleteRequest<>(info.getString("id"), options, observer);
+ }
+
+ @Override
+ public GeckoResult<PromptResponse> callDelegate(
+ final AutocompleteRequest<AddressSelectOption> prompt,
+ final GeckoSession session,
+ final PromptDelegate delegate) {
+ return delegate.onAddressSelect(session, prompt);
+ }
+ }
+
+ private static class PromptHandlers {
+ final Map<String, PromptHandler<?>> mPromptHandlers = new HashMap<>();
+
+ public void register(final PromptHandler<?> handler, final String type) {
+ mPromptHandlers.put(type, handler);
+ }
+
+ public PromptHandler<?> handlerFor(final String type) {
+ return mPromptHandlers.get(type);
+ }
+ }
+
+ private static final PromptHandlers sPromptHandlers = new PromptHandlers();
+
+ static {
+ sPromptHandlers.register(new AlertHandler(), "alert");
+ sPromptHandlers.register(new BeforeUnloadHandler(), "beforeUnload");
+ sPromptHandlers.register(new ButtonHandler(), "button");
+ sPromptHandlers.register(new TextHandler(), "text");
+ sPromptHandlers.register(new AuthHandler(), "auth");
+ sPromptHandlers.register(new ChoiceHandler(), "choice");
+ sPromptHandlers.register(new ColorHandler(), "color");
+ sPromptHandlers.register(new DateTimeHandler(), "datetime");
+ sPromptHandlers.register(new FileHandler(), "file");
+ sPromptHandlers.register(new PopupHandler(), "popup");
+ sPromptHandlers.register(new RepostHandler(), "repost");
+ sPromptHandlers.register(new ShareHandler(), "share");
+ sPromptHandlers.register(new LoginSaveHandler(), "Autocomplete:Save:Login");
+ sPromptHandlers.register(new CreditCardSaveHandler(), "Autocomplete:Save:CreditCard");
+ sPromptHandlers.register(new AddressSaveHandler(), "Autocomplete:Save:Address");
+ sPromptHandlers.register(new LoginSelectHandler(), "Autocomplete:Select:Login");
+ sPromptHandlers.register(new CreditCardSelectHandler(), "Autocomplete:Select:CreditCard");
+ sPromptHandlers.register(new AddressSelectHandler(), "Autocomplete:Select:Address");
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
new file mode 100644
index 0000000000..299dec95f1
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java
@@ -0,0 +1,266 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.collection.ArrayMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Map;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * Base class for (nested) runtime settings.
+ *
+ * <p>Handles pref-based settings. Please extend this class when adding nested settings for
+ * GeckoRuntimeSettings.
+ */
+public abstract class RuntimeSettings implements Parcelable {
+ /**
+ * Base class for (nested) runtime settings builders.
+ *
+ * <p>Please extend this class when adding nested settings builders for GeckoRuntimeSettings.
+ */
+ public abstract static class Builder<Settings extends RuntimeSettings> {
+ private final Settings mSettings;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public Builder() {
+ mSettings = newSettings(null);
+ }
+
+ /**
+ * Finalize and return the settings.
+ *
+ * @return The constructed settings.
+ */
+ @AnyThread
+ public @NonNull Settings build() {
+ return newSettings(mSettings);
+ }
+
+ @AnyThread
+ protected @NonNull Settings getSettings() {
+ return mSettings;
+ }
+
+ /**
+ * Create a default or copy settings object.
+ *
+ * @param settings Settings object to copy, null for default settings.
+ * @return The constructed settings object.
+ */
+ @AnyThread
+ protected abstract @NonNull Settings newSettings(final @Nullable Settings settings);
+ }
+
+ /** Used to handle pref-based settings. */
+ /* package */ class Pref<T> {
+ public final String name;
+ public final T defaultValue;
+ private T mValue;
+ private boolean mIsSet;
+
+ public Pref(@NonNull final String name, final T defaultValue) {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ mValue = defaultValue;
+
+ RuntimeSettings.this.addPref(this);
+ }
+
+ public void set(final T newValue) {
+ mValue = newValue;
+ mIsSet = true;
+ }
+
+ public void commit(final T newValue) {
+ if (newValue.equals(mValue)) {
+ return;
+ }
+ set(newValue);
+ commit();
+ }
+
+ public void commit() {
+ final GeckoRuntime runtime = RuntimeSettings.this.getRuntime();
+ if (runtime == null) {
+ return;
+ }
+ final GeckoBundle prefs = new GeckoBundle(1);
+ addToBundle(prefs);
+ runtime.setDefaultPrefs(prefs);
+ }
+
+ public T get() {
+ return mValue;
+ }
+
+ public boolean isSet() {
+ return mIsSet;
+ }
+
+ public void reset() {
+ mValue = defaultValue;
+ mIsSet = false;
+ }
+
+ private void addToBundle(final GeckoBundle bundle) {
+ final T value = mIsSet ? mValue : defaultValue;
+ if (value instanceof String) {
+ bundle.putString(name, (String) value);
+ } else if (value instanceof Integer) {
+ bundle.putInt(name, (Integer) value);
+ } else if (value instanceof Boolean) {
+ bundle.putBoolean(name, (Boolean) value);
+ } else {
+ throw new UnsupportedOperationException("Unhandled pref type for " + name);
+ }
+ }
+ }
+
+ private RuntimeSettings mParent;
+ private final ArrayList<RuntimeSettings> mChildren;
+ private final ArrayList<Pref<?>> mPrefs;
+
+ protected RuntimeSettings() {
+ this(null /* parent */);
+ }
+
+ /**
+ * Create settings object.
+ *
+ * @param parent The parent settings, specify in case of nested settings.
+ */
+ protected RuntimeSettings(final @Nullable RuntimeSettings parent) {
+ mPrefs = new ArrayList<Pref<?>>();
+ mChildren = new ArrayList<RuntimeSettings>();
+
+ setParent(parent);
+ }
+
+ /**
+ * Update the prefs based on the provided settings.
+ *
+ * @param settings Copy from this settings.
+ */
+ @AnyThread
+ protected void updatePrefs(final @NonNull RuntimeSettings settings) {
+ if (mPrefs.size() != settings.mPrefs.size()) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+
+ for (int i = 0; i < mPrefs.size(); ++i) {
+ if (!mPrefs.get(i).name.equals(settings.mPrefs.get(i).name)) {
+ throw new IllegalArgumentException("Settings must be compatible");
+ }
+ if (!settings.mPrefs.get(i).isSet()) {
+ continue;
+ }
+ // We know it is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) mPrefs.get(i);
+ uncheckedPref.commit(settings.mPrefs.get(i).get());
+ }
+ }
+
+ /* package */ @Nullable
+ GeckoRuntime getRuntime() {
+ if (mParent != null) {
+ return mParent.getRuntime();
+ }
+ return null;
+ }
+
+ private void setParent(final @Nullable RuntimeSettings parent) {
+ mParent = parent;
+ if (mParent != null) {
+ mParent.addChild(this);
+ }
+ }
+
+ private void addChild(final @NonNull RuntimeSettings child) {
+ mChildren.add(child);
+ }
+
+ /* pacakge */ void addPref(final Pref<?> pref) {
+ mPrefs.add(pref);
+ }
+
+ /**
+ * Return a mapping of the prefs managed in this settings, including child settings.
+ *
+ * @return A key-value mapping of the prefs.
+ */
+ /* package */ @NonNull
+ Map<String, Object> getPrefsMap() {
+ final ArrayMap<String, Object> prefs = new ArrayMap<>();
+ forAllPrefs(pref -> prefs.put(pref.name, pref.get()));
+
+ return Collections.unmodifiableMap(prefs);
+ }
+
+ /**
+ * Iterates through all prefs in this RuntimeSettings instance and in all children, grandchildren,
+ * etc.
+ */
+ private void forAllPrefs(final GeckoResult.Consumer<Pref<?>> visitor) {
+ for (final RuntimeSettings child : mChildren) {
+ child.forAllPrefs(visitor);
+ }
+
+ for (final Pref<?> pref : mPrefs) {
+ visitor.accept(pref);
+ }
+ }
+
+ /**
+ * Reset the prefs managed by this settings and its children.
+ *
+ * <p>The actual prefs values are set via {@link #getPrefsMap} during initialization and via
+ * {@link Pref#commit} during runtime for individual prefs.
+ */
+ /* package */ void commitResetPrefs() {
+ final ArrayList<String> names = new ArrayList<String>();
+ forAllPrefs(pref -> names.add(pref.name));
+
+ final GeckoBundle data = new GeckoBundle(1);
+ data.putStringArray("names", names);
+ EventDispatcher.getInstance().dispatch("GeckoView:ResetUserPrefs", data);
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override // Parcelable
+ @AnyThread
+ public void writeToParcel(final Parcel out, final int flags) {
+ for (final Pref<?> pref : mPrefs) {
+ out.writeValue(pref.get());
+ }
+ }
+
+ @AnyThread
+ // AIDL code may call readFromParcel even though it's not part of Parcelable.
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public void readFromParcel(final @NonNull Parcel source) {
+ for (final Pref<?> pref : mPrefs) {
+ // We know this is safe.
+ @SuppressWarnings("unchecked")
+ final Pref<Object> uncheckedPref = (Pref<Object>) pref;
+ uncheckedPref.commit(source.readValue(getClass().getClassLoader()));
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
new file mode 100644
index 0000000000..1fad0cb17e
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java
@@ -0,0 +1,171 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.mozglue.JNIObject;
+
+/** The telemetry API gives access to telemetry data of the Gecko runtime. */
+public final class RuntimeTelemetry {
+ protected RuntimeTelemetry() {}
+
+ /**
+ * The runtime telemetry metric object.
+ *
+ * @param <T> type of the underlying metric sample
+ */
+ public static class Metric<T> {
+ /** The runtime metric name. */
+ public final @NonNull String name;
+
+ /** The metric values. */
+ public final @NonNull T value;
+
+ /* package */ Metric(final String name, final T value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return "name: " + name + ", value: " + value;
+ }
+
+ // For testing
+ protected Metric() {
+ name = null;
+ value = null;
+ }
+ }
+
+ /** The Histogram telemetry metric object. */
+ public static class Histogram extends Metric<long[]> {
+ /** Whether or not this is a Categorical Histogram. */
+ public final boolean isCategorical;
+
+ /* package */ Histogram(final boolean isCategorical, final String name, final long[] value) {
+ super(name, value);
+ this.isCategorical = isCategorical;
+ }
+
+ // For testing
+ protected Histogram() {
+ super(null, null);
+ isCategorical = false;
+ }
+ }
+
+ /**
+ * The runtime telemetry delegate. Implement this if you want to receive runtime (Gecko) telemetry
+ * and attach it via {@link GeckoRuntimeSettings.Builder#telemetryDelegate}.
+ */
+ public interface Delegate {
+ /**
+ * A runtime telemetry histogram metric has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onHistogram(final @NonNull Histogram metric) {}
+
+ /**
+ * A runtime telemetry boolean scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onBooleanScalar(final @NonNull Metric<Boolean> metric) {}
+
+ /**
+ * A runtime telemetry long scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onLongScalar(final @NonNull Metric<Long> metric) {}
+
+ /**
+ * A runtime telemetry string scalar has been received.
+ *
+ * @param metric The runtime metric details.
+ */
+ @AnyThread
+ default void onStringScalar(final @NonNull Metric<String> metric) {}
+ }
+
+ // The proxy connects to telemetry core and forwards telemetry events
+ // to the attached delegate.
+ /* package */ static final class Proxy extends JNIObject {
+ private final Delegate mDelegate;
+
+ public Proxy(final @NonNull Delegate delegate) {
+ mDelegate = delegate;
+ }
+
+ // Attach to current runtime.
+ // We might have different mechanics of attaching to specific runtimes
+ // in future, for which case we should split the delegate assignment in
+ // the setup phase from the attaching.
+ public void attach() {
+ if (GeckoThread.isRunning()) {
+ registerDelegateProxy(this);
+ } else {
+ GeckoThread.queueNativeCall(Proxy.class, "registerDelegateProxy", Proxy.class, this);
+ }
+ }
+
+ public @NonNull Delegate getDelegate() {
+ return mDelegate;
+ }
+
+ @WrapForJNI(dispatchTo = "gecko")
+ private static native void registerDelegateProxy(Proxy proxy);
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchHistogram(
+ final boolean isCategorical, final String name, final long[] values) {
+ if (mDelegate == null) {
+ // TODO throw?
+ return;
+ }
+ mDelegate.onHistogram(new Histogram(isCategorical, name, values));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchStringScalar(final String name, final String value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onStringScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchBooleanScalar(final String name, final boolean value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onBooleanScalar(new Metric<>(name, value));
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ /* package */ void dispatchLongScalar(final String name, final long value) {
+ if (mDelegate == null) {
+ return;
+ }
+ mDelegate.onLongScalar(new Metric<>(name, value));
+ }
+
+ @Override // JNIObject
+ protected void disposeNative() {
+ // We don't hold native references.
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java
new file mode 100644
index 0000000000..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 <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_WIDTH = 1;
+
+ /**
+ * Units are in visual viewport height. If the visual viewport is 100 pixels high, then a value of
+ * 2.0 would represent a length of 200 pixels.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual
+ * Viewport</a>
+ */
+ public static final int VISUAL_VIEWPORT_HEIGHT = 2;
+
+ /**
+ * Units represent the entire scrollable documents width. If the document is 1000 pixels wide then
+ * a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_WIDTH = 3;
+
+ /**
+ * Units represent the entire scrollable documents height. If the document is 1000 pixels tall
+ * then a value of 1.0 would represent 1000 pixels.
+ */
+ public static final int DOCUMENT_HEIGHT = 4;
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}.
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength zero() {
+ return new ScreenLength(0.0, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}. Can be used to scroll to
+ * the top of a page when used with PanZoomController.scrollTo()
+ *
+ * @return ScreenLength of zero length.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength top() {
+ return zero();
+ }
+
+ /**
+ * Create a ScreenLength of the documents height. Type is {@link #DOCUMENT_HEIGHT}. Can be used to
+ * scroll to the bottom of a page when used with {@link PanZoomController#scrollTo(ScreenLength,
+ * ScreenLength)}
+ *
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength bottom() {
+ return new ScreenLength(1.0, DOCUMENT_HEIGHT);
+ }
+
+ /**
+ * Create a ScreenLength of a specific length. Type is {@link #PIXEL}.
+ *
+ * @param value Pixel length.
+ * @return ScreenLength of document height.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromPixels(final double value) {
+ return new ScreenLength(value, PIXEL);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_WIDTH}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the width of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports width.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportWidth(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_WIDTH);
+ }
+
+ /**
+ * Create a ScreenLength that uses the visual viewport width as units. Type is {@link
+ * #VISUAL_VIEWPORT_HEIGHT}. Can be used with {@link PanZoomController#scrollBy(ScreenLength,
+ * ScreenLength)} to scroll a value of the height of visual viewport content.
+ *
+ * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as
+ * long as the length of the visual viewports height.
+ * @return ScreenLength of specifying a length of value * visual viewport width.
+ */
+ @NonNull
+ @AnyThread
+ public static ScreenLength fromVisualViewportHeight(final double value) {
+ return new ScreenLength(value, VISUAL_VIEWPORT_HEIGHT);
+ }
+
+ private final double mValue;
+ @ScreenLengthType private final int mType;
+
+ /* package */ ScreenLength(final double value, @ScreenLengthType final int type) {
+ mValue = value;
+ mType = type;
+ }
+
+ /**
+ * Returns the scalar value used to calculate length. The units of the returned valued are defined
+ * by what is returned by {@link #getType()}
+ *
+ * @return Scalar value of the length.
+ */
+ @AnyThread
+ public double getValue() {
+ return mValue;
+ }
+
+ /**
+ * Returns the unit type of the length The length can be one of the following: {@link #PIXEL},
+ * {@link #VISUAL_VIEWPORT_WIDTH}, {@link #VISUAL_VIEWPORT_HEIGHT}, {@link #DOCUMENT_WIDTH},
+ * {@link #DOCUMENT_HEIGHT}
+ *
+ * @return Unit type of the length.
+ */
+ @AnyThread
+ @ScreenLengthType
+ public int getType() {
+ return mType;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java
new file mode 100644
index 0000000000..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<Pair<Integer, String>> sFlagNames =
+ Arrays.asList(
+ new Pair<>(GeckoSession.FINDER_FIND_BACKWARDS, "backwards"),
+ new Pair<>(GeckoSession.FINDER_FIND_LINKS_ONLY, "linksOnly"),
+ new Pair<>(GeckoSession.FINDER_FIND_MATCH_CASE, "matchCase"),
+ new Pair<>(GeckoSession.FINDER_FIND_WHOLE_WORD, "wholeWord"));
+
+ private static void addFlagsToBundle(
+ @FinderFindFlags final int flags, @NonNull final GeckoBundle bundle) {
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if ((flags & name.first) != 0) {
+ bundle.putBoolean(name.second, true);
+ }
+ }
+ }
+
+ /* package */ static int getFlagsFromBundle(@Nullable final GeckoBundle bundle) {
+ if (bundle == null) {
+ return 0;
+ }
+
+ int flags = 0;
+ for (final Pair<Integer, String> name : sFlagNames) {
+ if (bundle.getBoolean(name.second)) {
+ flags |= name.first;
+ }
+ }
+ return flags;
+ }
+
+ private final EventDispatcher mDispatcher;
+ @FinderDisplayFlags private int mDisplayFlags;
+
+ /* package */ SessionFinder(@NonNull final EventDispatcher dispatcher) {
+ mDispatcher = dispatcher;
+ setDisplayFlags(0);
+ }
+
+ /**
+ * Find and select a string on the current page, starting from the current selection or the start
+ * of the page if there is no selection. Optionally return results related to the search in a
+ * {@link FinderResult} object. If {@code searchString} is null, search is performed using the
+ * previous search string.
+ *
+ * @param searchString String to search, or null to find again using the previous string.
+ * @param flags Flags for performing the search; either 0 or a combination of {@link
+ * GeckoSession#FINDER_FIND_BACKWARDS FINDER_FIND_*} constants.
+ * @return Result of the search operation as a {@link GeckoResult} object.
+ * @see #clear
+ * @see #setDisplayFlags
+ */
+ @NonNull
+ public GeckoResult<FinderResult> find(
+ @Nullable final String searchString, @FinderFindFlags final int flags) {
+ final GeckoBundle bundle = new GeckoBundle(sFlagNames.size() + 1);
+ bundle.putString("searchString", searchString);
+ addFlagsToBundle(flags, bundle);
+
+ return mDispatcher
+ .queryBundle("GeckoView:FindInPage", bundle)
+ .map(response -> new FinderResult(response));
+ }
+
+ /**
+ * Clear any highlighted find-in-page matches.
+ *
+ * @see #find
+ * @see #setDisplayFlags
+ */
+ public void clear() {
+ mDispatcher.dispatch("GeckoView:ClearMatches", null);
+ }
+
+ /**
+ * Return flags for displaying find-in-page matches.
+ *
+ * @return Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #setDisplayFlags
+ * @see #find
+ */
+ @FinderDisplayFlags
+ public int getDisplayFlags() {
+ return mDisplayFlags;
+ }
+
+ /**
+ * Set flags for displaying find-in-page matches.
+ *
+ * @param flags Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL
+ * FINDER_DISPLAY_*} constants.
+ * @see #getDisplayFlags
+ * @see #find
+ */
+ public void setDisplayFlags(@FinderDisplayFlags final int flags) {
+ mDisplayFlags = flags;
+
+ final GeckoBundle bundle = new GeckoBundle(3);
+ bundle.putBoolean("highlightAll", (flags & GeckoSession.FINDER_DISPLAY_HIGHLIGHT_ALL) != 0);
+ bundle.putBoolean("dimPage", (flags & GeckoSession.FINDER_DISPLAY_DIM_PAGE) != 0);
+ bundle.putBoolean("drawOutline", (flags & GeckoSession.FINDER_DISPLAY_DRAW_LINK_OUTLINE) != 0);
+ mDispatcher.dispatch("GeckoView:DisplayMatches", bundle);
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/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<WebResponse> save() {
+ final GeckoResult<WebResponse> 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<WebResponse> 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<WebResponse, WebResponse>() {
+ @Override
+ public GeckoResult<WebResponse> 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}.
+ *
+ * <p>For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be
+ * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null,
+ * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link
+ * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in
+ * behavior in this viewless mode.
+ */
+public final class SessionTextInput {
+ /* package */ static final String LOGTAG = "GeckoSessionTextInput";
+ private static final boolean DEBUG = false;
+
+ // Interface to access GeckoInputConnection from SessionTextInput.
+ /* package */ interface InputConnectionClient {
+ View getView();
+
+ Handler getHandler(Handler defHandler);
+
+ InputConnection onCreateInputConnection(EditorInfo attrs);
+ }
+
+ // Interface to access GeckoEditable from GeckoInputConnection.
+ /* package */ interface EditableClient {
+ // The following value is used by requestCursorUpdates
+ // ONE_SHOT calls updateCompositionRects() after getting current composing
+ // character rects.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR})
+ /* package */ @interface CursorMonitorMode {}
+
+ @WrapForJNI static final int ONE_SHOT = 1;
+ // START_MONITOR start the monitor for composing character rects. If is is
+ // updaed, call updateCompositionRects()
+ @WrapForJNI static final int START_MONITOR = 2;
+ // ENDT_MONITOR stops the monitor for composing character rects.
+ @WrapForJNI static final int END_MONITOR = 3;
+
+ void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event);
+
+ Editable getEditable();
+
+ void setBatchMode(boolean isBatchMode);
+
+ Handler setInputConnectionHandler(@NonNull Handler handler);
+
+ void postToInputConnection(@NonNull Runnable runnable);
+
+ void requestCursorUpdates(@CursorMonitorMode int requestMode);
+
+ void insertImage(@NonNull byte[] data, @NonNull String mimeType);
+ }
+
+ // Interface to access GeckoInputConnection from GeckoEditable.
+ /* package */ interface EditableListener {
+ // IME notification type for notifyIME(), corresponding to NotificationToIME enum.
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ NOTIFY_IME_OF_TOKEN,
+ NOTIFY_IME_OPEN_VKB,
+ NOTIFY_IME_REPLY_EVENT,
+ NOTIFY_IME_OF_FOCUS,
+ NOTIFY_IME_OF_BLUR,
+ NOTIFY_IME_TO_COMMIT_COMPOSITION,
+ NOTIFY_IME_TO_CANCEL_COMPOSITION
+ })
+ /* package */ @interface IMENotificationType {}
+
+ @WrapForJNI static final int NOTIFY_IME_OF_TOKEN = -3;
+ @WrapForJNI static final int NOTIFY_IME_OPEN_VKB = -2;
+ @WrapForJNI static final int NOTIFY_IME_REPLY_EVENT = -1;
+ @WrapForJNI static final int NOTIFY_IME_OF_FOCUS = 1;
+ @WrapForJNI static final int NOTIFY_IME_OF_BLUR = 2;
+ @WrapForJNI static final int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8;
+ @WrapForJNI static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9;
+
+ // IME enabled state for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD})
+ /* package */ @interface IMEState {}
+
+ static final int IME_STATE_UNKNOWN = -1;
+ static final int IME_STATE_DISABLED = 0;
+ static final int IME_STATE_ENABLED = 1;
+ static final int IME_STATE_PASSWORD = 2;
+
+ // Flags for notifyIMEContext().
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED})
+ /* package */ @interface IMEContextFlags {}
+
+ @WrapForJNI static final int IME_FLAG_PRIVATE_BROWSING = 1 << 0;
+ @WrapForJNI static final int IME_FLAG_USER_ACTION = 1 << 1;
+ @WrapForJNI static final int IME_FOCUS_NOT_CHANGED = 1 << 2;
+
+ void notifyIME(@IMENotificationType int type);
+
+ void notifyIMEContext(
+ @IMEState int state,
+ String typeHint,
+ String modeHint,
+ String actionHint,
+ @IMEContextFlags int flag);
+
+ void onSelectionChange();
+
+ void onTextChange();
+
+ void onDiscardComposition();
+
+ void onDefaultKeyEvent(KeyEvent event);
+
+ void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect);
+ }
+
+ private static final class DefaultDelegate implements GeckoSession.TextInputDelegate {
+ public static final DefaultDelegate INSTANCE = new DefaultDelegate();
+
+ private InputMethodManager getInputMethodManager(@Nullable final View view) {
+ if (view == null) {
+ return null;
+ }
+ return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ }
+
+ @Override
+ public void restartInput(@NonNull final GeckoSession session, final int reason) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm == null) {
+ return;
+ }
+
+ // InputMethodManager has internal logic to detect if we are restarting input
+ // in an already focused View, which is the case here because all content text
+ // fields are inside one LayerView. When this happens, InputMethodManager will
+ // tell the input method to soft reset instead of hard reset. Stock latin IME
+ // on Android 4.2+ has a quirk that when it soft resets, it does not clear the
+ // composition. The following workaround tricks the IME into clearing the
+ // composition when soft resetting.
+ if (InputMethods.needsSoftResetWorkaround(
+ InputMethods.getCurrentInputMethod(view.getContext()))) {
+ // Fake a selection change, because the IME clears the composition when
+ // the selection changes, even if soft-resetting. Offsets here must be
+ // different from the previous selection offsets, and -1 seems to be a
+ // reasonable, deterministic value
+ imm.updateSelection(view, -1, -1, -1, -1);
+ }
+
+ try {
+ imm.restartInput(view);
+ } catch (final RuntimeException e) {
+ Log.e(LOGTAG, "Error restarting input", e);
+ }
+ }
+
+ @Override
+ public void showSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ if (view.hasFocus() && !imm.isActive(view)) {
+ // Marshmallow workaround: The view has focus but it is not the active
+ // view for the input method. (Bug 1211848)
+ view.clearFocus();
+ view.requestFocus();
+ }
+ imm.showSoftInput(view, 0);
+ }
+ }
+
+ @Override
+ public void hideSoftInput(@NonNull final GeckoSession session) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
+ }
+ }
+
+ @Override
+ public void updateSelection(
+ @NonNull final GeckoSession session,
+ final int selStart,
+ final int selEnd,
+ final int compositionStart,
+ final int compositionEnd) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ // When composition start and end is -1,
+ // InputMethodManager.updateSelection will remove composition
+ // on most IMEs. If not working, we have to add a workaround
+ // to EditableListener.onDiscardComposition.
+ imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd);
+ }
+ }
+
+ @Override
+ public void updateExtractedText(
+ @NonNull final GeckoSession session,
+ @NonNull final ExtractedTextRequest request,
+ @NonNull final ExtractedText text) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateExtractedText(view, request.token, text);
+ }
+ }
+
+ @TargetApi(21)
+ @Override
+ public void updateCursorAnchorInfo(
+ @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {
+ ThreadUtils.assertOnUiThread();
+ final View view = session.getTextInput().getView();
+ final InputMethodManager imm = getInputMethodManager(view);
+ if (imm != null) {
+ imm.updateCursorAnchorInfo(view, info);
+ }
+ }
+ }
+
+ private final GeckoSession mSession;
+ private final NativeQueue mQueue;
+ private final GeckoEditable mEditable;
+ private InputConnectionClient mInputConnection;
+ private GeckoSession.TextInputDelegate mDelegate;
+
+ /* package */ SessionTextInput(
+ final @NonNull GeckoSession session, final @NonNull NativeQueue queue) {
+ mSession = session;
+ mQueue = queue;
+ mEditable = new GeckoEditable(session);
+ }
+
+ /* package */ void onWindowChanged(final GeckoSession.Window window) {
+ if (mQueue.isReady()) {
+ window.attachEditable(mEditable);
+ } else {
+ mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable);
+ }
+ }
+
+ /**
+ * Get a Handler for the background input method thread. In order to use a background thread for
+ * input method operations on systems prior to Nougat, first override {@code View.getHandler()}
+ * for the View returning the InputConnection instance, and then call this method from the
+ * overridden method.
+ *
+ * <p>For example:
+ *
+ * <pre>
+ * &#64;Override
+ * public Handler getHandler() {
+ * if (Build.VERSION.SDK_INT &gt;= 24) {
+ * return super.getHandler();
+ * }
+ * return getSession().getTextInput().getHandler(super.getHandler());
+ * }</pre>
+ *
+ * @param defHandler Handler returned by the system {@code getHandler} implementation.
+ * @return Handler to return to the system through {@code getHandler}.
+ */
+ @AnyThread
+ public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) {
+ // May be called on any thread.
+ if (mInputConnection != null) {
+ return mInputConnection.getHandler(defHandler);
+ }
+ return defHandler;
+ }
+
+ /**
+ * Get the current {@link android.view.View} for text input.
+ *
+ * @return Current text input View or null if not set.
+ * @see #setView(View)
+ */
+ @UiThread
+ public @Nullable View getView() {
+ ThreadUtils.assertOnUiThread();
+ return mInputConnection != null ? mInputConnection.getView() : null;
+ }
+
+ /**
+ * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used
+ * to interact with the system input method manager and to display certain text input UI elements.
+ * See the {@code SessionTextInput} class documentation for information on viewless mode, when the
+ * current {@link android.view.View} is not set or set to null.
+ *
+ * @param view Text input View or null to clear current View.
+ * @see #getView()
+ */
+ @UiThread
+ public synchronized void setView(final @Nullable View view) {
+ ThreadUtils.assertOnUiThread();
+
+ if (view == null) {
+ mInputConnection = null;
+ } else if (mInputConnection == null || mInputConnection.getView() != view) {
+ mInputConnection = GeckoInputConnection.create(mSession, view, mEditable);
+ }
+ mEditable.setListener((EditableListener) mInputConnection);
+ }
+
+ /**
+ * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method
+ * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value
+ * will always be null.
+ *
+ * @param attrs EditorInfo instance to be filled on return.
+ * @return InputConnection instance, or null if there is no active input (or if in viewless mode).
+ */
+ @AnyThread
+ public synchronized @Nullable InputConnection onCreateInputConnection(
+ final @NonNull EditorInfo attrs) {
+ // May be called on any thread.
+ mEditable.onCreateInputConnection(attrs);
+
+ if (!mQueue.isReady() || mInputConnection == null) {
+ return null;
+ }
+ return mInputConnection.onCreateInputConnection(attrs);
+ }
+
+ /**
+ * Process a KeyEvent as a pre-IME event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyPreIme(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-down event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyDown(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a key-up event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyUp(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a long-press event.
+ *
+ * @param keyCode Key code.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyLongPress(getView(), keyCode, event);
+ }
+
+ /**
+ * Process a KeyEvent as a multiple-press event.
+ *
+ * @param keyCode Key code.
+ * @param repeatCount Key repeat count.
+ * @param event KeyEvent instance.
+ * @return True if the event was handled.
+ */
+ @UiThread
+ public boolean onKeyMultiple(
+ final int keyCode, final int repeatCount, final @NonNull KeyEvent event) {
+ ThreadUtils.assertOnUiThread();
+ return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event);
+ }
+
+ /**
+ * Set the current text input delegate.
+ *
+ * @param delegate TextInputDelegate instance or null to restore to default.
+ */
+ @UiThread
+ public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Get the current text input delegate.
+ *
+ * @return TextInputDelegate instance or a default instance if no delegate has been set.
+ */
+ @UiThread
+ public @NonNull GeckoSession.TextInputDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ if (mDelegate == null) {
+ mDelegate = DefaultDelegate.INSTANCE;
+ }
+ return mDelegate;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
new file mode 100644
index 0000000000..d25c51ef9a
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java
@@ -0,0 +1,20 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+
+/**
+ * Used by a ContentDelegate to indicate what action to take on a slow script event.
+ *
+ * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String)
+ */
+@AnyThread
+public enum SlowScriptResponse {
+ STOP,
+ CONTINUE;
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
new file mode 100644
index 0000000000..a49cdf26a5
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java
@@ -0,0 +1,405 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.AnyThread;
+import androidx.annotation.LongDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.math.BigInteger;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Locale;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission;
+
+/**
+ * Manage runtime storage data.
+ *
+ * <p>Retrieve an instance via {@link GeckoRuntime#getStorageController}.
+ */
+public final class StorageController {
+ private static final String LOGTAG = "StorageController";
+
+ // Keep in sync with GeckoViewStorageController.ClearFlags.
+ /** Flags used for data clearing operations. */
+ public static class ClearFlags {
+ /** Cookies. */
+ public static final long COOKIES = 1 << 0;
+
+ /** Network cache. */
+ public static final long NETWORK_CACHE = 1 << 1;
+
+ /** Image cache. */
+ public static final long IMAGE_CACHE = 1 << 2;
+
+ /** DOM storages. */
+ public static final long DOM_STORAGES = 1 << 4;
+
+ /** Auth tokens and caches. */
+ public static final long AUTH_SESSIONS = 1 << 5;
+
+ /** Site permissions. */
+ public static final long PERMISSIONS = 1 << 6;
+
+ /** All caches. */
+ public static final long ALL_CACHES = NETWORK_CACHE | IMAGE_CACHE;
+
+ /** All site settings (permissions, content preferences, security settings, etc.). */
+ public static final long SITE_SETTINGS = 1 << 7 | PERMISSIONS;
+
+ /** All site-related data (cookies, storages, caches, permissions, etc.). */
+ public static final long SITE_DATA =
+ 1 << 8 | COOKIES | DOM_STORAGES | ALL_CACHES | PERMISSIONS | SITE_SETTINGS;
+
+ /** All data. */
+ public static final long ALL = 1 << 9;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ flag = true,
+ value = {
+ ClearFlags.COOKIES,
+ ClearFlags.NETWORK_CACHE,
+ ClearFlags.IMAGE_CACHE,
+ ClearFlags.DOM_STORAGES,
+ ClearFlags.AUTH_SESSIONS,
+ ClearFlags.PERMISSIONS,
+ ClearFlags.ALL_CACHES,
+ ClearFlags.SITE_SETTINGS,
+ ClearFlags.SITE_DATA,
+ ClearFlags.ALL
+ })
+ public @interface StorageControllerClearFlags {}
+
+ /**
+ * Clear data for all hosts.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearData(final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given host. Clearing data for a host will not clear data created by its
+ * third-party origins.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param host The host to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromHost(
+ final @NonNull String host, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("host", host);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearHostData", bundle);
+ }
+
+ /**
+ * Clear data owned by the given base domain (eTLD+1). Clearing data for a base domain will also
+ * clear any associated third-party storage. This includes clearing for third-parties embedded by
+ * the domain and for the given domain embedded under other sites.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions prior to clearing data.
+ *
+ * @param baseDomain The base domain to be used.
+ * @param flags Combination of {@link ClearFlags}.
+ * @return A {@link GeckoResult} that will complete when clearing has finished.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> clearDataFromBaseDomain(
+ final @NonNull String baseDomain, final @StorageControllerClearFlags long flags) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("baseDomain", baseDomain);
+ bundle.putLong("flags", flags);
+
+ return EventDispatcher.getInstance().queryVoid("GeckoView:ClearBaseDomainData", bundle);
+ }
+
+ /**
+ * Clear data for the given context ID. Use {@link GeckoSessionSettings.Builder#contextId}.to set
+ * a context ID for a session.
+ *
+ * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no
+ * persistent data is left behind, you need to close all sessions for the given context prior to
+ * clearing data.
+ *
+ * @param contextId The context ID for the storage data to be deleted.
+ */
+ @AnyThread
+ public void clearDataForSessionContext(final @NonNull String contextId) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("contextId", createSafeSessionContextId(contextId));
+
+ EventDispatcher.getInstance().dispatch("GeckoView:ClearSessionContextData", bundle);
+ }
+
+ /* package */ static @Nullable String createSafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null) {
+ return null;
+ }
+ if (contextId.isEmpty()) {
+ // Let's avoid empty strings for Gecko.
+ return "gvctxempty";
+ }
+ // We don't want to restrict the session context ID string options, so to
+ // ensure that the string is safe for Gecko processing, we translate it to
+ // its hex representation.
+ return String.format("gvctx%x", new BigInteger(contextId.getBytes())).toLowerCase(Locale.ROOT);
+ }
+
+ /* package */ static @Nullable String retrieveUnsafeSessionContextId(
+ final @Nullable String contextId) {
+ if (contextId == null || contextId.isEmpty()) {
+ return null;
+ }
+ if ("gvctxempty".equals(contextId)) {
+ return "";
+ }
+ final byte[] bytes = new BigInteger(contextId.substring(5), 16).toByteArray();
+ return new String(bytes, Charset.forName("UTF-8"));
+ }
+
+ /**
+ * Get all currently stored permissions.
+ *
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getAllPermissions() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetAllPermissions")
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID, in normal
+ * mode This API will be deprecated in the future
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1797379
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(final @NonNull String uri) {
+ return getPermissions(uri, null, false);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and default (unset) context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode.
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final boolean privateMode) {
+ return getPermissions(uri, null, privateMode);
+ }
+
+ /**
+ * Get all currently stored permissions for a given URI and context ID.
+ *
+ * @param uri A String representing the URI to get permissions for.
+ * @param contextId A String specifying the context ID.
+ * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal
+ * mode
+ * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link
+ * ContentPermission}s for the URI.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<List<ContentPermission>> getPermissions(
+ final @NonNull String uri, final @Nullable String contextId, final boolean privateMode) {
+ final GeckoBundle msg = new GeckoBundle(2);
+ final int privateBrowsingId = (privateMode) ? 1 : 0;
+ msg.putString("uri", uri);
+ msg.putString("contextId", createSafeSessionContextId(contextId));
+ msg.putInt("privateBrowsingId", privateBrowsingId);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetPermissionsByURI", msg)
+ .map(
+ bundle -> {
+ final GeckoBundle[] permsArray = bundle.getBundleArray("permissions");
+ return ContentPermission.fromBundleArray(permsArray);
+ });
+ }
+
+ /**
+ * Set a new value for an existing permission.
+ *
+ * <p>Note: in private browsing, this value will only be cleared at the end of the session to add
+ * permanent permissions in private browsing, you can use {@link
+ * #setPrivateBrowsingPermanentPermission}.
+ *
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ false);
+ }
+
+ /**
+ * Set a permanent value for a permission in a private browsing session.
+ *
+ * <p>Normally permissions in private browsing are cleared at the end of the session. This method
+ * allows you to set a permanent permission bypassing this behavior.
+ *
+ * <p>Note: permanent permissions in private browsing are web discoverable and might make the user
+ * more easily trackable.
+ *
+ * @see #setPermission
+ * @param perm A {@link ContentPermission} that you wish to update the value of.
+ * @param value The new value for the permission.
+ */
+ @AnyThread
+ public void setPrivateBrowsingPermanentPermission(
+ final @NonNull ContentPermission perm, final @ContentPermission.Value int value) {
+ setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ true);
+ }
+
+ private void setPermissionInternal(
+ final @NonNull ContentPermission perm,
+ final @ContentPermission.Value int value,
+ final boolean allowPermanentPrivateBrowsing) {
+ if (perm.permission == GeckoSession.PermissionDelegate.PERMISSION_TRACKING
+ && value == ContentPermission.VALUE_PROMPT) {
+ Log.w(LOGTAG, "Cannot set a tracking permission to VALUE_PROMPT, aborting.");
+ return;
+ }
+ final GeckoBundle msg = perm.toGeckoBundle();
+ msg.putInt("newValue", value);
+ msg.putBoolean("allowPermanentPrivateBrowsing", allowPermanentPrivateBrowsing);
+ EventDispatcher.getInstance().dispatch("GeckoView:SetPermission", msg);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given {@link
+ * ContentBlocking.CBCookieBannerMode} should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeForDomain(
+ final @NonNull String uri,
+ final @ContentBlocking.CBCookieBannerMode int mode,
+ final boolean isPrivateBrowsing) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", false);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri in private mode.
+ *
+ * @param uri for which you want to change the {@link ContentBlocking.CBCookieBannerMode} value.
+ * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri.
+ * @return A {@link GeckoResult} that will complete when the mode has been set.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> setCookieBannerModeAndPersistInPrivateBrowsingForDomain(
+ final @NonNull String uri, final @ContentBlocking.CBCookieBannerMode int mode) {
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putInt("mode", mode);
+ data.putBoolean("allowPermanentPrivateBrowsing", true);
+ return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Removes a {@link ContentBlocking.CBCookieBannerMode} for the given uri and and browsing mode.
+ *
+ * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode}
+ * value.
+ * @param isPrivateBrowsing Indicates in which mode the given mode should be applied.
+ * @return A {@link GeckoResult} that will complete when the mode has been removed.
+ */
+ @AnyThread
+ public @NonNull GeckoResult<Void> removeCookieBannerModeForDomain(
+ final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(3);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryVoid("GeckoView:RemoveCookieBannerModeForDomain", data);
+ }
+
+ /**
+ * Gets the actual {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode.
+ *
+ * @param uri An uri for which you want get the {@link ContentBlocking.CBCookieBannerMode}.
+ * @param isPrivateBrowsing Indicates in which browsing mode the given uri should be.
+ * @return A {@link GeckoResult} that resolves to a {@link ContentBlocking.CBCookieBannerMode} for
+ * the given uri and browsing mode.
+ */
+ @AnyThread
+ public @NonNull @ContentBlocking.CBCookieBannerMode GeckoResult<Integer>
+ getCookieBannerModeForDomain(final @NonNull String uri, final boolean isPrivateBrowsing) {
+
+ final GeckoBundle data = new GeckoBundle(2);
+ data.putString("uri", uri);
+ data.putBoolean("isPrivateBrowsing", isPrivateBrowsing);
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:GetCookieBannerModeForDomain", data)
+ .map(StorageController::cookieBannerModeFromBundle, StorageController::fromQueryException);
+ }
+
+ private static @ContentBlocking.CBCookieBannerMode int cookieBannerModeFromBundle(
+ final GeckoBundle bundle) throws Exception {
+ if (bundle == null) {
+ throw new Exception("Unable to parse cookie banner mode");
+ }
+ return bundle.getInt("mode");
+ }
+
+ private static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ return new Exception(response.toString());
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java
new file mode 100644
index 0000000000..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<Transport> getTransportsForByte(final byte transports) {
+ final ArrayList<Transport> result = new ArrayList<Transport>();
+ if ((transports & AUTHENTICATOR_TRANSPORT_USB) == AUTHENTICATOR_TRANSPORT_USB) {
+ result.add(Transport.USB);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_NFC) == AUTHENTICATOR_TRANSPORT_NFC) {
+ result.add(Transport.NFC);
+ }
+ if ((transports & AUTHENTICATOR_TRANSPORT_BLE) == AUTHENTICATOR_TRANSPORT_BLE) {
+ result.add(Transport.BLUETOOTH_LOW_ENERGY);
+ }
+
+ return result;
+ }
+
+ public static class WebAuthnPublicCredential {
+ public final byte[] id;
+ public final byte transports;
+
+ public WebAuthnPublicCredential(final byte[] aId, final byte aTransports) {
+ this.id = aId;
+ this.transports = aTransports;
+ }
+
+ static ArrayList<WebAuthnPublicCredential> CombineBuffers(
+ final Object[] idObjectList, final ByteBuffer transportList) {
+ if (idObjectList.length != transportList.remaining()) {
+ throw new RuntimeException("Couldn't extract allowed list!");
+ }
+
+ final ArrayList<WebAuthnPublicCredential> credList =
+ new ArrayList<WebAuthnPublicCredential>();
+
+ final byte[] transportBytes = new byte[transportList.remaining()];
+ transportList.get(transportBytes);
+
+ for (int i = 0; i < idObjectList.length; i++) {
+ final ByteBuffer id = (ByteBuffer) idObjectList[i];
+ final byte[] idBytes = new byte[id.remaining()];
+ id.get(idBytes);
+
+ credList.add(new WebAuthnPublicCredential(idBytes, transportBytes[i]));
+ }
+ return credList;
+ }
+ }
+
+ // From WebAuthentication.webidl
+ public enum AttestationPreference {
+ NONE,
+ INDIRECT,
+ DIRECT,
+ }
+
+ @WrapForJNI
+ public static class MakeCredentialResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] attestationObject;
+
+ public MakeCredentialResponse(
+ final byte[] clientDataJson, final byte[] keyHandle, final byte[] attestationObject) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.attestationObject = attestationObject;
+ }
+ }
+
+ public static class Exception extends RuntimeException {
+ public Exception(final String error) {
+ super(error);
+ }
+ }
+
+ public static GeckoResult<MakeCredentialResponse> makeCredential(
+ final GeckoBundle credentialBundle,
+ final byte[] userId,
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] excludeList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ if (!credentialBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final PublicKeyCredentialCreationOptions.Builder requestBuilder =
+ new PublicKeyCredentialCreationOptions.Builder();
+
+ final List<PublicKeyCredentialParameters> params =
+ new ArrayList<PublicKeyCredentialParameters>();
+
+ // WebAuthn supports more algorithms
+ for (final Algorithm algo : SUPPORTED_ALGORITHMS) {
+ params.add(
+ new PublicKeyCredentialParameters(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(), algo.getAlgoValue()));
+ }
+
+ final PublicKeyCredentialUserEntity user =
+ new PublicKeyCredentialUserEntity(
+ userId,
+ credentialBundle.getString("userName", ""),
+ credentialBundle.getString("userIcon", ""),
+ credentialBundle.getString("userDisplayName", ""));
+
+ AttestationConveyancePreference pref = AttestationConveyancePreference.NONE;
+ final String attestationPreference =
+ authenticatorSelection.getString("attestationPreference", "NONE");
+ if (attestationPreference.equalsIgnoreCase(AttestationConveyancePreference.DIRECT.name())) {
+ pref = AttestationConveyancePreference.DIRECT;
+ } else if (attestationPreference.equalsIgnoreCase(
+ AttestationConveyancePreference.INDIRECT.name())) {
+ pref = AttestationConveyancePreference.INDIRECT;
+ }
+
+ final AuthenticatorSelectionCriteria.Builder selBuild =
+ new AuthenticatorSelectionCriteria.Builder();
+ if (authenticatorSelection.getInt("requirePlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.PLATFORM);
+ }
+ if (authenticatorSelection.getInt("requireCrossPlatformAttachment", 0) == 1) {
+ selBuild.setAttachment(Attachment.CROSS_PLATFORM);
+ }
+ final 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<PublicKeyCredentialDescriptor> excludedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) {
+ excludedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final PublicKeyCredentialRpEntity rp =
+ new PublicKeyCredentialRpEntity(
+ credentialBundle.getString("rpId"),
+ credentialBundle.getString("rpName", ""),
+ credentialBundle.getString("rpIcon", ""));
+
+ final PublicKeyCredentialCreationOptions requestOptions =
+ requestBuilder
+ .setUser(user)
+ .setAttestationConveyancePreference(pref)
+ .setAuthenticatorSelection(sel)
+ .setAuthenticationExtensions(ext)
+ .setChallenge(challenge)
+ .setRp(rp)
+ .setParameters(params)
+ .setTimeoutSeconds(credentialBundle.getLong("timeoutMS") / 1000.0)
+ .setExcludeList(excludedList)
+ .build();
+
+ final Uri origin = Uri.parse(credentialBundle.getString("origin"));
+
+ final BrowserPublicKeyCredentialCreationOptions browserOptions =
+ new BrowserPublicKeyCredentialCreationOptions.Builder()
+ .setPublicKeyCredentialCreationOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ // Certain Fenix builds and signing keys are whitelisted for Web Authentication.
+ // See https://wiki.mozilla.org/Security/Web_Authentication
+ //
+ // Third party apps will need to get whitelisted themselves.
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(browserOptions);
+ } else {
+ // For non-official builds, websites have to opt-in to permit the
+ // particular version of Gecko to perform WebAuthn operations on
+ // them. See https://developers.google.com/digital-asset-links
+ // for the general form, and Step 1 of
+ // https://developers.google.com/identity/fido/android/native-apps
+ // for details about doing this correctly for the FIDO2 API.
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getRegisterPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<MakeCredentialResponse> result = new GeckoResult<>();
+
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ final byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ if (rspData != null) {
+ final AuthenticatorAttestationResponse responseData =
+ AuthenticatorAttestationResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "attestation Object: "
+ + Base64.encodeToString(
+ responseData.getAttestationObject(), Base64.DEFAULT));
+
+ result.complete(
+ new WebAuthnTokenManager.MakeCredentialResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAttestationObject()));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to launch activity: ", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+ });
+
+ intentTask.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR"));
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<MakeCredentialResponse> webAuthnMakeCredential(
+ final GeckoBundle credentialBundle,
+ final ByteBuffer userId,
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle authenticatorSelection,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> excludeList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ final byte[] userBytes = new byte[userId.remaining()];
+ try {
+ challenge.get(challBytes);
+ userId.get(userBytes);
+
+ excludeList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return makeCredential(
+ credentialBundle,
+ userBytes,
+ challBytes,
+ excludeList.toArray(new WebAuthnPublicCredential[0]),
+ authenticatorSelection,
+ extensions);
+ } catch (final Exception e) {
+ // We need to ensure we catch any possible exception here in order to ensure
+ // that the Promise on the content side is appropriately rejected. In particular,
+ // we will get `NoClassDefFoundError` if we're running on a device that does not
+ // have Google Play Services.
+ Log.w(LOGTAG, "Couldn't make credential", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI
+ public static class GetAssertionResponse {
+ public final byte[] clientDataJson;
+ public final byte[] keyHandle;
+ public final byte[] authData;
+ public final byte[] signature;
+ public final byte[] userHandle;
+
+ public GetAssertionResponse(
+ final byte[] clientDataJson,
+ final byte[] keyHandle,
+ final byte[] authData,
+ final byte[] signature,
+ final byte[] userHandle) {
+ this.clientDataJson = clientDataJson;
+ this.keyHandle = keyHandle;
+ this.authData = authData;
+ this.signature = signature;
+ this.userHandle = userHandle;
+ }
+ }
+
+ private static WebAuthnTokenManager.Exception parseErrorIntent(final Intent intent) {
+ if (!intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) {
+ return null;
+ }
+
+ final byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA);
+ final AuthenticatorErrorResponse responseData =
+ AuthenticatorErrorResponse.deserializeFromBytes(errData);
+
+ Log.e(LOGTAG, "errorCode.name: " + responseData.getErrorCode());
+ Log.e(LOGTAG, "errorMessage: " + responseData.getErrorMessage());
+
+ return new WebAuthnTokenManager.Exception(responseData.getErrorCode().name());
+ }
+
+ private static GeckoResult<GetAssertionResponse> getAssertion(
+ final byte[] challenge,
+ final WebAuthnTokenManager.WebAuthnPublicCredential[] allowList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+
+ if (!assertionBundle.containsKey("isWebAuthn")) {
+ // FIDO U2F not supported by Android (for us anyway) at this time
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR"));
+ }
+
+ final List<PublicKeyCredentialDescriptor> allowedList =
+ new ArrayList<PublicKeyCredentialDescriptor>();
+ for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) {
+ allowedList.add(
+ new PublicKeyCredentialDescriptor(
+ PublicKeyCredentialType.PUBLIC_KEY.toString(),
+ cred.id,
+ getTransportsForByte(cred.transports)));
+ }
+
+ final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder();
+ if (extensions.containsKey("fidoAppId")) {
+ extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId")));
+ }
+ final AuthenticationExtensions ext = extBuilder.build();
+
+ final PublicKeyCredentialRequestOptions requestOptions =
+ new PublicKeyCredentialRequestOptions.Builder()
+ .setChallenge(challenge)
+ .setAllowList(allowedList)
+ .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0)
+ .setRpId(assertionBundle.getString("rpId"))
+ .setAuthenticationExtensions(ext)
+ .build();
+
+ final Uri origin = Uri.parse(assertionBundle.getString("origin"));
+ final BrowserPublicKeyCredentialRequestOptions browserOptions =
+ new BrowserPublicKeyCredentialRequestOptions.Builder()
+ .setPublicKeyCredentialRequestOptions(requestOptions)
+ .setOrigin(origin)
+ .build();
+
+ final Task<PendingIntent> intentTask;
+ // See the makeCredential method for documentation about this
+ // conditional.
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(browserOptions);
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+
+ intentTask = fidoClient.getSignPendingIntent(requestOptions);
+ }
+
+ final GeckoResult<GetAssertionResponse> result = new GeckoResult<>();
+ intentTask.addOnSuccessListener(
+ pendingIntent -> {
+ GeckoRuntime.getInstance()
+ .startActivityForResult(pendingIntent)
+ .accept(
+ intent -> {
+ final WebAuthnTokenManager.Exception error = parseErrorIntent(intent);
+ if (error != null) {
+ result.completeExceptionally(error);
+ return;
+ }
+
+ if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) {
+ final byte[] rspData =
+ intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA);
+ final AuthenticatorAssertionResponse responseData =
+ AuthenticatorAssertionResponse.deserializeFromBytes(rspData);
+
+ Log.d(
+ LOGTAG,
+ "key handle: "
+ + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "clientDataJSON: "
+ + Base64.encodeToString(
+ responseData.getClientDataJSON(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "auth data: "
+ + Base64.encodeToString(
+ responseData.getAuthenticatorData(), Base64.DEFAULT));
+ Log.d(
+ LOGTAG,
+ "signature: "
+ + Base64.encodeToString(responseData.getSignature(), Base64.DEFAULT));
+
+ // Nullable field
+ byte[] userHandle = responseData.getUserHandle();
+ if (userHandle == null) {
+ userHandle = new byte[0];
+ }
+
+ result.complete(
+ new WebAuthnTokenManager.GetAssertionResponse(
+ responseData.getClientDataJSON(),
+ responseData.getKeyHandle(),
+ responseData.getAuthenticatorData(),
+ responseData.getSignature(),
+ userHandle));
+ }
+ },
+ e -> {
+ Log.w(LOGTAG, "Failed to get FIDO intent", e);
+ result.completeExceptionally(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ });
+ });
+
+ return result;
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<GetAssertionResponse> webAuthnGetAssertion(
+ final ByteBuffer challenge,
+ final Object[] idList,
+ final ByteBuffer transportList,
+ final GeckoBundle assertionBundle,
+ final GeckoBundle extensions) {
+ final ArrayList<WebAuthnPublicCredential> allowList;
+
+ final byte[] challBytes = new byte[challenge.remaining()];
+ try {
+ challenge.get(challBytes);
+ allowList = WebAuthnPublicCredential.CombineBuffers(idList, transportList);
+ } catch (final RuntimeException e) {
+ Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+
+ try {
+ return getAssertion(
+ challBytes,
+ allowList.toArray(new WebAuthnPublicCredential[0]),
+ assertionBundle,
+ extensions);
+ } catch (final java.lang.Exception e) {
+ Log.w(LOGTAG, "Couldn't get assertion", e);
+ return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR"));
+ }
+ }
+
+ @WrapForJNI(calledFrom = "gecko")
+ private static GeckoResult<Boolean> webAuthnIsUserVerifyingPlatformAuthenticatorAvailable() {
+ final Task<Boolean> task;
+ if (BuildConfig.MOZILLA_OFFICIAL) {
+ final Fido2PrivilegedApiClient fidoClient =
+ Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ } else {
+ final Fido2ApiClient fidoClient =
+ Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext());
+ task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable();
+ }
+
+ final GeckoResult<Boolean> res = new GeckoResult<>();
+ task.addOnSuccessListener(
+ isUVPAA -> {
+ res.complete(isUVPAA);
+ });
+ task.addOnFailureListener(
+ e -> {
+ Log.w(LOGTAG, "isUserVerifyingPlatformAuthenticatorAvailable is failed", e);
+ res.complete(false);
+ });
+ return res;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java
new file mode 100644
index 0000000000..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 {
+ /**
+ * <code>file:</code> or <code>resource:</code> URI that points to the install location of this
+ * WebExtension. When the WebExtension is included with the APK the file can be specified using
+ * the <code>resource://android</code> alias. E.g.
+ *
+ * <pre><code>
+ * resource://android/assets/web_extensions/my_webextension/
+ * </code></pre>
+ *
+ * Will point to folder <code>/assets/web_extensions/my_webextension/</code> in the APK.
+ */
+ public final @NonNull String location;
+
+ /** Unique identifier for this WebExtension */
+ public final @NonNull String id;
+
+ /** {@link Flags} for this WebExtension. */
+ public final @WebExtensionFlags long flags;
+
+ /** Provides information about this {@link WebExtension}. */
+ public final @NonNull MetaData metaData;
+
+ /**
+ * Whether this extension is built-in. Built-in extension can be installed using {@link
+ * WebExtensionController#installBuiltIn}.
+ */
+ public final boolean isBuiltIn;
+
+ /**
+ * Called whenever a delegate is set or unset on this {@link WebExtension} instance. /* package
+ */
+ interface DelegateController {
+ void onMessageDelegate(final String nativeApp, final MessageDelegate delegate);
+
+ void onActionDelegate(final ActionDelegate delegate);
+
+ void onBrowsingDataDelegate(final BrowsingDataDelegate delegate);
+
+ void onTabDelegate(final TabDelegate delegate);
+
+ void onDownloadDelegate(final DownloadDelegate delegate);
+
+ ActionDelegate getActionDelegate();
+
+ BrowsingDataDelegate getBrowsingDataDelegate();
+
+ TabDelegate getTabDelegate();
+
+ DownloadDelegate getDownloadDelegate();
+ }
+
+ /* package */ interface DelegateControllerProvider {
+ @NonNull
+ DelegateController controllerFor(final WebExtension extension);
+ }
+
+ private final DelegateController mDelegateController;
+
+ @Override
+ public String toString() {
+ return "WebExtension {"
+ + "location="
+ + location
+ + ", "
+ + "id="
+ + id
+ + ", "
+ + "flags="
+ + flags
+ + "}";
+ }
+
+ private static final String LOGTAG = "WebExtension";
+
+ // Keep in sync with GeckoViewWebExtension.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.
+ *
+ * <p>This message delegate will receive messages from the background script for the native app
+ * specified in <code>nativeApp</code>.
+ *
+ * <p>For messages from content scripts, set a session-specific message delegate using {@link
+ * SessionController#setMessageDelegate}.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging">
+ * WebExtensions/Native_messaging </a>
+ *
+ * @param messageDelegate handles messaging between the WebExtension and the app. To send a
+ * message from the WebExtension use the <code>runtime.sendNativeMessage</code> WebExtension
+ * API: E.g.
+ * <pre><code>
+ * browser.runtime.sendNativeMessage(nativeApp,
+ * {message: "Hello from WebExtension!"});
+ * </code></pre>
+ * For bidirectional communication, use <code>runtime.connectNative</code>. E.g. in a content
+ * script:
+ * <pre><code>
+ * let port = browser.runtime.connectNative(nativeApp);
+ * port.onMessage.addListener(message =&gt; {
+ * console.log("Message received from app");
+ * });
+ * port.postMessage("Ping from WebExtension");
+ * </code></pre>
+ * The code above will trigger a {@link MessageDelegate#onConnect} call that will contain the
+ * corresponding {@link Port} object that can be used to send messages to the WebExtension.
+ * Note: the <code>nativeApp</code> specified in the WebExtension needs to match the <code>
+ * nativeApp</code> parameter of this method.
+ * <p>You can unset the message delegate by setting a <code>null</code> messageDelegate.
+ * @param nativeApp which native app id this message delegate will handle messaging for. Needs to
+ * match the <code>application</code> parameter of <code>runtime.sendNativeMessage</code> and
+ * <code>runtime.connectNative</code>.
+ * @see SessionController#setMessageDelegate
+ */
+ @UiThread
+ public void setMessageDelegate(
+ final @Nullable MessageDelegate messageDelegate, final @NonNull String nativeApp) {
+ mDelegateController.onMessageDelegate(nativeApp, messageDelegate);
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @LongDef(
+ value = {
+ BrowsingDataDelegate.Type.CACHE,
+ BrowsingDataDelegate.Type.COOKIES,
+ BrowsingDataDelegate.Type.DOWNLOADS,
+ BrowsingDataDelegate.Type.FORM_DATA,
+ BrowsingDataDelegate.Type.HISTORY,
+ BrowsingDataDelegate.Type.LOCAL_STORAGE,
+ BrowsingDataDelegate.Type.PASSWORDS
+ },
+ flag = true)
+ public @interface BrowsingDataTypes {}
+
+ /**
+ * This delegate is used to handle calls from the |browsingData| WebExtension API.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData">
+ * WebExtensions/API/browsingData </a>
+ */
+ @UiThread
+ public interface BrowsingDataDelegate {
+ /**
+ * This class represents the current default settings for the "Clear Data" functionality in the
+ * browser.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/settings">
+ * WebExtensions/API/browsingData/settings </a>
+ */
+ @UiThread
+ class Settings {
+ /**
+ * Currently selected setting in the browser's "Clear Data" UI for how far back in time to
+ * remove data given in milliseconds since the UNIX epoch.
+ */
+ public final int sinceUnixTimestamp;
+
+ /**
+ * Data types that can be toggled in the browser's "Clear Data" UI. One or more flags from
+ * {@link Type}.
+ */
+ public final @BrowsingDataTypes long toggleableTypes;
+
+ /**
+ * Data types currently selected in the browser's "Clear Data" UI. One or more flags from
+ * {@link Type}.
+ */
+ public final @BrowsingDataTypes long selectedTypes;
+
+ /**
+ * Creates an instance of Settings.
+ *
+ * <p>This class represents the current default settings for the "Clear Data" functionality in
+ * the browser.
+ *
+ * @param since Currently selected setting in the browser's "Clear Data" UI for how far back
+ * in time to remove data given in milliseconds since the UNIX epoch.
+ * @param toggleableTypes Data types that can be toggled in the browser's "Clear Data" UI. One
+ * or more flags from {@link Type}.
+ * @param selectedTypes Data types currently selected in the browser's "Clear Data" UI. One or
+ * more flags from {@link Type}.
+ */
+ @UiThread
+ public Settings(
+ final int since,
+ final @BrowsingDataTypes long toggleableTypes,
+ final @BrowsingDataTypes long selectedTypes) {
+ this.toggleableTypes = toggleableTypes;
+ this.selectedTypes = selectedTypes;
+ this.sinceUnixTimestamp = since;
+ }
+
+ private GeckoBundle fromBrowsingDataType(final @BrowsingDataTypes long types) {
+ final GeckoBundle result = new GeckoBundle(7);
+ result.putBoolean("cache", (types & Type.CACHE) != 0);
+ result.putBoolean("cookies", (types & Type.COOKIES) != 0);
+ result.putBoolean("downloads", (types & Type.DOWNLOADS) != 0);
+ result.putBoolean("formData", (types & Type.FORM_DATA) != 0);
+ result.putBoolean("history", (types & Type.HISTORY) != 0);
+ result.putBoolean("localStorage", (types & Type.LOCAL_STORAGE) != 0);
+ result.putBoolean("passwords", (types & Type.PASSWORDS) != 0);
+ return result;
+ }
+
+ /* package */ GeckoBundle toGeckoBundle() {
+ final GeckoBundle options = new GeckoBundle(1);
+ options.putLong("since", sinceUnixTimestamp);
+
+ final GeckoBundle result = new GeckoBundle(3);
+ result.putBundle("options", options);
+ result.putBundle("dataToRemove", fromBrowsingDataType(selectedTypes));
+ result.putBundle("dataRemovalPermitted", fromBrowsingDataType(toggleableTypes));
+ return result;
+ }
+ }
+
+ /** Types of data that a browser "Clear Data" UI might have access to. */
+ class Type {
+ protected Type() {}
+
+ public static final long CACHE = 1 << 0;
+ public static final long COOKIES = 1 << 1;
+ public static final long DOWNLOADS = 1 << 2;
+ public static final long FORM_DATA = 1 << 3;
+ public static final long HISTORY = 1 << 4;
+ public static final long LOCAL_STORAGE = 1 << 5;
+ public static final long PASSWORDS = 1 << 6;
+ }
+
+ /**
+ * Gets current settings for the browser's "Clear Data" UI.
+ *
+ * @return a {@link GeckoResult} that resolves to an instance of {@link Settings} that
+ * represents the current state for the browser's "Clear Data" UI.
+ * @see Settings
+ */
+ @Nullable
+ default GeckoResult<Settings> onGetSettings() {
+ return null;
+ }
+
+ /**
+ * Clear form data created after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearFormData(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear passwords saved after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearPasswords(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear history saved after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearHistory(final long sinceUnixTimestamp) {
+ return null;
+ }
+
+ /**
+ * Clear downloads created after the given timestamp.
+ *
+ * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch.
+ * @return a {@link GeckoResult} that resolves when data has been cleared.
+ */
+ @Nullable
+ default GeckoResult<Void> onClearDownloads(final long sinceUnixTimestamp) {
+ return null;
+ }
+ }
+
+ /** Delegates that responds to messages sent from a WebExtension. */
+ @UiThread
+ public interface MessageDelegate {
+ /**
+ * Called whenever the WebExtension sends a message to an app using <code>
+ * runtime.sendNativeMessage</code>.
+ *
+ * @param nativeApp The application identifier of the MessageDelegate that sent this message.
+ * @param message The message that was sent, either a primitive type or a {@link
+ * org.json.JSONObject}.
+ * @param sender The {@link MessageSender} corresponding to the frame that originated the
+ * message.
+ * <p>Note: all messages are to be considered untrusted and should be checked carefully for
+ * validity.
+ * @return A {@link GeckoResult} that resolves with a response to the message.
+ */
+ @Nullable
+ default GeckoResult<Object> onMessage(
+ final @NonNull String nativeApp,
+ final @NonNull Object message,
+ final @NonNull MessageSender sender) {
+ return null;
+ }
+
+ /**
+ * Called whenever the WebExtension connects to an app using <code>runtime.connectNative</code>.
+ *
+ * @param port {@link Port} instance that can be used to send and receive messages from the
+ * WebExtension. Use {@link Port#sender} to verify the origin of this connection request.
+ */
+ @Nullable
+ default void onConnect(final @NonNull Port port) {}
+ }
+
+ /**
+ * Delegate that handles communication from a WebExtension on a specific {@link Port} instance.
+ */
+ @UiThread
+ public interface PortDelegate {
+ /**
+ * Called whenever a message is sent through the corresponding {@link Port} instance.
+ *
+ * @param message The message that was sent, either a primitive type or a {@link
+ * org.json.JSONObject}.
+ * @param port The {@link Port} instance that received this message.
+ */
+ default void onPortMessage(final @NonNull Object message, final @NonNull Port port) {}
+
+ /**
+ * Called whenever the corresponding {@link Port} instance is disconnected or the corresponding
+ * {@link GeckoSession} is destroyed. Any message sent from the port after this call will be
+ * ignored.
+ *
+ * @param port The {@link Port} instance that was disconnected.
+ */
+ @NonNull
+ default void onDisconnect(final @NonNull Port port) {}
+ }
+
+ /**
+ * Port object that can be used for bidirectional communication with a WebExtension.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port">
+ * WebExtensions/API/runtime/Port </a>.
+ *
+ * @see MessageDelegate#onConnect
+ */
+ @UiThread
+ public static class Port {
+ /* package */ final long id;
+ /* package */ PortDelegate delegate;
+ /* package */ boolean disconnected = false;
+ /* package */ final EventDispatcher mEventDispatcher;
+ /* package */ boolean mListenersRegistered = false;
+
+ /** {@link MessageSender} corresponding to this port. */
+ public @NonNull final MessageSender sender;
+
+ /** The application identifier of the MessageDelegate that opened this port. */
+ public @NonNull final String name;
+
+ /** Override for tests. */
+ protected Port() {
+ this.id = -1;
+ this.delegate = null;
+ this.sender = null;
+ this.name = null;
+ mEventDispatcher = null;
+ }
+
+ /* package */ Port(final String name, final long id, final MessageSender sender) {
+ this.id = id;
+ this.delegate = null;
+ this.sender = sender;
+ this.name = name;
+ mEventDispatcher = EventDispatcher.byName("port:" + id);
+ }
+
+ private BundleEventListener mEventListener =
+ new BundleEventListener() {
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if ("GeckoView:WebExtension:Disconnect".equals(event)) {
+ disconnectFromExtension(callback);
+ } else if ("GeckoView:WebExtension:PortMessage".equals(event)) {
+ portMessage(message, callback);
+ }
+ }
+ };
+
+ private void disconnectFromExtension(final EventCallback callback) {
+ delegate.onDisconnect(this);
+ disconnected();
+ }
+
+ private void portMessage(final GeckoBundle bundle, final EventCallback callback) {
+ final Object content;
+ try {
+ content = bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex);
+ return;
+ }
+
+ delegate.onPortMessage(content, this);
+ }
+
+ /**
+ * Post a message to the WebExtension connected to this {@link Port} instance.
+ *
+ * @param message {@link JSONObject} that will be sent to the WebExtension.
+ */
+ public void postMessage(final @NonNull JSONObject message) {
+ final GeckoBundle args = new GeckoBundle(1);
+ try {
+ args.putBundle("message", GeckoBundle.fromJSONObject(message));
+ } catch (final JSONException ex) {
+ throw new RuntimeException(ex);
+ }
+
+ mEventDispatcher.dispatch("GeckoView:WebExtension:PortMessageFromApp", args);
+ }
+
+ /** Disconnects this port and notifies the other end. */
+ public void disconnect() {
+ if (this.disconnected) {
+ return;
+ }
+
+ final GeckoBundle args = new GeckoBundle(1);
+ args.putLong("portId", id);
+
+ mEventDispatcher.dispatch("GeckoView:WebExtension:PortDisconnect", args);
+ disconnected();
+ }
+
+ private void disconnected() {
+ unregisterListeners();
+ mEventDispatcher.shutdown();
+ this.disconnected = true;
+ }
+
+ /**
+ * Set a delegate for incoming messages through this {@link Port}.
+ *
+ * @param delegate Delegate that will receive messages sent through this {@link Port}.
+ */
+ public void setDelegate(final @Nullable PortDelegate delegate) {
+ this.delegate = delegate;
+
+ if (delegate != null) {
+ registerListeners();
+ } else {
+ unregisterListeners();
+ }
+ }
+
+ private void unregisterListeners() {
+ if (!mListenersRegistered) {
+ return;
+ }
+
+ mEventDispatcher.unregisterUiThreadListener(
+ mEventListener,
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:WebExtension:PortMessage");
+ mListenersRegistered = false;
+ }
+
+ private void registerListeners() {
+ if (mListenersRegistered) {
+ return;
+ }
+
+ mEventDispatcher.registerUiThreadListener(
+ mEventListener,
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:WebExtension:PortMessage");
+ mListenersRegistered = true;
+ }
+ }
+
+ /**
+ * This delegate is invoked whenever an extension uses the `tabs` WebExtension API to modify the
+ * state of a tab. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ */
+ public interface SessionTabDelegate {
+ /**
+ * Called when tabs.remove is invoked, this method decides if WebExtension can close the tab. In
+ * case WebExtension can close the tab, it should close passed GeckoSession and return
+ * GeckoResult.ALLOW or GeckoResult.DENY in case tab cannot be closed.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove">
+ * WebExtensions/API/tabs/remove</a>
+ *
+ * @param source An instance of {@link WebExtension}
+ * @param session An instance of {@link GeckoSession} to be closed.
+ * @return GeckoResult.ALLOW if the tab will be closed, GeckoResult.DENY otherwise
+ */
+ @UiThread
+ @NonNull
+ default GeckoResult<AllowOrDeny> onCloseTab(
+ @Nullable final WebExtension source, @NonNull final GeckoSession session) {
+ return GeckoResult.deny();
+ }
+
+ /**
+ * Called when tabs.update is invoked. The uri is provided for informational purposes, there's
+ * no need to call <code>loadURI</code> on it. The page will be loaded if this method returns
+ * GeckoResult.ALLOW.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update">
+ * WebExtensions/API/tabs/update</a>
+ *
+ * @param extension The extension that requested to update the tab.
+ * @param session The {@link GeckoSession} instance that needs to be updated.
+ * @param details {@link UpdateTabDetails} instance that describes what needs to be updated for
+ * this tab.
+ * @return <code>GeckoResult.ALLOW</code> if the tab will be updated, <code>GeckoResult.DENY
+ * </code> otherwise.
+ */
+ @UiThread
+ @NonNull
+ default GeckoResult<AllowOrDeny> onUpdateTab(
+ final @NonNull WebExtension extension,
+ final @NonNull GeckoSession session,
+ final @NonNull UpdateTabDetails details) {
+ return GeckoResult.deny();
+ }
+ }
+
+ /**
+ * Provides details about upating a tab with <code>tabs.update</code>.
+ *
+ * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update">
+ * WebExtensions/API/tabs/update </a>.
+ */
+ public static class UpdateTabDetails {
+ /**
+ * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs
+ * should stop being highlighted. If <code>false</code>, does nothing.
+ */
+ @Nullable public final Boolean active;
+
+ /** Whether the tab should be discarded automatically by the app when resources are low. */
+ @Nullable public final Boolean autoDiscardable;
+
+ /** If <code>true</code> and the tab is not highlighted, it should become active by default. */
+ @Nullable public final Boolean highlighted;
+
+ /** Whether the tab should be muted. */
+ @Nullable public final Boolean muted;
+
+ /** Whether the tab should be pinned. */
+ @Nullable public final Boolean pinned;
+
+ /**
+ * The url that the tab will be navigated to. This url is provided just for informational
+ * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession}
+ * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link
+ * SessionTabDelegate#onUpdateTab}
+ */
+ @Nullable public final String url;
+
+ /** For testing. */
+ protected UpdateTabDetails() {
+ active = null;
+ autoDiscardable = null;
+ highlighted = null;
+ muted = null;
+ pinned = null;
+ url = null;
+ }
+
+ /* package */ UpdateTabDetails(final GeckoBundle bundle) {
+ active = bundle.getBooleanObject("active");
+ autoDiscardable = bundle.getBooleanObject("autoDiscardable");
+ highlighted = bundle.getBooleanObject("highlighted");
+ muted = bundle.getBooleanObject("muted");
+ pinned = bundle.getBooleanObject("pinned");
+ url = bundle.getString("url");
+ }
+ }
+
+ /**
+ * Provides details about creating a tab with <code>tabs.create</code>. See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create">
+ * WebExtensions/API/tabs/create </a>.
+ *
+ * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>.
+ */
+ public static class CreateTabDetails {
+ /**
+ * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs
+ * should stop being highlighted. If <code>false</code>, does nothing.
+ */
+ @Nullable public final Boolean active;
+
+ /**
+ * The CookieStoreId used for the tab. This option is only available if the extension has the
+ * "cookies" permission.
+ */
+ @Nullable public final String cookieStoreId;
+
+ /**
+ * Whether the tab is created and made visible in the tab bar without any content loaded into
+ * memory, a state known as discarded. The tab’s content should be loaded when the tab is
+ * activated.
+ */
+ @Nullable public final Boolean discarded;
+
+ /** The position the tab should take in the window. */
+ @Nullable public final Integer index;
+
+ /** If true, open this tab in Reader Mode. */
+ @Nullable public final Boolean openInReaderMode;
+
+ /** Whether the tab should be pinned. */
+ @Nullable public final Boolean pinned;
+
+ /**
+ * The url that the tab will be navigated to. This url is provided just for informational
+ * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession}
+ * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link
+ * TabDelegate#onNewTab}
+ */
+ @Nullable public final String url;
+
+ /** For testing. */
+ protected CreateTabDetails() {
+ active = null;
+ cookieStoreId = null;
+ discarded = null;
+ index = null;
+ openInReaderMode = null;
+ pinned = null;
+ url = null;
+ }
+
+ /* package */ CreateTabDetails(final GeckoBundle bundle) {
+ active = bundle.getBooleanObject("active");
+ cookieStoreId = bundle.getString("cookieStoreId");
+ discarded = bundle.getBooleanObject("discarded");
+ index = bundle.getInteger("index");
+ openInReaderMode = bundle.getBooleanObject("openInReaderMode");
+ pinned = bundle.getBooleanObject("pinned");
+ url = bundle.getString("url");
+ }
+ }
+
+ /**
+ * This delegate is invoked whenever an extension uses the `tabs` WebExtension API and the request
+ * is not specific to an existing tab, e.g. when creating a new tab. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ */
+ public interface TabDelegate {
+ /**
+ * Called when tabs.create is invoked, this method returns a *newly-created* session that
+ * GeckoView will use to load the requested page on. If the returned value is null the page will
+ * not be opened.
+ *
+ * @param source An instance of {@link WebExtension}
+ * @param createDetails Information about this tab.
+ * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which
+ * case the request for a new tab by the extension will fail. The implementation of onNewTab
+ * is responsible for maintaining a reference to the returned object, to prevent it from
+ * being garbage collected.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onNewTab(
+ @NonNull final WebExtension source, @NonNull final CreateTabDetails createDetails) {
+ return null;
+ }
+
+ /**
+ * Called when runtime.openOptionsPage is invoked with options_ui.open_in_tab = false. In this
+ * case, GeckoView delegates options page handling to the app. With options_ui.open_in_tab =
+ * true, {@link #onNewTab} is called instead.
+ *
+ * @param source An instance of {@link WebExtension}.
+ */
+ @UiThread
+ default void onOpenOptionsPage(@NonNull final WebExtension source) {}
+ }
+
+ /**
+ * Get the tab delegate for this extension.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ *
+ * @return The {@link TabDelegate} instance for this extension.
+ */
+ @UiThread
+ @Nullable
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mDelegateController.getTabDelegate();
+ }
+
+ /**
+ * Set the tab delegate for this extension. This delegate will be invoked whenever this extension
+ * tries to modify the tabs state using the `tabs` WebExtension API.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>.
+ *
+ * @param delegate the {@link TabDelegate} instance for this extension.
+ */
+ @UiThread
+ public void setTabDelegate(final @Nullable TabDelegate delegate) {
+ mDelegateController.onTabDelegate(delegate);
+ }
+
+ @UiThread
+ @Nullable
+ public BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mDelegateController.getBrowsingDataDelegate();
+ }
+
+ @UiThread
+ public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) {
+ mDelegateController.onBrowsingDataDelegate(delegate);
+ }
+
+ private static class Sender {
+ public String webExtensionId;
+ public String nativeApp;
+
+ public Sender(final String webExtensionId, final String nativeApp) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof Sender)) {
+ return false;
+ }
+
+ final Sender o = (Sender) other;
+ return webExtensionId.equals(o.webExtensionId) && nativeApp.equals(o.nativeApp);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp});
+ }
+ }
+
+ // Public wrapper for Listener
+ public static class SessionController {
+ private final Listener<SessionTabDelegate> mListener;
+
+ /* package */ void setRuntime(final GeckoRuntime runtime) {
+ mListener.runtime = runtime;
+ }
+
+ /* package */ SessionController(final GeckoSession session) {
+ mListener = new Listener<>(session);
+ }
+
+ /**
+ * Defines a message delegate for a Native App.
+ *
+ * <p>If a delegate is already present, this delegate will replace the existing one.
+ *
+ * <p>This message delegate will be responsible for handling messaging between a WebExtension
+ * content script running on the {@link GeckoSession}.
+ *
+ * <p>Note: To receive messages from content scripts, the WebExtension needs to explicitely
+ * allow it in {@link WebExtension#WebExtension} by setting {@link
+ * Flags#ALLOW_CONTENT_MESSAGING}.
+ *
+ * @param webExtension {@link WebExtension} that this delegate receives messages from.
+ * @param delegate {@link MessageDelegate} that will receive messages from this session.
+ * @param nativeApp which native app id this message delegate will handle messaging for.
+ * @see WebExtension#setMessageDelegate
+ */
+ @AnyThread
+ public void setMessageDelegate(
+ final @NonNull WebExtension webExtension,
+ final @Nullable WebExtension.MessageDelegate delegate,
+ final @NonNull String nativeApp) {
+ mListener.setMessageDelegate(webExtension, delegate, nativeApp);
+ }
+
+ /**
+ * Get the message delegate for <code>nativeApp</code>.
+ *
+ * @param extension {@link WebExtension} that this delegate receives messages from.
+ * @param nativeApp identifier for the native app
+ * @return The {@link MessageDelegate} attached to the <code>nativeApp</code>. <code>null</code>
+ * if no delegate is present.
+ */
+ @AnyThread
+ public @Nullable WebExtension.MessageDelegate getMessageDelegate(
+ final @NonNull WebExtension extension, final @NonNull String nativeApp) {
+ return mListener.getMessageDelegate(extension, nativeApp);
+ }
+
+ /**
+ * Set the Action delegate for this session.
+ *
+ * <p>This delegate will receive page and browser action overrides specific to this session. The
+ * default Action will be received by the delegate set by {@link
+ * WebExtension#setActionDelegate}.
+ *
+ * @param extension the {@link WebExtension} object this delegate will receive updates for
+ * @param delegate the {@link ActionDelegate} that will receive updates.
+ * @see WebExtension.Action
+ */
+ @AnyThread
+ public void setActionDelegate(
+ final @NonNull WebExtension extension, final @Nullable ActionDelegate delegate) {
+ mListener.setActionDelegate(extension, delegate);
+ }
+
+ /**
+ * Get the Action delegate for this session.
+ *
+ * @param extension {@link WebExtension} that this delegates receive updates for.
+ * @return {@link ActionDelegate} for this session
+ */
+ @AnyThread
+ @Nullable
+ public ActionDelegate getActionDelegate(final @NonNull WebExtension extension) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ /**
+ * Set the TabDelegate for this session.
+ *
+ * <p>This delegate will receive messages specific for this session coming from the WebExtension
+ * <code>tabs</code> API.
+ *
+ * @param extension the {@link WebExtension} this delegate will receive updates for
+ * @param delegate the {@link TabDelegate} that will receive updates.
+ * @see WebExtension#setTabDelegate
+ */
+ @AnyThread
+ public void setTabDelegate(
+ final @NonNull WebExtension extension, final @Nullable SessionTabDelegate delegate) {
+ mListener.setTabDelegate(extension, delegate);
+ }
+
+ /**
+ * Get the TabDelegate for the given extension.
+ *
+ * @param extension the {@link WebExtension} this delegate refers to.
+ * @return the current {@link SessionTabDelegate} instance
+ */
+ @AnyThread
+ @Nullable
+ public SessionTabDelegate getTabDelegate(final @NonNull WebExtension extension) {
+ return mListener.getTabDelegate(extension);
+ }
+ }
+
+ /* package */ static final class Listener<TabDelegate> implements BundleEventListener {
+ private final HashMap<Sender, MessageDelegate> mMessageDelegates;
+ private final HashMap<String, ActionDelegate> mActionDelegates;
+ private final HashMap<String, BrowsingDataDelegate> mBrowsingDataDelegates;
+ private final HashMap<String, TabDelegate> mTabDelegates;
+ private final HashMap<String, DownloadDelegate> mDownloadDelegates;
+
+ private final GeckoSession mSession;
+ private final EventDispatcher mEventDispatcher;
+
+ private boolean mActionDelegateRegistered = false;
+ private boolean mBrowsingDataDelegateRegistered = false;
+ private boolean mTabDelegateRegistered = false;
+
+ public GeckoRuntime runtime;
+
+ public Listener(final GeckoRuntime runtime) {
+ this(null, runtime);
+ }
+
+ public Listener(final GeckoSession session) {
+ this(session, null);
+
+ // Close tab event is forwarded to the main listener so we need to listen
+ // to it here.
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:NewTab",
+ "GeckoView:WebExtension:UpdateTab",
+ "GeckoView:WebExtension:CloseTab",
+ "GeckoView:WebExtension:OpenOptionsPage");
+ mTabDelegateRegistered = true;
+ }
+
+ private Listener(final GeckoSession session, final GeckoRuntime runtime) {
+ mMessageDelegates = new HashMap<>();
+ mActionDelegates = new HashMap<>();
+ mBrowsingDataDelegates = new HashMap<>();
+ mTabDelegates = new HashMap<>();
+ mDownloadDelegates = new HashMap<>();
+ mEventDispatcher =
+ session != null ? session.getEventDispatcher() : EventDispatcher.getInstance();
+ mSession = session;
+ this.runtime = runtime;
+
+ // We queue these messages if the delegate has not been attached yet,
+ // so we need to start listening immediately.
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:Message",
+ "GeckoView:WebExtension:PortMessage",
+ "GeckoView:WebExtension:Connect",
+ "GeckoView:WebExtension:Disconnect",
+ "GeckoView:BrowsingData:GetSettings",
+ "GeckoView:BrowsingData:Clear",
+ "GeckoView:WebExtension:Download");
+ }
+
+ public void unregisterWebExtension(final WebExtension extension) {
+ mMessageDelegates.remove(extension.id);
+ mActionDelegates.remove(extension.id);
+ mBrowsingDataDelegates.remove(extension.id);
+ mTabDelegates.remove(extension.id);
+ mDownloadDelegates.remove(extension.id);
+ }
+
+ public void setTabDelegate(final WebExtension webExtension, final TabDelegate delegate) {
+ if (!mTabDelegateRegistered && delegate != null) {
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:WebExtension:NewTab",
+ "GeckoView:WebExtension:UpdateTab",
+ "GeckoView:WebExtension:CloseTab",
+ "GeckoView:WebExtension:OpenOptionsPage");
+ mTabDelegateRegistered = true;
+ }
+
+ mTabDelegates.put(webExtension.id, delegate);
+ }
+
+ public TabDelegate getTabDelegate(final WebExtension webExtension) {
+ return mTabDelegates.get(webExtension.id);
+ }
+
+ public void setBrowsingDataDelegate(
+ final WebExtension webExtension, final BrowsingDataDelegate delegate) {
+ mBrowsingDataDelegates.put(webExtension.id, delegate);
+ }
+
+ public BrowsingDataDelegate getBrowsingDataDelegate(final WebExtension webExtension) {
+ return mBrowsingDataDelegates.get(webExtension.id);
+ }
+
+ public void setActionDelegate(
+ final WebExtension webExtension, final WebExtension.ActionDelegate delegate) {
+ if (!mActionDelegateRegistered && delegate != null) {
+ mEventDispatcher.registerUiThreadListener(
+ this,
+ "GeckoView:BrowserAction:Update",
+ "GeckoView:BrowserAction:OpenPopup",
+ "GeckoView:PageAction:Update",
+ "GeckoView:PageAction:OpenPopup");
+ mActionDelegateRegistered = true;
+ }
+
+ mActionDelegates.put(webExtension.id, delegate);
+ }
+
+ public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) {
+ return mActionDelegates.get(webExtension.id);
+ }
+
+ public void setMessageDelegate(
+ final WebExtension webExtension,
+ final WebExtension.MessageDelegate delegate,
+ final String nativeApp) {
+ mMessageDelegates.put(new Sender(webExtension.id, nativeApp), delegate);
+
+ if (runtime != null && delegate != null) {
+ runtime
+ .getWebExtensionController()
+ .releasePendingMessages(webExtension, nativeApp, mSession);
+ }
+ }
+
+ public WebExtension.MessageDelegate getMessageDelegate(
+ final WebExtension webExtension, final String nativeApp) {
+ return mMessageDelegates.get(new Sender(webExtension.id, nativeApp));
+ }
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (runtime == null) {
+ return;
+ }
+
+ runtime.getWebExtensionController().handleMessage(event, message, callback, mSession);
+ }
+
+ public void setDownloadDelegate(
+ final @NonNull WebExtension extension, final @Nullable DownloadDelegate delegate) {
+ mDownloadDelegates.put(extension.id, delegate);
+ }
+
+ public WebExtension.DownloadDelegate getDownloadDelegate(final WebExtension extension) {
+ return mDownloadDelegates.get(extension.id);
+ }
+ }
+
+ /**
+ * Describes the sender of a message from a WebExtension.
+ *
+ * <p>See also: <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender">
+ * WebExtensions/API/runtime/MessageSender</a>
+ */
+ @UiThread
+ public static class MessageSender {
+ /** {@link WebExtension} that sent this message. */
+ public final @NonNull WebExtension webExtension;
+
+ /**
+ * {@link GeckoSession} that sent this message. <code>null</code> if coming from a background
+ * script.
+ */
+ public final @Nullable GeckoSession session;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({ENV_TYPE_UNKNOWN, ENV_TYPE_EXTENSION, ENV_TYPE_CONTENT_SCRIPT})
+ public @interface EnvType {}
+
+ /* package */ static final int ENV_TYPE_UNKNOWN = 0;
+
+ /** This sender originated inside a privileged extension context like a background script. */
+ public static final int ENV_TYPE_EXTENSION = 1;
+
+ /** This sender originated inside a content script. */
+ public static final int ENV_TYPE_CONTENT_SCRIPT = 2;
+
+ /**
+ * Type of environment that sent this message, either
+ *
+ * <ul>
+ * <li>{@link MessageSender#ENV_TYPE_EXTENSION} if the message was sent from a background page
+ * <li>{@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent from a content
+ * script
+ * </ul>
+ */
+ // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ?
+ public final @EnvType int environmentType;
+
+ /**
+ * URL of the frame that sent this message.
+ *
+ * <p>Use this value together with {@link MessageSender#isTopLevel} to verify that the message
+ * is coming from the expected page. Only top level frames can be trusted.
+ */
+ public final @NonNull String url;
+
+ /* package */ final boolean isTopLevel;
+
+ /* package */ MessageSender(
+ final @NonNull WebExtension webExtension,
+ final @Nullable GeckoSession session,
+ final @Nullable String url,
+ final @EnvType int environmentType,
+ final boolean isTopLevel) {
+ this.webExtension = webExtension;
+ this.session = session;
+ this.isTopLevel = isTopLevel;
+ this.url = url;
+ this.environmentType = environmentType;
+ }
+
+ /** Override for testing. */
+ protected MessageSender() {
+ this.webExtension = null;
+ this.session = null;
+ this.isTopLevel = false;
+ this.url = null;
+ this.environmentType = ENV_TYPE_UNKNOWN;
+ }
+
+ /**
+ * Whether this MessageSender belongs to a top level frame.
+ *
+ * @return true if the MessageSender was sent from the top level frame, false otherwise.
+ */
+ public boolean isTopLevel() {
+ return this.isTopLevel;
+ }
+ }
+
+ /* package */ static WebExtension fromBundle(
+ final DelegateControllerProvider provider, final GeckoBundle bundle) {
+ if (bundle == null) {
+ return null;
+ }
+ return new WebExtension(provider, bundle.getBundle("extension"));
+ }
+
+ /**
+ * Represents either a Browser Action or a Page Action from the WebExtension API.
+ *
+ * <p>Instances of this class may represent the default <code>Action</code> which applies to all
+ * WebExtension tabs or a tab-specific override. To reconstruct the full <code>Action</code>
+ * object, you can use {@link Action#withDefault}.
+ *
+ * <p>Tab specific overrides can be obtained by registering a delegate using {@link
+ * SessionController#setActionDelegate}, while default values can be obtained by registering a
+ * delegate using {@link #setActionDelegate}. <br>
+ * See also
+ *
+ * <ul>
+ * <li><a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+ * WebExtensions/API/browserAction </a>
+ * <li><a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+ * WebExtensions/API/pageAction </a>
+ * </ul>
+ */
+ @AnyThread
+ public static class Action {
+ /**
+ * Title of this Action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getTitle">
+ * pageAction/getTitle</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getTitle">
+ * browserAction/getTitle</a>
+ */
+ public final @Nullable String title;
+
+ /**
+ * Icon for this Action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/setIcon">
+ * pageAction/setIcon</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon">
+ * browserAction/setIcon</a>
+ */
+ public final @Nullable Image icon;
+
+ /**
+ * Whether this action is enabled and should be visible.
+ *
+ * <p>Note: for page action, this is <code>true</code> when the extension calls <code>
+ * pageAction.show</code> and <code>false</code> when the extension calls <code>pageAction.hide
+ * </code>.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show">
+ * pageAction/show</a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/enabled">
+ * browserAction/enabled</a>
+ */
+ public final @Nullable Boolean enabled;
+
+ /**
+ * Badge text for this action.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText">
+ * browserAction/getBadgeText</a>
+ */
+ public final @Nullable String badgeText;
+
+ /**
+ * Background color for the badge for this Action.
+ *
+ * <p>This method will return an Android color int that can be used in {@link
+ * android.widget.TextView#setBackgroundColor(int)} and similar methods.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor">
+ * browserAction/getBadgeBackgroundColor</a>
+ */
+ public final @Nullable Integer badgeBackgroundColor;
+
+ /**
+ * Text color for the badge for this Action.
+ *
+ * <p>This method will return an Android color int that can be used in {@link
+ * android.widget.TextView#setTextColor(int)} and similar methods.
+ *
+ * <p>See also: <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor">
+ * browserAction/getBadgeTextColor</a>
+ */
+ public final @Nullable Integer badgeTextColor;
+
+ private final WebExtension mExtension;
+
+ /* package */ static final int TYPE_BROWSER_ACTION = 1;
+ /* package */ static final int TYPE_PAGE_ACTION = 2;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION})
+ public @interface ActionType {}
+
+ /* package */ final @ActionType int type;
+
+ /* package */ Action(
+ final @ActionType int type, final GeckoBundle bundle, final WebExtension extension) {
+ mExtension = extension;
+
+ this.type = type;
+
+ title = bundle.getString("title");
+ badgeText = bundle.getString("badgeText");
+ badgeBackgroundColor = colorFromRgbaArray(bundle.getDoubleArray("badgeBackgroundColor"));
+ badgeTextColor = colorFromRgbaArray(bundle.getDoubleArray("badgeTextColor"));
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+
+ if (bundle.getBoolean("patternMatching", false)) {
+ // This action was enabled by pattern matching
+ enabled = true;
+ } else if (bundle.containsKey("enabled")) {
+ enabled = bundle.getBoolean("enabled");
+ } else {
+ enabled = null;
+ }
+ }
+
+ private Integer colorFromRgbaArray(final double[] c) {
+ if (c == null) {
+ return null;
+ }
+
+ return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]);
+ }
+
+ @Override
+ public String toString() {
+ return "Action {\n"
+ + "\ttitle: "
+ + this.title
+ + ",\n"
+ + "\ticon: "
+ + this.icon
+ + ",\n"
+ + "\tenabled: "
+ + this.enabled
+ + ",\n"
+ + "\tbadgeText: "
+ + this.badgeText
+ + ",\n"
+ + "\tbadgeTextColor: "
+ + this.badgeTextColor
+ + ",\n"
+ + "\tbadgeBackgroundColor: "
+ + this.badgeBackgroundColor
+ + ",\n"
+ + "}";
+ }
+
+ // For testing
+ protected Action() {
+ type = TYPE_BROWSER_ACTION;
+ mExtension = null;
+ title = null;
+ icon = null;
+ enabled = null;
+ badgeText = null;
+ badgeTextColor = null;
+ badgeBackgroundColor = null;
+ }
+
+ /**
+ * Merges values from this Action with the default Action.
+ *
+ * @param defaultValue the default Action as received from {@link
+ * ActionDelegate#onBrowserAction} or {@link ActionDelegate#onPageAction}.
+ * @return an {@link Action} where all <code>null</code> values from this instance are replaced
+ * with values from <code>defaultValue</code>.
+ * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action
+ * is a Page Action and default value is a Browser Action.
+ */
+ @NonNull
+ public Action withDefault(final @NonNull Action defaultValue) {
+ return new Action(this, defaultValue);
+ }
+
+ /**
+ * @see Action#withDefault
+ */
+ private Action(final Action source, final Action defaultValue) {
+ if (source.type != defaultValue.type) {
+ throw new IllegalArgumentException("defaultValue must be of the same type.");
+ }
+
+ type = source.type;
+ mExtension = source.mExtension;
+
+ title = source.title != null ? source.title : defaultValue.title;
+ icon = source.icon != null ? source.icon : defaultValue.icon;
+ enabled = source.enabled != null ? source.enabled : defaultValue.enabled;
+ badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText;
+ badgeTextColor =
+ source.badgeTextColor != null ? source.badgeTextColor : defaultValue.badgeTextColor;
+ badgeBackgroundColor =
+ source.badgeBackgroundColor != null
+ ? source.badgeBackgroundColor
+ : defaultValue.badgeBackgroundColor;
+ }
+
+ /** Notifies the extension that the user has clicked on this Action. */
+ @UiThread
+ public void click() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", mExtension.id);
+
+ // The click event will return the popup uri if we should open a popup in
+ // response to clicking on the action button.
+ final GeckoResult<String> popupUri;
+ if (type == TYPE_BROWSER_ACTION) {
+ popupUri =
+ EventDispatcher.getInstance().queryString("GeckoView:BrowserAction:Click", bundle);
+ } else if (type == TYPE_PAGE_ACTION) {
+ popupUri = EventDispatcher.getInstance().queryString("GeckoView:PageAction:Click", bundle);
+ } else {
+ throw new IllegalStateException("Unknown Action type");
+ }
+
+ popupUri.accept(
+ uri -> {
+ if (uri == null || uri.isEmpty()) {
+ return;
+ }
+
+ final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate();
+ if (delegate == null) {
+ return;
+ }
+
+ // The .accept method will be called from the UIThread in this case because
+ // the GeckoResult instance was created on the UIThread
+ @SuppressLint("WrongThread")
+ final GeckoResult<GeckoSession> popup = delegate.onTogglePopup(mExtension, this);
+ openPopup(popup, uri);
+ });
+ }
+
+ /* package */ void openPopup(final GeckoResult<GeckoSession> popup, final String popupUri) {
+ if (popup == null) {
+ return;
+ }
+
+ popup.accept(
+ session -> {
+ if (session == null) {
+ return;
+ }
+
+ session.getSettings().setIsPopup(true);
+ session.loadUri(popupUri);
+ });
+ }
+ }
+
+ /**
+ * Receives updates whenever a Browser action or a Page action has been defined by an extension.
+ *
+ * <p>This delegate will receive the default action when registered with {@link
+ * WebExtension#setActionDelegate}. To receive {@link GeckoSession}-specific overrides you can use
+ * {@link SessionController#setActionDelegate}.
+ */
+ public interface ActionDelegate {
+ /**
+ * Called whenever a browser action is defined or updated.
+ *
+ * <p>This method will be called whenever an extension that defines a browser action is
+ * registered or the properties of the Action are updated.
+ *
+ * <p>See also <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction">
+ * WebExtensions/API/browserAction </a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_action">
+ * WebExtensions/manifest.json/browser_action </a>.
+ *
+ * @param extension The extension that defined this browser action.
+ * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action
+ * override applies. <code>null</code> if <code>action</code> is the new default value.
+ * @param action {@link Action} containing the override values for this {@link GeckoSession} or
+ * the default value if <code>session</code> is <code>null</code>.
+ */
+ @UiThread
+ default void onBrowserAction(
+ final @NonNull WebExtension extension,
+ final @Nullable GeckoSession session,
+ final @NonNull Action action) {}
+
+ /**
+ * Called whenever a page action is defined or updated.
+ *
+ * <p>This method will be called whenever an extension that defines a page action is registered
+ * or the properties of the Action are updated.
+ *
+ * <p>See also <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction">
+ * WebExtensions/API/pageAction </a>, <a target=_blank
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action">
+ * WebExtensions/manifest.json/page_action </a>.
+ *
+ * @param extension The extension that defined this page action.
+ * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action
+ * override applies. <code>null</code> if <code>action</code> is the new default value.
+ * @param action {@link Action} containing the override values for this {@link GeckoSession} or
+ * the default value if <code>session</code> is <code>null</code>.
+ */
+ @UiThread
+ default void onPageAction(
+ final @NonNull WebExtension extension,
+ final @Nullable GeckoSession session,
+ final @NonNull Action action) {}
+
+ /**
+ * Called whenever the action wants to toggle a popup view.
+ *
+ * @param extension The extension that wants to display a popup
+ * @param action The action where the popup is defined
+ * @return A GeckoSession that will be used to display the pop-up, null if no popup will be
+ * displayed.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onTogglePopup(
+ final @NonNull WebExtension extension, final @NonNull Action action) {
+ return null;
+ }
+
+ /**
+ * Called whenever the action wants to open a popup view.
+ *
+ * @param extension The extension that wants to display a popup
+ * @param action The action where the popup is defined
+ * @return A GeckoSession that will be used to display the pop-up, null if no popup will be
+ * displayed.
+ */
+ @UiThread
+ @Nullable
+ default GeckoResult<GeckoSession> onOpenPopup(
+ final @NonNull WebExtension extension, final @NonNull Action action) {
+ return null;
+ }
+ }
+
+ /** Extension thrown when an error occurs during extension installation. */
+ public static class InstallException extends Exception {
+ public static class ErrorCodes {
+ /** The download failed due to network problems. */
+ public static final int ERROR_NETWORK_FAILURE = -1;
+
+ /** The downloaded file did not match the provided hash. */
+ public static final int ERROR_INCORRECT_HASH = -2;
+
+ /** The downloaded file seems to be corrupted in some way. */
+ public static final int ERROR_CORRUPT_FILE = -3;
+
+ /** An error occurred trying to write to the filesystem. */
+ public static final int ERROR_FILE_ACCESS = -4;
+
+ /** The extension must be signed and isn't. */
+ public static final int ERROR_SIGNEDSTATE_REQUIRED = -5;
+
+ /** The downloaded extension had a different type than expected. */
+ public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6;
+
+ /** The downloaded extension had a different version than expected */
+ public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9;
+
+ /** The extension did not have the expected ID. */
+ public static final int ERROR_INCORRECT_ID = -7;
+
+ /** The extension did not have the expected ID. */
+ public static final int ERROR_INVALID_DOMAIN = -8;
+
+ /** The extension install was canceled. */
+ public static final int ERROR_USER_CANCELED = -100;
+
+ /** The extension install was postponed until restart. */
+ public static final int ERROR_POSTPONED = -101;
+
+ /** For testing. */
+ protected ErrorCodes() {}
+ }
+
+ /** These states should match gecko's AddonManager.STATE_* constants. */
+ private static class StateCodes {
+ public static final int STATE_POSTPONED = 7;
+ public static final int STATE_CANCELED = 12;
+ }
+
+ /* package */ static Throwable fromQueryException(final Throwable exception) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) exception;
+ final Object response = queryException.data;
+ if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) {
+ final GeckoBundle bundle = (GeckoBundle) response;
+ int errorCode = bundle.getInt("installError");
+ final int installState = bundle.getInt("state");
+ if (errorCode == 0 && installState == StateCodes.STATE_CANCELED) {
+ errorCode = ErrorCodes.ERROR_USER_CANCELED;
+ } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) {
+ errorCode = ErrorCodes.ERROR_POSTPONED;
+ }
+ return new WebExtension.InstallException(errorCode);
+ } else {
+ return new Exception(response.toString());
+ }
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ value = {
+ ErrorCodes.ERROR_NETWORK_FAILURE,
+ ErrorCodes.ERROR_INCORRECT_HASH,
+ ErrorCodes.ERROR_CORRUPT_FILE,
+ ErrorCodes.ERROR_FILE_ACCESS,
+ ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE,
+ ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION,
+ ErrorCodes.ERROR_INCORRECT_ID,
+ ErrorCodes.ERROR_INVALID_DOMAIN,
+ ErrorCodes.ERROR_USER_CANCELED,
+ ErrorCodes.ERROR_POSTPONED,
+ })
+ public @interface Codes {}
+
+ /** One of {@link ErrorCodes} that provides more information about this exception. */
+ public final @Codes int code;
+
+ /** For testing */
+ protected InstallException() {
+ this.code = ErrorCodes.ERROR_NETWORK_FAILURE;
+ }
+
+ @Override
+ public String toString() {
+ return "InstallException: " + code;
+ }
+
+ /* package */ InstallException(final @Codes int code) {
+ this.code = code;
+ }
+ }
+
+ /**
+ * Set the Action delegate for this WebExtension.
+ *
+ * <p>This delegate will receive updates every time the default Action value changes.
+ *
+ * <p>To listen for {@link GeckoSession}-specific updates, use {@link
+ * SessionController#setActionDelegate}
+ *
+ * @param delegate {@link ActionDelegate} that will receive updates.
+ */
+ @AnyThread
+ public void setActionDelegate(final @Nullable ActionDelegate delegate) {
+ mDelegateController.onActionDelegate(delegate);
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ if (delegate != null) {
+ EventDispatcher.getInstance().dispatch("GeckoView:ActionDelegate:Attached", bundle);
+ }
+ }
+
+ /**
+ * Describes the signed status for a {@link WebExtension}.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on signing
+ * in Firefox. </a>
+ */
+ public static class SignedStateFlags {
+ // Keep in sync with AddonManager.jsm
+ /**
+ * This extension may be signed but by a certificate that doesn't chain to our our trusted
+ * certificate.
+ */
+ public static final int UNKNOWN = -1;
+
+ /** This extension is unsigned. */
+ public static final int MISSING = 0;
+
+ /** This extension has been preliminarily reviewed. */
+ public static final int PRELIMINARY = 1;
+
+ /** This extension has been fully reviewed. */
+ public static final int SIGNED = 2;
+
+ /** This extension is a system add-on. */
+ public static final int SYSTEM = 3;
+
+ /** This extension is signed with a "Mozilla Extensions" certificate. */
+ public static final int PRIVILEGED = 4;
+
+ /* package */ static final int LAST = PRIVILEGED;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ SignedStateFlags.UNKNOWN,
+ SignedStateFlags.MISSING,
+ SignedStateFlags.PRELIMINARY,
+ SignedStateFlags.SIGNED,
+ SignedStateFlags.SYSTEM,
+ SignedStateFlags.PRIVILEGED
+ })
+ public @interface SignedState {}
+
+ /**
+ * Describes the blocklist state for a {@link WebExtension}. See <a
+ * href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">Add-ons that
+ * cause stability or security issues are put on a blocklist </a>.
+ */
+ public static class BlocklistStateFlags {
+ // Keep in sync with nsIBlocklistService.idl
+ /** This extension does not appear in the blocklist. */
+ public static final int NOT_BLOCKED = 0;
+
+ /**
+ * This extension is in the blocklist but the problem is not severe enough to warant forcibly
+ * blocking.
+ */
+ public static final int SOFTBLOCKED = 1;
+
+ /** This extension should be blocked and never used. */
+ public static final int BLOCKED = 2;
+
+ /** This extension is considered outdated, and there is a known update available. */
+ public static final int OUTDATED = 3;
+
+ /** This extension is vulnerable and there is an update. */
+ public static final int VULNERABLE_UPDATE_AVAILABLE = 4;
+
+ /** This extension is vulnerable and there is no update. */
+ public static final int VULNERABLE_NO_UPDATE = 5;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ BlocklistStateFlags.NOT_BLOCKED,
+ BlocklistStateFlags.SOFTBLOCKED,
+ BlocklistStateFlags.BLOCKED,
+ BlocklistStateFlags.OUTDATED,
+ BlocklistStateFlags.VULNERABLE_UPDATE_AVAILABLE,
+ BlocklistStateFlags.VULNERABLE_NO_UPDATE
+ })
+ public @interface BlocklistState {}
+
+ public static class DisabledFlags {
+ /** The extension has been disabled by the user */
+ public static final int USER = 1 << 1;
+
+ /**
+ * The extension has been disabled by the blocklist. The details of why this extension was
+ * blocked can be found in {@link MetaData#blocklistState}.
+ */
+ public static final int BLOCKLIST = 1 << 2;
+
+ /**
+ * The extension has been disabled by the application. To enable the extension you can use
+ * {@link WebExtensionController#enable} passing in {@link
+ * WebExtensionController.EnableSource#APP} as <code>source</code>.
+ */
+ public static final int APP = 1 << 3;
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP})
+ public @interface EnabledFlags {}
+
+ /** Provides information about a {@link WebExtension}. */
+ public class MetaData {
+ /**
+ * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying
+ * prompts.
+ */
+ public final @NonNull Image icon;
+
+ /**
+ * API permissions requested or granted to this extension.
+ *
+ * <p>Permission identifiers match entries in the manifest, see <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions">
+ * API permissions </a>.
+ */
+ public final @NonNull String[] permissions;
+
+ /**
+ * Host permissions requested or granted to this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions">
+ * Host permissions </a>.
+ */
+ public final @NonNull String[] origins;
+
+ /**
+ * Branding name for this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name">
+ * manifest.json/name </a>
+ */
+ public final @Nullable String name;
+
+ /**
+ * Branding description for this extension. This string will be localized using the current
+ * GeckoView language setting.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description">
+ * manifest.json/description </a>
+ */
+ public final @Nullable String description;
+
+ /**
+ * Version string for this extension.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version">
+ * manifest.json/version </a>
+ */
+ public final @NonNull String version;
+
+ /**
+ * Creator name as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
+ * manifest.json/developer </a>
+ */
+ public final @Nullable String creatorName;
+
+ /**
+ * Creator url as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
+ * manifest.json/developer </a>
+ */
+ public final @Nullable String creatorUrl;
+
+ /**
+ * Homepage url as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url">
+ * manifest.json/homepage_url </a>
+ */
+ public final @Nullable String homepageUrl;
+
+ /**
+ * Options page as provided in the manifest.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui">
+ * manifest.json/options_ui </a>
+ */
+ public final @Nullable String optionsPageUrl;
+
+ /**
+ * Whether the options page should be open in a Tab or not.
+ *
+ * <p>See <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#Syntax">
+ * manifest.json/options_ui#Syntax </a>
+ */
+ public final boolean openOptionsPageInTab;
+
+ /**
+ * Whether or not this is a recommended extension.
+ *
+ * <p>See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">Recommended
+ * Extensions program </a>
+ */
+ public final boolean isRecommended;
+
+ /**
+ * Blocklist status for this extension.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">
+ * Add-ons that cause stability or security issues are put on a blocklist </a>.
+ */
+ public final @BlocklistState int blocklistState;
+
+ /**
+ * Signed status for this extension.
+ *
+ * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on
+ * signing in Firefox. </a>.
+ */
+ public final @SignedState int signedState;
+
+ /**
+ * Disabled binary flags for this extension.
+ *
+ * <p>This will be either equal to <code>0</code> if the extension is enabled or will contain
+ * one or more flags from {@link DisabledFlags}.
+ *
+ * <p>e.g. if the extension has been disabled by the user, the value in {@link
+ * DisabledFlags#USER} will be equal to <code>1</code>:
+ *
+ * <pre><code>
+ * boolean isUserDisabled = metaData.disabledFlags
+ * &amp; DisabledFlags.USER &gt; 0;
+ * </code></pre>
+ */
+ public final @EnabledFlags int disabledFlags;
+
+ /**
+ * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this
+ * extension.
+ */
+ public final @NonNull String baseUrl;
+
+ /**
+ * Whether this extension is allowed to run in private browsing or not. To modify this value use
+ * {@link WebExtensionController#setAllowedInPrivateBrowsing}.
+ */
+ public final boolean allowedInPrivateBrowsing;
+
+ /** Whether this extension is enabled or not. */
+ public final boolean enabled;
+
+ /**
+ * Whether this extension is temporary or not. Temporary extensions are not retained and will be
+ * uninstalled when the browser exits.
+ */
+ public final boolean temporary;
+
+ /** Override for testing. */
+ protected MetaData() {
+ icon = null;
+ permissions = null;
+ origins = null;
+ name = null;
+ description = null;
+ version = null;
+ creatorName = null;
+ creatorUrl = null;
+ homepageUrl = null;
+ optionsPageUrl = null;
+ openOptionsPageInTab = false;
+ isRecommended = false;
+ blocklistState = BlocklistStateFlags.NOT_BLOCKED;
+ signedState = SignedStateFlags.UNKNOWN;
+ disabledFlags = 0;
+ enabled = true;
+ temporary = false;
+ baseUrl = null;
+ allowedInPrivateBrowsing = false;
+ }
+
+ /* package */ MetaData(final GeckoBundle bundle) {
+ // We only expose permissions that the embedder should prompt for
+ permissions = bundle.getStringArray("promptPermissions");
+ origins = bundle.getStringArray("origins");
+ description = bundle.getString("description");
+ version = bundle.getString("version");
+ creatorName = bundle.getString("creatorName");
+ creatorUrl = bundle.getString("creatorURL");
+ homepageUrl = bundle.getString("homepageURL");
+ name = bundle.getString("name");
+ optionsPageUrl = bundle.getString("optionsPageURL");
+ openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab");
+ isRecommended = bundle.getBoolean("isRecommended");
+ blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED);
+ enabled = bundle.getBoolean("enabled", false);
+ temporary = bundle.getBoolean("temporary", false);
+ baseUrl = bundle.getString("baseURL");
+ allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false);
+
+ final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN);
+ if (signedState <= SignedStateFlags.LAST) {
+ this.signedState = signedState;
+ } else {
+ Log.e(LOGTAG, "Unrecognized signed state: " + signedState);
+ this.signedState = SignedStateFlags.UNKNOWN;
+ }
+
+ int disabledFlags = 0;
+ final String[] disabledFlagsString = bundle.getStringArray("disabledFlags");
+
+ for (final String flag : disabledFlagsString) {
+ if (flag.equals("userDisabled")) {
+ disabledFlags |= DisabledFlags.USER;
+ } else if (flag.equals("blocklistDisabled")) {
+ disabledFlags |= DisabledFlags.BLOCKLIST;
+ } else if (flag.equals("appDisabled")) {
+ disabledFlags |= DisabledFlags.APP;
+ } else {
+ Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag);
+ }
+ }
+ this.disabledFlags = disabledFlags;
+
+ if (bundle.containsKey("icons")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icons"));
+ } else {
+ icon = null;
+ }
+ }
+ }
+
+ // TODO: make public bug 1595822
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {
+ Context.NONE,
+ Context.BOOKMARK,
+ Context.BROWSER_ACTION,
+ Context.PAGE_ACTION,
+ Context.TAB,
+ Context.TOOLS_MENU
+ })
+ public @interface ContextFlags {}
+
+ /**
+ * Flags to determine which contexts a menu item should be shown in. See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ContextType>
+ * menus.ContextType</a>.
+ */
+ static class Context {
+ /** Shows the menu item in no contexts. */
+ static final int NONE = 0;
+
+ /**
+ * Shows the menu item when the user context-clicks an item on the bookmarks toolbar, bookmarks
+ * menu, bookmarks sidebar, or Library window.
+ */
+ static final int BOOKMARK = 1 << 1;
+
+ /** Shows the menu item when the user context-clicks the extension's browser action. */
+ static final int BROWSER_ACTION = 1 << 2;
+
+ /** Shows the menu item when the user context-clicks on the extension's page action. */
+ static final int PAGE_ACTION = 1 << 3;
+
+ /** Shows when the user context-clicks on a tab (such as the element on the tab bar.) */
+ static final int TAB = 1 << 4;
+
+ /** Adds the item to the browser's tools menu. */
+ static final int TOOLS_MENU = 1 << 5;
+ }
+
+ // TODO: make public bug 1595822
+
+ /**
+ * Represents an addition to the context menu by an extension.
+ *
+ * <p>In the <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus>menus</a>
+ * API, all elements added by one extension should be collapsed under one header. This class
+ * represents all of one extension's menu items, as well as the icon that should be used with that
+ * header.
+ */
+ static class Menu {
+ /** List of menu items that belong to this extension. */
+ final @NonNull List<MenuItem> items;
+
+ /** Icon for this extension. */
+ final @Nullable Image icon;
+
+ /** Title for the menu header. */
+ final @Nullable String title;
+
+ /** The extension adding this Menu to the context menu. */
+ final @NonNull WebExtension extension;
+
+ /* package */ Menu(final @NonNull WebExtension extension, final GeckoBundle bundle) {
+ this.extension = extension;
+ title = bundle.getString("title", "");
+ final GeckoBundle[] items = bundle.getBundleArray("items");
+ this.items = new ArrayList<>();
+ if (items != null) {
+ for (final GeckoBundle item : items) {
+ this.items.add(new MenuItem(this.extension, item));
+ }
+ }
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+ }
+
+ /** Notifies the extension that a user has opened the context menu. */
+ void show() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", extension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuShow", bundle);
+ }
+
+ /** Notifies the extension that a user has hidden the context menu. */
+ void hide() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", extension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuHide", bundle);
+ }
+ }
+
+ // TODO: make public bug 1595822
+ /**
+ * Represents an item in the menu.
+ *
+ * <p>If there is only one menu item in the list, the embedder should display that item as itself,
+ * not under a header.
+ */
+ static class MenuItem {
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = false,
+ value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR})
+ public @interface Type {}
+
+ /** A set of constants that represents the display type of this menu item. */
+ static class MenuType {
+ /**
+ * This represents a menu item that just displays a label.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.normal</a>
+ */
+ static final int NORMAL = 0;
+
+ /**
+ * This represents a menu item that can be selected and deselected.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.checkbox</a>
+ */
+ static final int CHECKBOX = 1;
+
+ /**
+ * This represents a menu item that is one of a group of choices. All menu items for an
+ * extension that are of type radio are part of one radio group.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.radio</a>
+ */
+ static final int RADIO = 2;
+
+ /**
+ * This represents a line separating elements.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType>
+ * menus.ItemType.separator</a>
+ */
+ static final int SEPARATOR = 3;
+ }
+
+ /**
+ * Direct children for this menu item. These should be displayed as a sub-menu.
+ *
+ * <p>See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create>
+ * createProperties.parentId</a>
+ */
+ final @Nullable List<MenuItem> children;
+
+ /** One of the {@link Type} constants. Determines the type of the action. */
+ final @Type int type;
+
+ /**
+ * The id of this menu item. See <a
+ * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create>
+ * createProperties.id</a>
+ */
+ final @Nullable String id;
+
+ /** Determines if the menu item should be currently displayed. */
+ final boolean visible;
+
+ /** The title to be displayed for this menu item. */
+ final @Nullable String title;
+
+ /** Whether or not the menu item is initially checked. Defaults to false. */
+ final boolean checked;
+
+ /** Contexts that this menu item should be shown in. */
+ final @ContextFlags int contexts;
+
+ /** Icon for this menu item. */
+ final @Nullable Image icon;
+
+ final WebExtension mExtension;
+
+ /**
+ * Creates a new menu item using a bundle and a reference to the extension that this item
+ * belongs to.
+ *
+ * @param extension WebExtension object.
+ * @param bundle GeckoBundle containing the item information.
+ */
+ /* package */ MenuItem(final WebExtension extension, final GeckoBundle bundle) {
+ title = bundle.getString("title");
+ mExtension = extension;
+ checked = bundle.getBoolean("checked", false);
+ visible = bundle.getBoolean("visible", true);
+ id = bundle.getString("id");
+ contexts = bundle.getInt("contexts");
+ type = bundle.getInt("type");
+ children = new ArrayList<>();
+
+ if (bundle.containsKey("icon")) {
+ icon = Image.fromSizeSrcBundle(bundle.getBundle("icon"));
+ } else {
+ icon = null;
+ }
+ }
+
+ /** Notifies the extension that the user has clicked on this menu item. */
+ void click() {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("menuId", this.id);
+ bundle.putString("extensionId", mExtension.id);
+
+ EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuClick", bundle);
+ }
+ }
+
+ public interface DownloadDelegate {
+ /**
+ * Method that is called when Web Extension requests a download (when downloads.download() is
+ * called in Web Extension)
+ *
+ * @param source - Web Extension that requested the download
+ * @param request - contains the {@link WebRequest} and additional parameters for the request
+ * @return {@link DownloadInitData} instance
+ */
+ @AnyThread
+ @Nullable
+ default GeckoResult<WebExtension.DownloadInitData> onDownload(
+ @NonNull final WebExtension source, @NonNull final DownloadRequest request) {
+ return null;
+ }
+ }
+
+ /**
+ * Set the download delegate for this extension. This delegate will be invoked whenever this
+ * extension tries to use the `downloads` WebExtension API.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions/API/downloads</a>.
+ *
+ * @param delegate the {@link DownloadDelegate} instance for this extension.
+ */
+ @UiThread
+ public void setDownloadDelegate(final @Nullable DownloadDelegate delegate) {
+ mDelegateController.onDownloadDelegate(delegate);
+ }
+
+ /**
+ * Get the download delegate for this extension.
+ *
+ * <p>See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions
+ * downloads API</a>.
+ *
+ * @return The {@link DownloadDelegate} instance for this extension.
+ */
+ @UiThread
+ @Nullable
+ public DownloadDelegate getDownloadDelegate() {
+ return mDelegateController.getDownloadDelegate();
+ }
+
+ /**
+ * Represents a download for <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">downloads
+ * API</a> Instantiate using {@link WebExtensionController#createDownload}
+ */
+ public static class Download {
+ /**
+ * Represents a unique identifier for the downloaded item that is persistent across browser
+ * sessions
+ */
+ public final int id;
+
+ /**
+ * For testing.
+ *
+ * @param id - integer id for the download item
+ */
+ protected Download(final int id) {
+ this.id = id;
+ }
+
+ /* package */ void setDelegate(final Delegate delegate) {}
+
+ /**
+ * Updates the download state. This will trigger a call to <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged">downloads.onChanged</a>
+ * event to the corresponding `DownloadItem` on the extension side.
+ *
+ * @param data - current metadata associated with the download. {@link Download.Info}
+ * implementation instance
+ * @return GeckoResult with nothing or error inside
+ */
+ @Nullable
+ @UiThread
+ public GeckoResult<Void> update(final @NonNull Download.Info data) {
+ final GeckoBundle bundle = new GeckoBundle(12);
+
+ bundle.putInt("downloadItemId", this.id);
+
+ bundle.putString("filename", data.filename());
+ bundle.putString("mime", data.mime());
+ bundle.putString("startTime", String.valueOf(data.startTime()));
+ bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime()));
+ bundle.putInt("state", data.state());
+ bundle.putBoolean("canResume", data.canResume());
+ bundle.putBoolean("paused", data.paused());
+ final Integer error = data.error();
+ if (error != null) {
+ bundle.putInt("error", error);
+ }
+ bundle.putLong("totalBytes", data.totalBytes());
+ bundle.putLong("fileSize", data.fileSize());
+ bundle.putBoolean("exists", data.fileExists());
+
+ return EventDispatcher.getInstance()
+ .queryVoid("GeckoView:WebExtension:DownloadChanged", bundle)
+ .map(
+ null,
+ e -> {
+ if (e instanceof EventDispatcher.QueryException) {
+ final EventDispatcher.QueryException queryException =
+ (EventDispatcher.QueryException) e;
+ if (queryException.data instanceof String) {
+ return new IllegalArgumentException((String) queryException.data);
+ }
+ }
+ return e;
+ });
+ }
+
+ /* package */ interface Delegate {
+
+ default GeckoResult<Void> onPause(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onResume(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onCancel(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onErase(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onOpen(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+
+ default GeckoResult<Void> onRemoveFile(
+ final WebExtension source, final WebExtension.Download download) {
+ return null;
+ }
+ }
+
+ /**
+ * Represents a download in progress where the app is currently receiving data from the server.
+ * See also {@link Info#state()}.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({STATE_IN_PROGRESS, STATE_INTERRUPTED, STATE_COMPLETE})
+ public @interface DownloadState {}
+
+ /** Download is in progress. Default state */
+ public static final int STATE_IN_PROGRESS = 0;
+
+ /** An error broke the connection with the server. */
+ public static final int STATE_INTERRUPTED = 1;
+
+ /** The download completed successfully. */
+ public static final int STATE_COMPLETE = 2;
+
+ /**
+ * Represents a possible reason why a download was interrupted. See also {@link Info#error()}.
+ */
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ INTERRUPT_REASON_NO_INTERRUPT,
+ INTERRUPT_REASON_FILE_FAILED,
+ INTERRUPT_REASON_FILE_ACCESS_DENIED,
+ INTERRUPT_REASON_FILE_NO_SPACE,
+ INTERRUPT_REASON_FILE_NAME_TOO_LONG,
+ INTERRUPT_REASON_FILE_TOO_LARGE,
+ INTERRUPT_REASON_FILE_VIRUS_INFECTED,
+ INTERRUPT_REASON_FILE_TRANSIENT_ERROR,
+ INTERRUPT_REASON_FILE_BLOCKED,
+ INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED,
+ INTERRUPT_REASON_FILE_TOO_SHORT,
+ INTERRUPT_REASON_NETWORK_FAILED,
+ INTERRUPT_REASON_NETWORK_TIMEOUT,
+ INTERRUPT_REASON_NETWORK_DISCONNECTED,
+ INTERRUPT_REASON_NETWORK_SERVER_DOWN,
+ INTERRUPT_REASON_NETWORK_INVALID_REQUEST,
+ INTERRUPT_REASON_SERVER_FAILED,
+ INTERRUPT_REASON_SERVER_NO_RANGE,
+ INTERRUPT_REASON_SERVER_BAD_CONTENT,
+ INTERRUPT_REASON_SERVER_UNAUTHORIZED,
+ INTERRUPT_REASON_SERVER_CERT_PROBLEM,
+ INTERRUPT_REASON_SERVER_FORBIDDEN,
+ INTERRUPT_REASON_USER_CANCELED,
+ INTERRUPT_REASON_USER_SHUTDOWN,
+ INTERRUPT_REASON_CRASH
+ })
+ public @interface DownloadInterruptReason {}
+
+ // File-related errors
+ public static final int INTERRUPT_REASON_NO_INTERRUPT = 0;
+ public static final int INTERRUPT_REASON_FILE_FAILED = 1;
+ public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2;
+ public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3;
+ public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4;
+ public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5;
+ public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6;
+ public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7;
+ public static final int INTERRUPT_REASON_FILE_BLOCKED = 8;
+ public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9;
+ public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10;
+ // Network-related errors
+ public static final int INTERRUPT_REASON_NETWORK_FAILED = 11;
+ public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12;
+ public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13;
+ public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14;
+ public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15;
+ // Server-related errors
+ public static final int INTERRUPT_REASON_SERVER_FAILED = 16;
+ public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17;
+ public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18;
+ public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19;
+ public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20;
+ public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21;
+ // User-related errors
+ public static final int INTERRUPT_REASON_USER_CANCELED = 22;
+ public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23;
+ // Miscellaneous
+ public static final int INTERRUPT_REASON_CRASH = 24;
+
+ /**
+ * Interface for communicating the state of downloads to Web Extensions. See also <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/DownloadItem">WebExtensions/API/downloads/DownloadItem</a>
+ */
+ public interface Info {
+
+ /**
+ * @return A number representing the number of bytes received so far from the host during the
+ * download This does not take file compression into consideration
+ */
+ @UiThread
+ default long bytesReceived() {
+ return 0;
+ }
+
+ /**
+ * @return boolean indicating whether a currently-interrupted (e.g. paused) download can be
+ * resumed from the point where it was interrupted
+ */
+ @UiThread
+ default boolean canResume() {
+ return false;
+ }
+
+ /**
+ * @return A number representing the time when this download ended. This is null if the
+ * download has not yet finished.
+ */
+ @Nullable
+ @UiThread
+ default Long endTime() {
+ return null;
+ }
+
+ /**
+ * @return One of <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/InterruptReason">Interrupt
+ * Reason</a> constants denoting the error reason.
+ */
+ @Nullable
+ @UiThread
+ default @DownloadInterruptReason Integer error() {
+ return null;
+ }
+
+ /**
+ * @return the estimated number of milliseconds between the UNIX epoch and when this download
+ * is estimated to be completed. This is null if it is not known.
+ */
+ @Nullable
+ @UiThread
+ default Long estimatedEndTime() {
+ return null;
+ }
+
+ /**
+ * @return boolean indicating whether a downloaded file still exists
+ */
+ @UiThread
+ default boolean fileExists() {
+ return false;
+ }
+
+ /**
+ * @return the filename.
+ */
+ @NonNull
+ @UiThread
+ default String filename() {
+ return "";
+ }
+
+ /**
+ * @return the total number of bytes in the whole file, after decompression. A value of -1
+ * means that the total file size is unknown.
+ */
+ @UiThread
+ default long fileSize() {
+ return -1;
+ }
+
+ /**
+ * @return the downloaded file's MIME type
+ */
+ @NonNull
+ @UiThread
+ default String mime() {
+ return "";
+ }
+
+ /**
+ * @return boolean indicating whether the download is paused i.e. if the download has stopped
+ * reading data from the host but has kept the connection open
+ */
+ @UiThread
+ default boolean paused() {
+ return false;
+ }
+
+ /**
+ * @return String representing the downloaded file's referrer
+ */
+ @NonNull
+ @UiThread
+ default String referrer() {
+ return "";
+ }
+
+ /**
+ * @return the number of milliseconds between the UNIX epoch and when this download began
+ */
+ @UiThread
+ default long startTime() {
+ return -1;
+ }
+
+ /**
+ * @return a new state; one of the state constants to indicate whether the download is in
+ * progress, interrupted or complete
+ */
+ @UiThread
+ default @DownloadState int state() {
+ return STATE_IN_PROGRESS;
+ }
+
+ /**
+ * @return total number of bytes in the file being downloaded. This does not take file
+ * compression into consideration. A value of -1 here means that the total number of bytes
+ * is unknown
+ */
+ @UiThread
+ default long totalBytes() {
+ return -1;
+ }
+ }
+
+ @NonNull
+ /* package */ static GeckoBundle downloadInfoToBundle(final @NonNull Info data) {
+ final GeckoBundle dataBundle = new GeckoBundle();
+
+ dataBundle.putLong("bytesReceived", data.bytesReceived());
+ dataBundle.putBoolean("canResume", data.canResume());
+ dataBundle.putBoolean("exists", data.fileExists());
+ dataBundle.putString("filename", data.filename());
+ dataBundle.putLong("fileSize", data.fileSize());
+ dataBundle.putString("mime", data.mime());
+ dataBundle.putBoolean("paused", data.paused());
+ dataBundle.putString("referrer", data.referrer());
+ dataBundle.putString("startTime", String.valueOf(data.startTime()));
+ dataBundle.putInt("state", data.state());
+ dataBundle.putLong("totalBytes", data.totalBytes());
+
+ final Long endTime = data.endTime();
+ if (endTime != null) {
+ dataBundle.putString("endTime", endTime.toString());
+ }
+ final Integer error = data.error();
+ if (error != null) {
+ dataBundle.putInt("error", error);
+ }
+ final Long estimatedEndTime = data.estimatedEndTime();
+ if (estimatedEndTime != null) {
+ dataBundle.putString("estimatedEndTime", estimatedEndTime.toString());
+ }
+
+ return dataBundle;
+ }
+ }
+
+ /** Represents Web Extension API specific download request */
+ public static class DownloadRequest {
+ /** Regular GeckoView {@link WebRequest} object */
+ public final @NonNull WebRequest request;
+
+ /** Optional fetch flags for {@link GeckoWebExecutor} */
+ public final @GeckoWebExecutor.FetchFlags int downloadFlags;
+
+ /** A file path relative to the default downloads directory */
+ public final @Nullable String filename;
+
+ /**
+ * The action you want taken if there is a filename conflict, as defined <a
+ * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/FilenameConflictAction">here</a>
+ */
+ public final @ConflictActionFlags int conflictActionFlag;
+
+ /**
+ * Specifies whether to provide a file chooser dialog to allow the user to select a filename
+ * (true), or not (false)
+ */
+ public final boolean saveAs;
+
+ /**
+ * Flag that enables downloads to continue even if they encounter HTTP errors. When false, the
+ * download is canceled when it encounters an HTTP error. When true, the download continues when
+ * an HTTP error is encountered and the HTTP server error is not reported. However, if the
+ * download fails due to file-related, network-related, user-related, or other error, that error
+ * is reported.
+ */
+ public final boolean allowHttpErrors;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT})
+ public @interface ConflictActionFlags {}
+
+ /** The app should modify the filename to make it unique */
+ public static final int CONFLICT_ACTION_UNIQUIFY = 0;
+
+ /** The app should overwrite the old file with the newly-downloaded file */
+ public static final int CONFLICT_ACTION_OVERWRITE = 1;
+
+ /** The app should prompt the user, asking them to choose whether to uniquify or overwrite */
+ public static final int CONFLICT_ACTION_PROMPT = 1 << 1;
+
+ protected DownloadRequest(final DownloadRequest.Builder builder) {
+ this.request = builder.mRequest;
+ this.downloadFlags = builder.mDownloadFlags;
+ this.filename = builder.mFilename;
+ this.conflictActionFlag = builder.mConflictActionFlag;
+ this.saveAs = builder.mSaveAs;
+ this.allowHttpErrors = builder.mAllowHttpErrors;
+ }
+
+ /**
+ * Convenience method to convert a GeckoBundle to a DownloadRequest.
+ *
+ * @param optionsBundle - in the shape of the options object browser.downloads.download()
+ * accepts
+ * @return request - a DownloadRequest instance
+ */
+ /* package */ static DownloadRequest fromBundle(final GeckoBundle optionsBundle) {
+ final String uri = optionsBundle.getString("url");
+
+ final WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri);
+
+ final String method = optionsBundle.getString("method");
+ if (method != null) {
+ mainRequestBuilder.method(method);
+
+ if (method.equals("POST")) {
+ final String body = optionsBundle.getString("body");
+ mainRequestBuilder.body(body);
+ }
+ }
+
+ final GeckoBundle[] headers = optionsBundle.getBundleArray("headers");
+ if (headers != null) {
+ for (final GeckoBundle header : headers) {
+ String value = header.getString("value");
+ if (value == null) {
+ value = header.getString("binaryValue");
+ }
+ mainRequestBuilder.addHeader(header.getString("name"), value);
+ }
+ }
+
+ final WebRequest mainRequest = mainRequestBuilder.build();
+
+ int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE;
+ final boolean incognito = optionsBundle.getBoolean("incognito");
+ if (incognito) {
+ downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE;
+ }
+
+ final boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors");
+
+ int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY;
+ final String conflictActionString = optionsBundle.getString("conflictAction");
+ if (conflictActionString != null) {
+ switch (conflictActionString.toLowerCase(Locale.ROOT)) {
+ case "overwrite":
+ conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_OVERWRITE;
+ break;
+ case "prompt":
+ conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_PROMPT;
+ break;
+ }
+ }
+
+ final boolean saveAs = optionsBundle.getBoolean("saveAs");
+
+ final WebExtension.DownloadRequest request =
+ new WebExtension.DownloadRequest.Builder(mainRequest)
+ .filename(optionsBundle.getString("filename"))
+ .downloadFlags(downloadFlags)
+ .conflictAction(conflictActionFlags)
+ .saveAs(saveAs)
+ .allowHttpErrors(allowHttpErrors)
+ .build();
+
+ return request;
+ }
+
+ /* package */ static class Builder {
+ private final WebRequest mRequest;
+ private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0;
+ private String mFilename = null;
+ private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY;
+ private boolean mSaveAs = false;
+ private boolean mAllowHttpErrors = false;
+
+ /* package */ Builder(final WebRequest request) {
+ this.mRequest = request;
+ }
+
+ /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) {
+ this.mDownloadFlags = flags;
+ return this;
+ }
+
+ /* package */ Builder filename(final String filename) {
+ this.mFilename = filename;
+ return this;
+ }
+
+ /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) {
+ this.mConflictActionFlag = conflictActionFlag;
+ return this;
+ }
+
+ /* package */ Builder saveAs(final boolean saveAs) {
+ this.mSaveAs = saveAs;
+ return this;
+ }
+
+ /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) {
+ this.mAllowHttpErrors = allowHttpErrors;
+ return this;
+ }
+
+ /* package */ DownloadRequest build() {
+ return new DownloadRequest(this);
+ }
+ }
+ }
+
+ /** Represents initial information on a download provided to Web Extension */
+ public static class DownloadInitData {
+ @NonNull public final WebExtension.Download download;
+ @NonNull public final Download.Info initData;
+
+ public DownloadInitData(final Download download, final Download.Info initData) {
+ this.download = download;
+ this.initData = initData;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java
new file mode 100644
index 0000000000..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<WebExtension.TabDelegate> mListener;
+
+ // Map [ (extensionId, nativeApp, session) -> message ]
+ private final MultiMap<MessageRecipient, Message> mPendingMessages;
+ private final MultiMap<String, Message> mPendingNewTab;
+ private final MultiMap<String, Message> mPendingBrowsingData;
+ private final MultiMap<String, Message> mPendingDownload;
+
+ private final SparseArray<WebExtension.Download> mDownloads;
+
+ private static class Message {
+ final GeckoBundle bundle;
+ final EventCallback callback;
+ final String event;
+ final GeckoSession session;
+
+ public Message(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ this.bundle = bundle;
+ this.callback = callback;
+ this.event = event;
+ this.session = session;
+ }
+ }
+
+ private static class ExtensionStore {
+ private final Map<String, WebExtension> mData = new HashMap<>();
+ private Observer mObserver;
+
+ interface Observer {
+ /**
+ * * This event is fired every time a new extension object is created by the store.
+ *
+ * @param extension the newly-created extension object
+ */
+ WebExtension onNewExtension(final GeckoBundle extension);
+ }
+
+ public GeckoResult<WebExtension> get(final String id) {
+ final WebExtension extension = mData.get(id);
+ if (extension != null) {
+ return GeckoResult.fromValue(extension);
+ }
+
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("extensionId", id);
+
+ final GeckoResult<WebExtension> pending =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Get", bundle)
+ .map(
+ extensionBundle -> {
+ final WebExtension ext = mObserver.onNewExtension(extensionBundle);
+ mData.put(ext.id, ext);
+ return ext;
+ });
+
+ return pending;
+ }
+
+ public void setObserver(final Observer observer) {
+ mObserver = observer;
+ }
+
+ public void remove(final String id) {
+ mData.remove(id);
+ }
+
+ /**
+ * Add this extension to the store and update it's current value if it's already present.
+ *
+ * @param id the {@link WebExtension} id.
+ * @param extension the {@link WebExtension} to add to the store.
+ */
+ public void update(final String id, final WebExtension extension) {
+ mData.put(id, extension);
+ }
+ }
+
+ private ExtensionStore mExtensions = new ExtensionStore();
+
+ private Internals mInternals = new Internals();
+
+ // Avoids exposing listeners to the API
+ private class Internals implements BundleEventListener, ExtensionStore.Observer {
+ @Override
+ // BundleEventListener
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ WebExtensionController.this.handleMessage(event, message, callback, null);
+ }
+
+ @Override
+ public WebExtension onNewExtension(final GeckoBundle bundle) {
+ return WebExtension.fromBundle(mDelegateControllerProvider, bundle);
+ }
+ }
+
+ /* package */ void releasePendingMessages(
+ final WebExtension extension, final String nativeApp, final GeckoSession session) {
+ Log.i(
+ LOGTAG,
+ "releasePendingMessages:"
+ + " extension="
+ + extension.id
+ + " nativeApp="
+ + nativeApp
+ + " session="
+ + session);
+ final List<Message> messages =
+ mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session));
+ if (messages == null) {
+ return;
+ }
+
+ for (final Message message : messages) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+ }
+
+ private class DelegateController implements WebExtension.DelegateController {
+ private final WebExtension mExtension;
+
+ public DelegateController(final WebExtension extension) {
+ mExtension = extension;
+ }
+
+ @Override
+ public void onMessageDelegate(
+ final String nativeApp, final WebExtension.MessageDelegate delegate) {
+ mListener.setMessageDelegate(mExtension, delegate, nativeApp);
+ }
+
+ @Override
+ public void onActionDelegate(final WebExtension.ActionDelegate delegate) {
+ mListener.setActionDelegate(mExtension, delegate);
+ }
+
+ @Override
+ public WebExtension.ActionDelegate getActionDelegate() {
+ return mListener.getActionDelegate(mExtension);
+ }
+
+ @Override
+ public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) {
+ mListener.setBrowsingDataDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingBrowsingData.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingBrowsingData.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() {
+ return mListener.getBrowsingDataDelegate(mExtension);
+ }
+
+ @Override
+ public void onTabDelegate(final WebExtension.TabDelegate delegate) {
+ mListener.setTabDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingNewTab.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingNewTab.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.TabDelegate getTabDelegate() {
+ return mListener.getTabDelegate(mExtension);
+ }
+
+ @Override
+ public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) {
+ mListener.setDownloadDelegate(mExtension, delegate);
+
+ for (final Message message : mPendingDownload.get(mExtension.id)) {
+ WebExtensionController.this.handleMessage(
+ message.event, message.bundle, message.callback, message.session);
+ }
+
+ mPendingDownload.remove(mExtension.id);
+ }
+
+ @Override
+ public WebExtension.DownloadDelegate getDownloadDelegate() {
+ return mListener.getDownloadDelegate(mExtension);
+ }
+ }
+
+ final WebExtension.DelegateControllerProvider mDelegateControllerProvider =
+ new WebExtension.DelegateControllerProvider() {
+ @Override
+ public WebExtension.DelegateController controllerFor(final WebExtension extension) {
+ return new DelegateController(extension);
+ }
+ };
+
+ /**
+ * This delegate will be called whenever an extension is about to be installed or it needs new
+ * permissions, e.g during an update or because it called <code>permissions.request</code>
+ */
+ @UiThread
+ public interface PromptDelegate {
+ /**
+ * Called whenever a new extension is being installed. This is intended as an opportunity for
+ * the app to prompt the user for the permissions required by this extension.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be installed or {@link AllowOrDeny#DENY DENY} if this extension
+ * should not be installed. A null value will be interpreted as {@link AllowOrDeny#DENY
+ * DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) {
+ return null;
+ }
+
+ /**
+ * Called whenever an updated extension has new permissions. This is intended as an opportunity
+ * for the app to prompt the user for the new permissions required by this extension.
+ *
+ * @param currentlyInstalled The {@link WebExtension} that is currently installed.
+ * @param updatedExtension The {@link WebExtension} that will replace the previous extension.
+ * @param newPermissions The new permissions that are needed.
+ * @param newOrigins The new origins that are needed.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if
+ * this extension should be update or {@link AllowOrDeny#DENY DENY} if this extension should
+ * not be update. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onUpdatePrompt(
+ @NonNull final WebExtension currentlyInstalled,
+ @NonNull final WebExtension updatedExtension,
+ @NonNull final String[] newPermissions,
+ @NonNull final String[] newOrigins) {
+ return null;
+ }
+
+ /**
+ * Called whenever permissions are requested. This is intended as an opportunity for the app to
+ * prompt the user for the permissions required by this extension at runtime.
+ *
+ * @param extension The {@link WebExtension} that is about to be installed. You can use {@link
+ * WebExtension#metaData} to gather information about this extension when building the user
+ * prompt dialog.
+ * @param permissions The permissions that are requested.
+ * @param origins The requested host permissions.
+ * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the
+ * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be
+ * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}.
+ */
+ @Nullable
+ default GeckoResult<AllowOrDeny> onOptionalPrompt(
+ final @NonNull WebExtension extension,
+ final @NonNull String[] permissions,
+ final @NonNull String[] origins) {
+ return null;
+ }
+ }
+
+ public interface DebuggerDelegate {
+ /**
+ * Called whenever the list of installed extensions has been modified using the debugger with
+ * tools like web-ext.
+ *
+ * <p>This is intended as an opportunity to refresh the list of installed extensions using
+ * {@link WebExtensionController#list} and to set delegates on the new {@link WebExtension}
+ * objects, e.g. using {@link WebExtension#setActionDelegate} and {@link
+ * WebExtension#setMessageDelegate}.
+ *
+ * @see <a
+ * href="https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext">
+ * Getting started with web-ext</a>
+ */
+ @UiThread
+ default void onExtensionListUpdated() {}
+ }
+
+ /** 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<Boolean> cancel() {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("installId", installId);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:CancelInstall", bundle)
+ .map(response -> response.getBoolean("cancelled"));
+ }
+ }
+
+ /**
+ * Install an extension.
+ *
+ * <p>An installed extension will persist and will be available even when restarting the {@link
+ * GeckoRuntime}.
+ *
+ * <p>Installed extensions through this method need to be signed by Mozilla, see <a
+ * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
+ * Distributing your add-on </a>.
+ *
+ * <p>When calling this method, the GeckoView library will download the extension, validate its
+ * manifest and signature, and give you an opportunity to verify its permissions through {@link
+ * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate.
+ *
+ * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https:
+ * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app
+ * needs the appropriate permissions for local URIs.
+ * @return A {@link GeckoResult} that will complete when the installation process finishes. For
+ * successful installations, the GeckoResult will return the {@link WebExtension} object that
+ * you can use to set delegates and retrieve information about the WebExtension using {@link
+ * WebExtension#metaData}.
+ * <p>If an error occurs during the installation process, the GeckoResult will complete
+ * exceptionally with a {@link WebExtension.InstallException InstallException} that will
+ * contain the relevant error code in {@link WebExtension.InstallException#code
+ * InstallException#code}.
+ * @see PromptDelegate#installPrompt
+ * @see WebExtension.InstallException.ErrorCodes
+ * @see WebExtension#metaData
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> install(final @NonNull String uri) {
+ final InstallCanceller canceller = new InstallCanceller();
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("installId", canceller.installId);
+
+ final GeckoResult<WebExtension> result =
+ EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Install", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ result.setCancellationDelegate(canceller);
+ return result;
+ }
+
+ /**
+ * Set whether an extension should be allowed to run in private browsing or not.
+ *
+ * @param extension the {@link WebExtension} instance to modify.
+ * @param allowed true if this extension should be allowed to run in private browsing pages, false
+ * otherwise.
+ * @return the updated {@link WebExtension} instance.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> setAllowedInPrivateBrowsing(
+ final @NonNull WebExtension extension, final boolean allowed) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("extensionId", extension.id);
+ bundle.putBoolean("allowed", allowed);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Install a built-in extension.
+ *
+ * <p>Built-in extensions have access to native messaging, don't need to be signed and are
+ * installed from a folder in the APK instead of a .xpi bundle.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.installBuiltIn("resource://android/assets/example/");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> installBuiltIn(final @NonNull String uri) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("locationUri", uri);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Ensure that a built-in extension is installed.
+ *
+ * <p>Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already
+ * present and it has the same version.
+ *
+ * <p>Example:
+ *
+ * <p><code>
+ * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com");
+ * </code> Will install the built-in extension located at <code>/assets/example/</code> in the
+ * app's APK.
+ *
+ * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only
+ * <code>resource://android</code> URIs are allowed.
+ * @param id Extension ID as present in the manifest.json file.
+ * @see WebExtension.MessageDelegate
+ * @return A {@link GeckoResult} that completes with the extension once it's installed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<WebExtension> ensureBuiltIn(
+ final @NonNull String uri, final @Nullable String id) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("locationUri", uri);
+ bundle.putString("webExtensionId", id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Uninstall an extension.
+ *
+ * <p>Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance,
+ * delete all its data and trigger a request to close all extension pages currently open.
+ *
+ * @param extension The {@link WebExtension} to be uninstalled.
+ * @return A {@link GeckoResult} that will complete when the uninstall process is completed.
+ */
+ @NonNull
+ @AnyThread
+ public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Uninstall", bundle)
+ .accept(result -> unregisterWebExtension(extension));
+ }
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({EnableSource.USER, EnableSource.APP})
+ public @interface EnableSources {}
+
+ /**
+ * Contains the possible values for the <code>source</code> parameter in {@link #enable} and
+ * {@link #disable}.
+ */
+ public static class EnableSource {
+ /** Action has been requested by the user. */
+ public static final int USER = 1;
+
+ /**
+ * Action requested by the app itself, e.g. to disable an extension that is not supported in
+ * this version of the app.
+ */
+ public static final int APP = 2;
+
+ static String toString(final @EnableSources int flag) {
+ if (flag == USER) {
+ return "user";
+ } else if (flag == APP) {
+ return "app";
+ } else {
+ throw new IllegalArgumentException("Value provided in flags is not valid.");
+ }
+ }
+ }
+
+ /**
+ * Enable an extension that has been disabled. If the extension is already enabled, this method
+ * has no effect.
+ *
+ * @param extension The {@link WebExtension} to be enabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user,use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the enablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> enable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Enable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ /**
+ * Disable an extension that is enabled. If the extension is already disabled, this method has no
+ * effect.
+ *
+ * @param extension The {@link WebExtension} to be disabled.
+ * @param source The agent that initiated this action, e.g. if the action has been initiated by
+ * the user, use {@link EnableSource#USER}.
+ * @return the new {@link WebExtension} instance, updated to reflect the disablement.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> disable(
+ final @NonNull WebExtension extension, final @EnableSources int source) {
+ final GeckoBundle bundle = new GeckoBundle(2);
+ bundle.putString("webExtensionId", extension.id);
+ bundle.putString("source", EnableSource.toString(source));
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Disable", bundle)
+ .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext))
+ .map(this::registerWebExtension);
+ }
+
+ private List<WebExtension> listFromBundle(final GeckoBundle response) {
+ final GeckoBundle[] bundles = response.getBundleArray("extensions");
+ final List<WebExtension> list = new ArrayList<>(bundles.length);
+
+ for (final GeckoBundle bundle : bundles) {
+ final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle);
+ list.add(registerWebExtension(extension));
+ }
+
+ return list;
+ }
+
+ /**
+ * List installed extensions for this {@link GeckoRuntime}.
+ *
+ * <p>The returned list can be used to set delegates on the {@link WebExtension} objects using
+ * {@link WebExtension#setActionDelegate}, {@link WebExtension#setMessageDelegate}.
+ *
+ * @return a {@link GeckoResult} that will resolve when the list of extensions is available.
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<List<WebExtension>> list() {
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:List")
+ .map(this::listFromBundle);
+ }
+
+ /**
+ * Update a web extension.
+ *
+ * <p>When checking for an update, GeckoView will download the update manifest that is defined by
+ * the web extension's manifest property <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/">browser_specific_settings.gecko.update_url</a>.
+ * If an update is found it will be downloaded and installed. If the extension needs any new
+ * permissions the {@link PromptDelegate#updatePrompt} will be triggered.
+ *
+ * <p>More information about the update manifest format is available <a
+ * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/#manifest-structure">here</a>.
+ *
+ * @param extension The extension to update.
+ * @return A {@link GeckoResult} that will complete when the update process finishes. If an update
+ * is found and installed successfully, the GeckoResult will return the updated {@link
+ * WebExtension}. If no update is available, null will be returned. If the updated extension
+ * requires new permissions, the {@link PromptDelegate#installPrompt} will be called.
+ * @see PromptDelegate#updatePrompt
+ */
+ @AnyThread
+ @NonNull
+ public GeckoResult<WebExtension> update(final @NonNull WebExtension extension) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putString("webExtensionId", extension.id);
+
+ return EventDispatcher.getInstance()
+ .queryBundle("GeckoView:WebExtension:Update", bundle)
+ .map(
+ ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext),
+ WebExtension.InstallException::fromQueryException)
+ .map(this::registerWebExtension);
+ }
+
+ /* package */ WebExtensionController(final GeckoRuntime runtime) {
+ mListener = new WebExtension.Listener<>(runtime);
+ mPendingMessages = new MultiMap<>();
+ mPendingNewTab = new MultiMap<>();
+ mPendingBrowsingData = new MultiMap<>();
+ mPendingDownload = new MultiMap<>();
+ mExtensions.setObserver(mInternals);
+ mDownloads = new SparseArray<>();
+ }
+
+ /* package */ WebExtension registerWebExtension(final WebExtension webExtension) {
+ if (webExtension != null) {
+ mExtensions.update(webExtension.id, webExtension);
+ }
+ return webExtension;
+ }
+
+ /* package */ void handleMessage(
+ final String event,
+ final GeckoBundle bundle,
+ final EventCallback callback,
+ final GeckoSession session) {
+ final Message message = new Message(event, bundle, callback, session);
+
+ Log.d(LOGTAG, "handleMessage " + event);
+
+ if ("GeckoView:WebExtension:InstallPrompt".equals(event)) {
+ installPrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) {
+ updatePrompt(bundle, callback);
+ return;
+ } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) {
+ if (mDebuggerDelegate != null) {
+ mDebuggerDelegate.onExtensionListUpdated();
+ }
+ return;
+ } 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<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void updatePrompt(final GeckoBundle message, final EventCallback callback) {
+ final GeckoBundle currentBundle = message.getBundle("currentlyInstalled");
+ final GeckoBundle updatedBundle = message.getBundle("updatedExtension");
+ final String[] newPermissions = message.getStringArray("newPermissions");
+ final String[] newOrigins = message.getStringArray("newOrigins");
+ if (currentBundle == null || updatedBundle == null) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing bundle");
+ }
+
+ Log.e(LOGTAG, "Missing bundle");
+ return;
+ }
+
+ final WebExtension currentExtension =
+ new WebExtension(mDelegateControllerProvider, currentBundle);
+
+ final WebExtension updatedExtension =
+ new WebExtension(mDelegateControllerProvider, updatedBundle);
+
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to update extension " + currentExtension.id + " but no delegate is registered");
+ return;
+ }
+
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onUpdatePrompt(
+ currentExtension, updatedExtension, newPermissions, newOrigins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ private void optionalPrompt(final Message message, final WebExtension extension) {
+ if (mPromptDelegate == null) {
+ Log.e(
+ LOGTAG,
+ "Tried to request optional permissions for extension "
+ + extension.id
+ + " but no delegate is registered");
+ return;
+ }
+
+ final String[] permissions =
+ message.bundle.getBundle("permissions").getStringArray("permissions");
+ final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins");
+ final GeckoResult<AllowOrDeny> promptResponse =
+ mPromptDelegate.onOptionalPrompt(extension, permissions, origins);
+ if (promptResponse == null) {
+ return;
+ }
+
+ message.callback.resolveTo(
+ promptResponse.map(
+ allowOrDeny -> {
+ final GeckoBundle response = new GeckoBundle(1);
+ response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny));
+ return response;
+ }));
+ }
+
+ 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<WebExtension.BrowsingDataDelegate.Settings> settingsResult =
+ delegate.onGetSettings();
+ if (settingsResult == null) {
+ message.callback.sendError("browsingData.settings is not supported");
+ return;
+ }
+ message.callback.resolveTo(settingsResult.map(settings -> settings.toGeckoBundle()));
+ }
+
+ private void browsingDataClear(final Message message, final WebExtension extension) {
+ final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension);
+ if (delegate == null) {
+ mPendingBrowsingData.add(extension.id, message);
+ return;
+ }
+
+ final long unixTimestamp = message.bundle.getLong("since");
+ final String dataType = message.bundle.getString("dataType");
+
+ final GeckoResult<Void> response;
+ if ("downloads".equals(dataType)) {
+ response = delegate.onClearDownloads(unixTimestamp);
+ } else if ("formData".equals(dataType)) {
+ response = delegate.onClearFormData(unixTimestamp);
+ } else if ("history".equals(dataType)) {
+ response = delegate.onClearHistory(unixTimestamp);
+ } else if ("passwords".equals(dataType)) {
+ response = delegate.onClearPasswords(unixTimestamp);
+ } else {
+ throw new IllegalStateException("Illegal clear data type: " + dataType);
+ }
+
+ message.callback.resolveTo(response);
+ }
+
+ /* package */ void download(final Message message, final WebExtension extension) {
+ final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension);
+ if (delegate == null) {
+ mPendingDownload.add(extension.id, message);
+ return;
+ }
+
+ final GeckoBundle optionsBundle = message.bundle.getBundle("options");
+
+ final WebExtension.DownloadRequest request =
+ WebExtension.DownloadRequest.fromBundle(optionsBundle);
+
+ final GeckoResult<WebExtension.DownloadInitData> result =
+ delegate.onDownload(extension, request);
+ if (result == null) {
+ message.callback.sendError("downloads.download is not supported");
+ return;
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == null) {
+ Log.e(LOGTAG, "onDownload returned invalid null value");
+ throw new IllegalArgumentException("downloads.download is not supported");
+ }
+
+ final GeckoBundle returnMessage =
+ WebExtension.Download.downloadInfoToBundle(value.initData);
+ returnMessage.putInt("id", value.download.id);
+
+ return returnMessage;
+ }));
+ }
+
+ /* package */ void openOptionsPage(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+
+ if (delegate != null) {
+ delegate.onOpenOptionsPage(extension);
+ } else {
+ message.callback.sendError("runtime.openOptionsPage is not supported");
+ }
+
+ message.callback.sendSuccess(null);
+ }
+
+ /* package */
+ @SuppressLint("WrongThread") // for .isOpen
+ void newTab(final Message message, final WebExtension extension) {
+ final GeckoBundle bundle = message.bundle;
+
+ final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension);
+ final WebExtension.CreateTabDetails details =
+ new WebExtension.CreateTabDetails(bundle.getBundle("createProperties"));
+
+ final GeckoResult<GeckoSession> result;
+ if (delegate != null) {
+ result = delegate.onNewTab(extension, details);
+ } else {
+ mPendingNewTab.add(extension.id, message);
+ return;
+ }
+
+ if (result == null) {
+ message.callback.sendSuccess(false);
+ return;
+ }
+
+ final String newSessionId = message.bundle.getString("newSessionId");
+ message.callback.resolveTo(
+ result.map(
+ session -> {
+ if (session == null) {
+ return false;
+ }
+
+ if (session.isOpen()) {
+ throw new IllegalArgumentException("Must use an unopened GeckoSession instance");
+ }
+
+ session.open(mListener.runtime, newSessionId);
+ return true;
+ }));
+ }
+
+ /* package */ void updateTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+ final EventCallback callback = message.callback;
+
+ if (delegate == null) {
+ callback.sendError("tabs.update is not supported");
+ return;
+ }
+
+ final WebExtension.UpdateTabDetails details =
+ new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties"));
+ callback.resolveTo(
+ delegate
+ .onUpdateTab(extension, message.session, details)
+ .map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.update is not supported");
+ }
+ }));
+ }
+
+ /* package */ void closeTab(final Message message, final WebExtension extension) {
+ final WebExtension.SessionTabDelegate delegate =
+ message.session.getWebExtensionController().getTabDelegate(extension);
+
+ final GeckoResult<AllowOrDeny> result;
+ if (delegate != null) {
+ result = delegate.onCloseTab(extension, message.session);
+ } else {
+ result = GeckoResult.fromValue(AllowOrDeny.DENY);
+ }
+
+ message.callback.resolveTo(
+ result.map(
+ value -> {
+ if (value == AllowOrDeny.ALLOW) {
+ return null;
+ } else {
+ throw new Exception("tabs.remove is not supported");
+ }
+ }));
+ }
+
+ /**
+ * Notifies extensions about a active tab change over the `tabs.onActivated` event.
+ *
+ * @param session The {@link GeckoSession} of the newly selected session/tab.
+ * @param active true if the tab became active, false if the tab became inactive.
+ */
+ @AnyThread
+ public void setTabActive(@NonNull final GeckoSession session, final boolean active) {
+ final GeckoBundle bundle = new GeckoBundle(1);
+ bundle.putBoolean("active", active);
+ session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle);
+ }
+
+ /* package */ void unregisterWebExtension(final WebExtension webExtension) {
+ mExtensions.remove(webExtension.id);
+ mListener.unregisterWebExtension(webExtension);
+ }
+
+ private WebExtension.MessageSender fromBundle(
+ final WebExtension extension, final GeckoBundle sender, final GeckoSession session) {
+ if (extension == null) {
+ // All senders should have an extension
+ return null;
+ }
+
+ final String envType = sender.getString("envType");
+ @WebExtension.MessageSender.EnvType final int environmentType;
+
+ if ("content_child".equals(envType)) {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT;
+ } else if ("addon_child".equals(envType)) {
+ // TODO Bug 1554277: check that this message is coming from the right process
+ environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION;
+ } else {
+ environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN;
+ }
+
+ if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException("Missing or unknown envType: " + envType);
+ }
+
+ return null;
+ }
+
+ final String url = sender.getString("url");
+ final boolean isTopLevel;
+ if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ // This message is coming from the background page, a popup, or an extension page
+ isTopLevel = true;
+ } else {
+ // If session is present we are either receiving this message from a content script or
+ // an extension page, let's make sure we have the proper identification so that
+ // embedders can check the origin of this message.
+ // -1 is an invalid frame id
+ final boolean hasFrameId =
+ sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1;
+ final boolean hasUrl = sender.containsKey("url");
+ if (!hasFrameId || !hasUrl) {
+ if (BuildConfig.DEBUG_BUILD) {
+ throw new RuntimeException(
+ "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl);
+ }
+
+ // This message does not have the proper identification and may be compromised,
+ // let's ignore it.
+ return null;
+ }
+
+ isTopLevel = sender.getInt("frameId", -1) == 0;
+ }
+
+ return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel);
+ }
+
+ private WebExtension.MessageDelegate getDelegate(
+ final String nativeApp,
+ final WebExtension.MessageSender sender,
+ final EventCallback callback) {
+ if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0
+ && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) {
+ callback.sendError("This NativeApp can't receive messages from Content Scripts.");
+ return null;
+ }
+
+ WebExtension.MessageDelegate delegate = null;
+
+ if (sender.session != null) {
+ delegate =
+ sender
+ .session
+ .getWebExtensionController()
+ .getMessageDelegate(sender.webExtension, nativeApp);
+ } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
+ delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp);
+ }
+
+ return delegate;
+ }
+
+ private static class MessageRecipient {
+ public final String webExtensionId;
+ public final String nativeApp;
+ public final GeckoSession session;
+
+ public MessageRecipient(
+ final String webExtensionId, final String nativeApp, final GeckoSession session) {
+ this.webExtensionId = webExtensionId;
+ this.nativeApp = nativeApp;
+ this.session = session;
+ }
+
+ private static boolean equals(final Object a, final Object b) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ return Objects.equals(a, b);
+ }
+
+ return (a == b) || (a != null && a.equals(b));
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (!(other instanceof MessageRecipient)) {
+ return false;
+ }
+
+ final MessageRecipient o = (MessageRecipient) other;
+ return equals(webExtensionId, o.webExtensionId)
+ && equals(nativeApp, o.nativeApp)
+ && equals(session, o.session);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session});
+ }
+ }
+
+ private void connect(
+ final String nativeApp,
+ final long portId,
+ final Message message,
+ final WebExtension.MessageSender sender) {
+ if (portId == -1) {
+ message.callback.sendError("Missing portId.");
+ return;
+ }
+
+ final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender);
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ delegate.onConnect(port);
+ message.callback.sendSuccess(true);
+ }
+
+ private void message(
+ final String nativeApp, final Message message, final WebExtension.MessageSender sender) {
+ final EventCallback callback = message.callback;
+
+ final Object content;
+ try {
+ content = message.bundle.toJSONObject().get("data");
+ } catch (final JSONException ex) {
+ callback.sendError(ex.getMessage());
+ return;
+ }
+
+ final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback);
+ if (delegate == null) {
+ mPendingMessages.add(
+ new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message);
+ return;
+ }
+
+ final GeckoResult<Object> response = delegate.onMessage(nativeApp, content, sender);
+ if (response == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(response);
+ }
+
+ private GeckoResult<WebExtension> extensionFromBundle(final GeckoBundle message) {
+ final String extensionId = message.getString("extensionId");
+ return mExtensions.get(extensionId);
+ }
+
+ private void openPopup(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ final String popupUri = message.bundle.getString("popupUri");
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action);
+ action.openPopup(popup, popupUri);
+ }
+
+ private WebExtension.ActionDelegate actionDelegateFor(
+ final WebExtension extension, final GeckoSession session) {
+ if (session == null) {
+ return mListener.getActionDelegate(extension);
+ }
+
+ return session.getWebExtensionController().getActionDelegate(extension);
+ }
+
+ private void actionUpdate(
+ final Message message,
+ final WebExtension extension,
+ final @WebExtension.Action.ActionType int actionType) {
+ if (extension == null) {
+ return;
+ }
+
+ final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session);
+ if (delegate == null) {
+ return;
+ }
+
+ final WebExtension.Action action =
+ new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension);
+ if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) {
+ delegate.onBrowserAction(extension, message.session, action);
+ } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) {
+ delegate.onPageAction(extension, message.session, action);
+ }
+ }
+
+ // TODO: implement bug 1595822
+ /* package */ static GeckoResult<List<WebExtension.Menu>> getMenu(
+ final GeckoBundle menuArrayBundle) {
+ return null;
+ }
+
+ @Nullable
+ @UiThread
+ public WebExtension.Download createDownload(final int id) {
+ if (mDownloads.indexOfKey(id) >= 0) {
+ throw new IllegalArgumentException("Download with this id already exists");
+ } else {
+ final WebExtension.Download download = new WebExtension.Download(id);
+ mDownloads.put(id, download);
+
+ return download;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
new file mode 100644
index 0000000000..520cb9faa0
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java
@@ -0,0 +1,117 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/** This is an abstract base class for HTTP request and response types. */
+@WrapForJNI
+@AnyThread
+public abstract class WebMessage {
+
+ /** The URI for the request or response. */
+ public final @NonNull String uri;
+
+ /** An unmodifiable Map of headers. Defaults to an empty instance. */
+ public final @NonNull Map<String, String> headers;
+
+ protected WebMessage(final @NonNull Builder builder) {
+ uri = builder.mUri;
+ headers = Collections.unmodifiableMap(builder.mHeaders);
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderKeys() {
+ final String[] keys = new String[headers.size()];
+ headers.keySet().toArray(keys);
+ return keys;
+ }
+
+ // This is only used via JNI.
+ private String[] getHeaderValues() {
+ final String[] values = new String[headers.size()];
+ headers.values().toArray(values);
+ return values;
+ }
+
+ /** This is a Builder used by subclasses of {@link WebMessage}. */
+ @AnyThread
+ public abstract static class Builder {
+ /* package */ String mUri;
+ /* package */ Map<String, String> mHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+ /* package */ ByteBuffer mBody;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ /* package */ Builder(final @NonNull String uri) {
+ uri(uri);
+ }
+
+ /**
+ * Set the URI
+ *
+ * @param uri A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder uri(final @NonNull String uri) {
+ mUri = uri;
+ return this;
+ }
+
+ /**
+ * Set a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, it will be replaced by this value.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ mHeaders.put(key, value);
+ return this;
+ }
+
+ /**
+ * Add a HTTP header. This may be called multiple times for additional headers. If an existing
+ * header of the same name exists, the values will be merged.
+ *
+ * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve
+ * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten
+ * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order.
+ *
+ * @param key The key for the HTTP header, e.g. "content-type".
+ * @param value The value for the HTTP header, e.g. "application/json".
+ * @return This Builder instance.
+ */
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ final String existingValue = mHeaders.get(key);
+ if (existingValue != null) {
+ final StringBuilder builder = new StringBuilder(existingValue);
+ builder.append(", ");
+ builder.append(value);
+ mHeaders.put(key, builder.toString());
+ } else {
+ mHeaders.put(key, value);
+ }
+
+ return this;
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
new file mode 100644
index 0000000000..c2de231f80
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java
@@ -0,0 +1,233 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.ParcelFormatException;
+import android.os.Parcelable;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.ThreadUtils;
+
+/**
+ * This class represents a single <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web Notification</a>. These
+ * can be received by connecting a {@link WebNotificationDelegate} to {@link GeckoRuntime} via
+ * {@link GeckoRuntime#setWebNotificationDelegate(WebNotificationDelegate)}.
+ */
+public class WebNotification implements Parcelable {
+
+ /**
+ * Title is shown at the top of the notification window.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/title">Web
+ * Notification - title</a>
+ */
+ public final @Nullable String title;
+
+ /**
+ * Tag is the ID of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/tag">Web
+ * Notification - tag</a>
+ */
+ public final @NonNull String tag;
+
+ private final @Nullable String mCookie;
+
+ /**
+ * Text represents the body of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/body">Web
+ * Notification - text</a>
+ */
+ public final @Nullable String text;
+
+ /**
+ * ImageURL contains the URL of an icon to be displayed as part of the notification.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/icon">Web
+ * Notification - icon</a>
+ */
+ public final @Nullable String imageUrl;
+
+ /**
+ * TextDirection indicates the direction that the language of the text is displayed. Possible
+ * values are: auto: adopts the browser's language setting behaviour (the default.) ltr: left to
+ * right. rtl: right to left.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir">Web
+ * Notification - dir</a>
+ */
+ public final @Nullable String textDirection;
+
+ /**
+ * Lang indicates the notification's language, as specified using a DOMString representing a BCP
+ * 47 language tag.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMString">DOM String</a>
+ * @see <a href="http://www.rfc-editor.org/rfc/bcp/bcp47.txt">BCP 47</a>
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/lang">Web
+ * Notification - lang</a>
+ */
+ public final @Nullable String lang;
+
+ /**
+ * RequireInteraction indicates whether a notification should remain active until the user clicks
+ * or dismisses it, rather than closing automatically.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/requireInteraction">Web
+ * Notification - requireInteraction</a>
+ */
+ public final @NonNull boolean requireInteraction;
+
+ /**
+ * This is the URL of the page or Service Worker that generated the notification. Null if this
+ * notification was not generated by a Web Page (e.g. from an Extension).
+ *
+ * <p>TODO: make NonNull once we have Bug 1589693
+ */
+ public final @Nullable String source;
+
+ /**
+ * When set, indicates that no sounds or vibrations should be made.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent">Web
+ * Notification - silent</a>
+ */
+ public final boolean silent;
+
+ /** indicates whether the notification came from private browsing mode or not. */
+ public final boolean privateBrowsing;
+
+ /**
+ * A vibration pattern to run with the display of the notification. A vibration pattern can be an
+ * array with as few as one member. The values are times in milliseconds where the even indices
+ * (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause.
+ * For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate">Web
+ * Notification - vibrate</a>
+ */
+ public final @NonNull int[] vibrate;
+
+ @WrapForJNI
+ /* package */ WebNotification(
+ @Nullable final String title,
+ @NonNull final String tag,
+ @Nullable final String cookie,
+ @Nullable final String text,
+ @Nullable final String imageUrl,
+ @Nullable final String textDirection,
+ @Nullable final String lang,
+ @NonNull final boolean requireInteraction,
+ @NonNull final String source,
+ final boolean silent,
+ final boolean privateBrowsing,
+ @NonNull final int[] vibrate) {
+ this.tag = tag;
+ this.mCookie = cookie;
+ this.title = title;
+ this.text = text;
+ this.imageUrl = imageUrl;
+ this.textDirection = textDirection;
+ this.lang = lang;
+ this.requireInteraction = requireInteraction;
+ this.source = "".equals(source) ? null : source;
+ this.silent = silent;
+ this.vibrate = vibrate;
+ this.privateBrowsing = privateBrowsing;
+ }
+
+ /**
+ * This should be called when the user taps or clicks a notification. Note that this does not
+ * automatically dismiss the notification as far as Web Content is concerned. For that, see {@link
+ * #dismiss()}.
+ */
+ @UiThread
+ public void click() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClick(tag, mCookie);
+ }
+
+ /**
+ * This should be called when the app stops showing the notification. This is important, as there
+ * may be a limit to the number of active notifications each site can display.
+ */
+ @UiThread
+ public void dismiss() {
+ ThreadUtils.assertOnUiThread();
+ GeckoAppShell.onNotificationClose(tag, mCookie);
+ }
+
+ // Increment this value whenever anything changes in the parcelable representation.
+ private static final int VERSION = 1;
+
+ // To avoid TransactionTooLargeException, we only store small imageUrls
+ private static final int IMAGE_URL_LENGTH_MAX = 150;
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel dest, final int flags) {
+ dest.writeInt(VERSION);
+ dest.writeString(title);
+ dest.writeString(tag);
+ dest.writeString(mCookie);
+ dest.writeString(text);
+ if (imageUrl.length() < IMAGE_URL_LENGTH_MAX) {
+ dest.writeString(imageUrl);
+ } else {
+ dest.writeString("");
+ }
+ dest.writeString(textDirection);
+ dest.writeString(lang);
+ dest.writeInt(requireInteraction ? 1 : 0);
+ dest.writeString(source);
+ dest.writeInt(silent ? 1 : 0);
+ dest.writeInt(privateBrowsing ? 1 : 0);
+ dest.writeIntArray(vibrate);
+ }
+
+ private WebNotification(final Parcel in) {
+ title = in.readString();
+ tag = in.readString();
+ mCookie = in.readString();
+ text = in.readString();
+ imageUrl = in.readString();
+ textDirection = in.readString();
+ lang = in.readString();
+ requireInteraction = in.readInt() == 1;
+ source = in.readString();
+ silent = in.readInt() == 1;
+ privateBrowsing = in.readInt() == 1;
+ vibrate = in.createIntArray();
+ }
+
+ public static final Creator<WebNotification> CREATOR =
+ new Creator<>() {
+ @Override
+ public WebNotification createFromParcel(final Parcel in) {
+ final int version = in.readInt();
+ if (version != VERSION) {
+ throw new ParcelFormatException(
+ "Mismatched version: " + version + " expected: " + VERSION);
+ }
+ return new WebNotification(in);
+ }
+
+ @Override
+ public WebNotification[] newArray(final int size) {
+ return new WebNotification[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
new file mode 100644
index 0000000000..40db55fa3c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java
@@ -0,0 +1,29 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+public interface WebNotificationDelegate {
+ /**
+ * This is called when a new notification is created.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onShowNotification(@NonNull final WebNotification notification) {}
+
+ /**
+ * This is called when an existing notification is closed.
+ *
+ * @param notification The WebNotification received.
+ */
+ @AnyThread
+ @WrapForJNI
+ default void onCloseNotification(@NonNull final WebNotification notification) {}
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
new file mode 100644
index 0000000000..f5ea153bfe
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java
@@ -0,0 +1,165 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+import org.mozilla.gecko.util.ThreadUtils;
+
+public class WebPushController {
+ private static final String LOGTAG = "WebPushController";
+
+ private WebPushDelegate mDelegate;
+ private BundleEventListener mEventListener;
+
+ /* package */ WebPushController() {
+ mEventListener = new EventListener();
+ EventDispatcher.getInstance()
+ .registerUiThreadListener(
+ mEventListener,
+ "GeckoView:PushSubscribe",
+ "GeckoView:PushUnsubscribe",
+ "GeckoView:PushGetSubscription");
+ }
+
+ /**
+ * Sets the {@link WebPushDelegate} for this instance.
+ *
+ * @param delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ public void setDelegate(final @Nullable WebPushDelegate delegate) {
+ ThreadUtils.assertOnUiThread();
+ mDelegate = delegate;
+ }
+
+ /**
+ * Gets the {@link WebPushDelegate} for this instance.
+ *
+ * @return delegate The {@link WebPushDelegate} instance.
+ */
+ @UiThread
+ @Nullable
+ public WebPushDelegate getDelegate() {
+ ThreadUtils.assertOnUiThread();
+ return mDelegate;
+ }
+
+ /**
+ * Send a push event for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+ onPushEvent(scope, null);
+ }
+
+ /**
+ * Send a push event with a payload for a given subscription.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ * @param data The unencrypted payload.
+ */
+ @UiThread
+ public void onPushEvent(final @NonNull String scope, final @Nullable byte[] data) {
+ ThreadUtils.assertOnUiThread();
+
+ GeckoThread.waitForState(GeckoThread.State.JNI_READY)
+ .accept(
+ val -> {
+ final GeckoBundle msg = new GeckoBundle(2);
+ msg.putString("scope", scope);
+ msg.putString("data", Base64Utils.encode(data));
+ EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg);
+ },
+ e -> Log.e(LOGTAG, "Unable to deliver Web Push message", e));
+ }
+
+ /**
+ * Notify that a given subscription has changed. This is normally a signal to the content that it
+ * needs to re-subscribe.
+ *
+ * @param scope The Service Worker scope associated with this subscription.
+ */
+ @UiThread
+ public void onSubscriptionChanged(final @NonNull String scope) {
+ ThreadUtils.assertOnUiThread();
+
+ final GeckoBundle msg = new GeckoBundle(1);
+ msg.putString("scope", scope);
+ EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg);
+ }
+
+ private class EventListener implements BundleEventListener {
+
+ @Override
+ public void handleMessage(
+ final String event, final GeckoBundle message, final EventCallback callback) {
+ if (mDelegate == null) {
+ callback.sendError("Not allowed");
+ return;
+ }
+
+ switch (event) {
+ case "GeckoView:PushSubscribe":
+ {
+ byte[] appServerKey = null;
+ if (message.containsKey("appServerKey")) {
+ appServerKey = Base64Utils.decode(message.getString("appServerKey"));
+ }
+
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onSubscribe(message.getString("scope"), appServerKey);
+
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ result.accept(
+ subscription ->
+ callback.sendSuccess(subscription != null ? subscription.toBundle() : null),
+ error -> callback.sendSuccess(null));
+ break;
+ }
+ case "GeckoView:PushUnsubscribe":
+ {
+ final GeckoResult<Void> result = mDelegate.onUnsubscribe(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(result.map(val -> null));
+ break;
+ }
+ case "GeckoView:PushGetSubscription":
+ {
+ final GeckoResult<WebPushSubscription> result =
+ mDelegate.onGetSubscription(message.getString("scope"));
+ if (result == null) {
+ callback.sendSuccess(null);
+ return;
+ }
+
+ callback.resolveTo(
+ result.map(subscription -> subscription != null ? subscription.toBundle() : null));
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
new file mode 100644
index 0000000000..d9e9c39274
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java
@@ -0,0 +1,62 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+public interface WebPushDelegate {
+ /**
+ * Creates a push subscription for the given service worker scope. A scope uniquely identifies a
+ * service worker. `appServerKey` optionally creates a restricted subscription.
+ *
+ * <p>Applications will likely want to persist the returned {@link WebPushSubscription} in order
+ * to support {@link #onGetSubscription(String)}.
+ *
+ * @param scope The Service Worker scope.
+ * @param appServerKey An optional application server key.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-subscribe">subscribe()</a>
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscriptionoptionsinit-applicationserverkey">Application
+ * server key</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onSubscribe(
+ @NonNull final String scope, @Nullable final byte[] appServerKey) {
+ return null;
+ }
+
+ /**
+ * Retrieves a subscription for the given service worker scope.
+ *
+ * @param scope The scope for the requested {@link WebPushSubscription}.
+ * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription}
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushmanager-getsubscription">getSubscription()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<WebPushSubscription> onGetSubscription(
+ @NonNull final String scope) {
+ return null;
+ }
+
+ /**
+ * Removes a push subscription. If this fails, apps should resolve the returned {@link
+ * GeckoResult} with an exception.
+ *
+ * @param scope The Service Worker scope for the subscription.
+ * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing.
+ * @see <a
+ * href="http://w3c.github.io/push-api/#dom-pushsubscription-unsubscribe">unsubscribe()</a>
+ */
+ @UiThread
+ default @Nullable GeckoResult<Void> onUnsubscribe(@NonNull final String scope) {
+ return null;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
new file mode 100644
index 0000000000..7ce9a3d60c
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java
@@ -0,0 +1,180 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+import androidx.annotation.AnyThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.util.Arrays;
+import org.mozilla.gecko.util.GeckoBundle;
+
+/**
+ * This class represents a single Web Push subscription, as described in the <a
+ * href="https://www.w3.org/TR/push-api/">Web Push API</a> specification.
+ *
+ * <p>This is a low-level interface, allowing applications to do all of the heavy lifting
+ * themselves. It is recommended that consumers have a thorough understanding of the Web Push API,
+ * especially <a href="https://tools.ietf.org/html/rfc8291">RFC 8291</a>.
+ *
+ * <p>Only trivial sanity checks are performed on the values held here. The application must ensure
+ * it is generating compliant keys/secrets itself.
+ */
+public class WebPushSubscription implements Parcelable {
+ private static final int P256_PUBLIC_KEY_LENGTH = 65;
+
+ /**
+ * The Service Worker scope associated with this subscription.
+ *
+ * @see <a
+ * href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">ServiceWorker
+ * registration</a>
+ */
+ @NonNull public final String scope;
+
+ /**
+ * The Web Push endpoint for this subscription. This is the URL of a web service which implements
+ * the Web Push protocol.
+ *
+ * @see <a href="https://tools.ietf.org/html/rfc8030#section-5">RFC 8030</a>
+ */
+ @NonNull public final String endpoint;
+
+ /**
+ * This is an optional public key provided by the application server to authenticate itself with
+ * the endpoint, formatted according to X9.62.
+ *
+ * <p>This key is used for VAPID, the Voluntary Application Server Identification (VAPID) for Web
+ * Push, from <a href="https://tools.ietf.org/html/rfc8292">RFC 8292</a>.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey">applicationServerKey</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a>
+ */
+ @Nullable public final byte[] appServerKey;
+
+ /**
+ * The P-256 EC public key, formatted as X9.62, generated by the embedder, to be provided to the
+ * app server for message encryption.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh">PushEncryptionKeyName
+ * - p256dh</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.1">RFC 8291 section 3.1</a>
+ */
+ @NonNull public final byte[] browserPublicKey;
+
+ /**
+ * 16 byte secret key, generated by the embedder, to be provided to the app server for use in
+ * encrypting and authenticating messages sent to the {@link #endpoint}.
+ *
+ * @see <a
+ * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth">PushEncryptionKeyName
+ * - auth</a>
+ * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.2">RFC 8291, section 3.2</a>
+ */
+ @NonNull public final byte[] authSecret;
+
+ @SuppressWarnings("checkstyle:javadocmethod")
+ public WebPushSubscription(
+ final @NonNull String scope,
+ final @NonNull String endpoint,
+ final @Nullable byte[] appServerKey,
+ final @NonNull byte[] browserPublicKey,
+ final @NonNull byte[] authSecret) {
+ this.scope = scope;
+ this.endpoint = endpoint;
+ this.appServerKey = appServerKey;
+ this.browserPublicKey = browserPublicKey;
+ this.authSecret = authSecret;
+
+ if (appServerKey != null) {
+ if (appServerKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("appServerKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (Arrays.equals(appServerKey, browserPublicKey)) {
+ throw new IllegalArgumentException("appServerKey and browserPublicKey must differ");
+ }
+ }
+
+ if (browserPublicKey.length != P256_PUBLIC_KEY_LENGTH) {
+ throw new IllegalArgumentException(
+ String.format("browserPublicKey should be %d bytes", P256_PUBLIC_KEY_LENGTH));
+ }
+
+ if (authSecret.length != 16) {
+ throw new IllegalArgumentException("authSecret must be 128 bits");
+ }
+ }
+
+ private WebPushSubscription(final Parcel in) {
+ this.scope = in.readString();
+ this.endpoint = in.readString();
+
+ if (ParcelableUtils.readBoolean(in)) {
+ this.appServerKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.appServerKey);
+ } else {
+ appServerKey = null;
+ }
+
+ this.browserPublicKey = new byte[P256_PUBLIC_KEY_LENGTH];
+ in.readByteArray(this.browserPublicKey);
+
+ this.authSecret = new byte[16];
+ in.readByteArray(this.authSecret);
+ }
+
+ /* package */ GeckoBundle toBundle() {
+ final GeckoBundle bundle = new GeckoBundle(5);
+ bundle.putString("scope", scope);
+ bundle.putString("endpoint", endpoint);
+ if (appServerKey != null) {
+ bundle.putString("appServerKey", Base64Utils.encode(appServerKey));
+ }
+ bundle.putString("browserPublicKey", Base64Utils.encode(browserPublicKey));
+ bundle.putString("authSecret", Base64Utils.encode(authSecret));
+ return bundle;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(final Parcel out, final int flags) {
+ out.writeString(scope);
+ out.writeString(endpoint);
+
+ ParcelableUtils.writeBoolean(out, appServerKey != null);
+ if (appServerKey != null) {
+ out.writeByteArray(appServerKey);
+ }
+
+ out.writeByteArray(browserPublicKey);
+ out.writeByteArray(authSecret);
+ }
+
+ public static final Parcelable.Creator<WebPushSubscription> CREATOR =
+ new Parcelable.Creator<WebPushSubscription>() {
+ @Override
+ @AnyThread
+ public WebPushSubscription createFromParcel(final Parcel parcel) {
+ return new WebPushSubscription(parcel);
+ }
+
+ @Override
+ @AnyThread
+ public WebPushSubscription[] newArray(final int size) {
+ return new WebPushSubscription[size];
+ }
+ };
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
new file mode 100644
index 0000000000..30ee5451aa
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java
@@ -0,0 +1,248 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import org.mozilla.gecko.annotation.WrapForJNI;
+
+/**
+ * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this
+ * class via {@link WebRequest.Builder}, and fetch responses via {@link
+ * GeckoWebExecutor#fetch(WebRequest)}.
+ */
+@WrapForJNI
+@AnyThread
+public class WebRequest extends WebMessage {
+ /** The HTTP method for the request. Defaults to "GET". */
+ public final @NonNull String method;
+
+ /** The body of the request. Must be a directly-allocated ByteBuffer. May be null. */
+ public final @Nullable ByteBuffer body;
+
+ /**
+ * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. These modes match those from
+ * the DOM Fetch API.
+ *
+ * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request/cache">DOM Fetch API
+ * cache modes</a>
+ */
+ public final @CacheMode int cacheMode;
+
+ /**
+ * If true, do not use newer protocol features that might have interop problems on the Internet.
+ * Intended only for use with critical infrastructure.
+ */
+ public final boolean beConservative;
+
+ /** The value of the Referer header for this request. */
+ public final @Nullable String referrer;
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ CACHE_MODE_DEFAULT,
+ CACHE_MODE_NO_STORE,
+ CACHE_MODE_RELOAD,
+ CACHE_MODE_NO_CACHE,
+ CACHE_MODE_FORCE_CACHE,
+ CACHE_MODE_ONLY_IF_CACHED
+ })
+ public @interface CacheMode {};
+
+ /** Default cache mode. Normal caching rules apply. */
+ public static final int CACHE_MODE_DEFAULT = 1;
+
+ /**
+ * The response will be fetched from the server without looking in the cache, and will not update
+ * the cache with the downloaded response.
+ */
+ public static final int CACHE_MODE_NO_STORE = 2;
+
+ /**
+ * The response will be fetched from the server without looking in the cache. The cache will be
+ * updated with the downloaded response.
+ */
+ public static final int CACHE_MODE_RELOAD = 3;
+
+ /** Forces a conditional request to the server if there is a cache match. */
+ public static final int CACHE_MODE_NO_CACHE = 4;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match, a normal request will be made and the cache will be updated with the downloaded
+ * response.
+ */
+ public static final int CACHE_MODE_FORCE_CACHE = 5;
+
+ /**
+ * If a response is found in the cache, it will be returned, whether it's fresh or not. If there
+ * is no match from the cache, 504 Gateway Timeout will be returned.
+ */
+ public static final int CACHE_MODE_ONLY_IF_CACHED = 6;
+
+ /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT;
+ /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED;
+
+ /**
+ * Constructs a WebRequest with the specified URI.
+ *
+ * @param uri A URI String, e.g. https://mozilla.org
+ */
+ public WebRequest(final @NonNull String uri) {
+ this(new Builder(uri));
+ }
+
+ /** Constructs a new WebRequest from a {@link WebRequest.Builder}. */
+ /* package */ WebRequest(final @NonNull Builder builder) {
+ super(builder);
+ method = builder.mMethod;
+ cacheMode = builder.mCacheMode;
+ referrer = builder.mReferrer;
+ beConservative = builder.mBeConservative;
+
+ if (builder.mBody != null) {
+ body = builder.mBody.asReadOnlyBuffer();
+ } else {
+ body = null;
+ }
+ }
+
+ /** Builder offers a convenient way for constructing {@link WebRequest} instances. */
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ String mMethod = "GET";
+ /* package */ int mCacheMode = CACHE_MODE_DEFAULT;
+ /* package */ String mReferrer;
+ /* package */ boolean mBeConservative;
+
+ /**
+ * Construct a Builder instance with the specified URI.
+ *
+ * @param uri A URI String.
+ */
+ public Builder(final @NonNull String uri) {
+ super(uri);
+ }
+
+ @Override
+ public @NonNull Builder uri(final @NonNull String uri) {
+ super.uri(uri);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder header(final @NonNull String key, final @NonNull String value) {
+ super.header(key, value);
+ return this;
+ }
+
+ @Override
+ public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) {
+ super.addHeader(key, value);
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param buffer A {@link ByteBuffer} with the data. Must be allocated directly via {@link
+ * ByteBuffer#allocateDirect(int)}.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable ByteBuffer buffer) {
+ if (buffer != null && !buffer.isDirect()) {
+ throw new IllegalArgumentException("body must be directly allocated");
+ }
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the body.
+ *
+ * @param bodyString A {@link String} with the data.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder body(final @Nullable String bodyString) {
+ if (bodyString == null) {
+ mBody = null;
+ return this;
+ }
+ final CharBuffer chars = CharBuffer.wrap(bodyString);
+ final ByteBuffer buffer = ByteBuffer.allocateDirect(bodyString.length());
+ Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true);
+
+ mBody = buffer;
+ return this;
+ }
+
+ /**
+ * Set the HTTP method.
+ *
+ * @param method The HTTP method String.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder method(final @NonNull String method) {
+ mMethod = method;
+ return this;
+ }
+
+ /**
+ * Set the cache mode.
+ *
+ * @param mode One of the {@link #CACHE_MODE_DEFAULT CACHE_*} flags.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder cacheMode(final @CacheMode int mode) {
+ if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) {
+ throw new IllegalArgumentException("Unknown cache mode");
+ }
+ mCacheMode = mode;
+ return this;
+ }
+
+ /**
+ * Set the HTTP Referer header.
+ *
+ * @param referrer A URI String
+ * @return This Builder instance.
+ */
+ public @NonNull Builder referrer(final @Nullable String referrer) {
+ mReferrer = referrer;
+ return this;
+ }
+
+ /**
+ * Set the beConservative property.
+ *
+ * @param beConservative If true, do not use newer protocol features that might have interop
+ * problems on the Internet. Intended only for use with critical infrastructure.
+ * @return This Builder instance.
+ */
+ public @NonNull Builder beConservative(final boolean beConservative) {
+ mBeConservative = beConservative;
+ return this;
+ }
+
+ /**
+ * @return A {@link WebRequest} constructed with the values from this Builder instance.
+ */
+ public @NonNull WebRequest build() {
+ if (mUri == null) {
+ throw new IllegalStateException("Must set URI");
+ }
+ return new WebRequest(this);
+ }
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
new file mode 100644
index 0000000000..455078feb7
--- /dev/null
+++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java
@@ -0,0 +1,380 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*-
+ * vim: ts=4 sw=4 expandtab:
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.geckoview;
+
+import android.annotation.SuppressLint;
+import androidx.annotation.AnyThread;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import java.io.ByteArrayInputStream;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import org.mozilla.gecko.annotation.WrapForJNI;
+import org.mozilla.gecko.util.XPCOMError;
+
+/**
+ * WebRequestError is simply a container for error codes and categories used by {@link
+ * GeckoSession.NavigationDelegate#onLoadError(GeckoSession, String, WebRequestError)}.
+ */
+@AnyThread
+public class WebRequestError extends Exception {
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_CATEGORY_UNKNOWN,
+ ERROR_CATEGORY_SECURITY,
+ ERROR_CATEGORY_NETWORK,
+ ERROR_CATEGORY_CONTENT,
+ ERROR_CATEGORY_URI,
+ ERROR_CATEGORY_PROXY,
+ ERROR_CATEGORY_SAFEBROWSING
+ })
+ public @interface ErrorCategory {}
+
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ ERROR_UNKNOWN,
+ ERROR_SECURITY_SSL,
+ ERROR_SECURITY_BAD_CERT,
+ ERROR_NET_RESET,
+ ERROR_NET_INTERRUPT,
+ ERROR_NET_TIMEOUT,
+ ERROR_CONNECTION_REFUSED,
+ ERROR_UNKNOWN_PROTOCOL,
+ ERROR_UNKNOWN_HOST,
+ ERROR_UNKNOWN_SOCKET_TYPE,
+ ERROR_UNKNOWN_PROXY_HOST,
+ ERROR_MALFORMED_URI,
+ ERROR_REDIRECT_LOOP,
+ ERROR_SAFEBROWSING_PHISHING_URI,
+ ERROR_SAFEBROWSING_MALWARE_URI,
+ ERROR_SAFEBROWSING_UNWANTED_URI,
+ ERROR_SAFEBROWSING_HARMFUL_URI,
+ ERROR_CONTENT_CRASHED,
+ ERROR_OFFLINE,
+ ERROR_PORT_BLOCKED,
+ ERROR_PROXY_CONNECTION_REFUSED,
+ ERROR_FILE_NOT_FOUND,
+ ERROR_FILE_ACCESS_DENIED,
+ ERROR_INVALID_CONTENT_ENCODING,
+ ERROR_UNSAFE_CONTENT_TYPE,
+ ERROR_CORRUPTED_CONTENT,
+ ERROR_DATA_URI_TOO_LONG,
+ ERROR_HTTPS_ONLY,
+ ERROR_BAD_HSTS_CERT
+ })
+ public @interface Error {}
+
+ /**
+ * This is normally used for error codes that don't currently fit into any of the other
+ * categories.
+ */
+ public static final int ERROR_CATEGORY_UNKNOWN = 0x1;
+
+ /** This is used for error codes that relate to SSL certificate validation. */
+ public static final int ERROR_CATEGORY_SECURITY = 0x2;
+
+ /** This is used for error codes relating to network problems. */
+ public static final int ERROR_CATEGORY_NETWORK = 0x3;
+
+ /** This is used for error codes relating to invalid or corrupt web pages. */
+ public static final int ERROR_CATEGORY_CONTENT = 0x4;
+
+ public static final int ERROR_CATEGORY_URI = 0x5;
+ public static final int ERROR_CATEGORY_PROXY = 0x6;
+ public static final int ERROR_CATEGORY_SAFEBROWSING = 0x7;
+
+ /** An unknown error occurred */
+ public static final int ERROR_UNKNOWN = 0x11;
+
+ // Security
+ /** This is used for a variety of SSL negotiation problems. */
+ public static final int ERROR_SECURITY_SSL = 0x22;
+
+ /** This is used to indicate an untrusted or otherwise invalid SSL certificate. */
+ public static final int ERROR_SECURITY_BAD_CERT = 0x32;
+
+ // Network
+ /** The network connection was interrupted. */
+ public static final int ERROR_NET_INTERRUPT = 0x23;
+
+ /** The network request timed out. */
+ public static final int ERROR_NET_TIMEOUT = 0x33;
+
+ /** The network request was refused by the server. */
+ public static final int ERROR_CONNECTION_REFUSED = 0x43;
+
+ /** The network request tried to use an unknown socket type. */
+ public static final int ERROR_UNKNOWN_SOCKET_TYPE = 0x53;
+
+ /** A redirect loop was detected. */
+ public static final int ERROR_REDIRECT_LOOP = 0x63;
+
+ /** This device does not have a network connection. */
+ public static final int ERROR_OFFLINE = 0x73;
+
+ /** The request tried to use a port that is blocked by either the OS or Gecko. */
+ public static final int ERROR_PORT_BLOCKED = 0x83;
+
+ /** The connection was reset. */
+ public static final int ERROR_NET_RESET = 0x93;
+
+ /**
+ * GeckoView could not connect to this website in HTTPS-only mode. Call
+ * document.reloadWithHttpsOnlyException() in the error page to temporarily disable HTTPS only
+ * mode for this request.
+ *
+ * <p>See also {@link GeckoSession.NavigationDelegate#onLoadError}
+ */
+ public static final int ERROR_HTTPS_ONLY = 0xA3;
+
+ /**
+ * A certificate validation error occurred when connecting to a site that does not allow error
+ * overrides.
+ */
+ public static final int ERROR_BAD_HSTS_CERT = 0xB3;
+
+ // Content
+ /** A content type was returned which was deemed unsafe. */
+ public static final int ERROR_UNSAFE_CONTENT_TYPE = 0x24;
+
+ /** The content returned was corrupted. */
+ public static final int ERROR_CORRUPTED_CONTENT = 0x34;
+
+ /** The content process crashed. */
+ public static final int ERROR_CONTENT_CRASHED = 0x44;
+
+ /** The content has an invalid encoding. */
+ public static final int ERROR_INVALID_CONTENT_ENCODING = 0x54;
+
+ // URI
+ /** The host could not be resolved. */
+ public static final int ERROR_UNKNOWN_HOST = 0x25;
+
+ /** An invalid URL was specified. */
+ public static final int ERROR_MALFORMED_URI = 0x35;
+
+ /** An unknown protocol was specified. */
+ public static final int ERROR_UNKNOWN_PROTOCOL = 0x45;
+
+ /** A file was not found (usually used for file:// URIs). */
+ public static final int ERROR_FILE_NOT_FOUND = 0x55;
+
+ /** The OS blocked access to a file. */
+ public static final int ERROR_FILE_ACCESS_DENIED = 0x65;
+
+ /** A data:// URI is too long to load at the top level. */
+ public static final int ERROR_DATA_URI_TOO_LONG = 0x75;
+
+ // Proxy
+ /** The proxy server refused the connection. */
+ public static final int ERROR_PROXY_CONNECTION_REFUSED = 0x26;
+
+ /** The host name of the proxy server could not be resolved. */
+ public static final int ERROR_UNKNOWN_PROXY_HOST = 0x36;
+
+ // Safebrowsing
+ /** The requested URI was present in the "malware" blocklist. */
+ public static final int ERROR_SAFEBROWSING_MALWARE_URI = 0x27;
+
+ /** The requested URI was present in the "unwanted" blocklist. */
+ public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 0x37;
+
+ /** The requested URI was present in the "harmful" blocklist. */
+ public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 0x47;
+
+ /** The requested URI was present in the "phishing" blocklist. */
+ public static final int ERROR_SAFEBROWSING_PHISHING_URI = 0x57;
+
+ /** The error code, e.g. {@link #ERROR_MALFORMED_URI}. */
+ public final int code;
+
+ /** The error category, e.g. {@link #ERROR_CATEGORY_URI}. */
+ public final int category;
+
+ /**
+ * The server certificate used. This can be useful if the error code is is e.g. {@link
+ * #ERROR_SECURITY_BAD_CERT}.
+ */
+ public final @Nullable X509Certificate certificate;
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ */
+ public WebRequestError(final @Error int code, final @ErrorCategory int category) {
+ this(code, category, null);
+ }
+
+ /**
+ * Construct a new WebRequestError with the specified code and category.
+ *
+ * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI}
+ * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI}
+ * @param certificate The X509Certificate server certificate used, if applicable.
+ */
+ public WebRequestError(
+ final @Error int code, final @ErrorCategory int category, final X509Certificate certificate) {
+ super(String.format("Request failed, error=0x%x, category=0x%x", code, category));
+ this.code = code;
+ this.category = category;
+ this.certificate = certificate;
+ }
+
+ @Override
+ public boolean equals(final Object other) {
+ if (other == null || !(other instanceof WebRequestError)) {
+ return false;
+ }
+
+ final WebRequestError otherError = (WebRequestError) other;
+
+ // We don't compare the certificate here because it's almost never what you want.
+ return otherError.code == this.code && otherError.category == this.category;
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(new Object[] {category, code});
+ }
+
+ @WrapForJNI
+ /* package */ static WebRequestError fromGeckoError(
+ final long geckoError,
+ final int geckoErrorModule,
+ final int geckoErrorClass,
+ final byte[] certificateBytes) {
+ // XXX: the geckoErrorModule argument is redundant
+ assert geckoErrorModule == XPCOMError.getErrorModule(geckoError);
+ final int code = convertGeckoError(geckoError, geckoErrorClass);
+ final int category = getErrorCategory(XPCOMError.getErrorModule(geckoError), code);
+ X509Certificate certificate = null;
+ if (certificateBytes != null) {
+ try {
+ final CertificateFactory factory = CertificateFactory.getInstance("X.509");
+ certificate =
+ (X509Certificate)
+ factory.generateCertificate(new ByteArrayInputStream(certificateBytes));
+ } catch (final CertificateException e) {
+ throw new IllegalArgumentException("Unable to parse DER certificate");
+ }
+ }
+
+ return new WebRequestError(code, category, certificate);
+ }
+
+ @SuppressLint("WrongConstant")
+ @WrapForJNI
+ /* package */ static @ErrorCategory int getErrorCategory(
+ final long errorModule, final @Error int error) {
+ if (errorModule == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ return ERROR_CATEGORY_SECURITY;
+ }
+ return error & 0xF;
+ }
+
+ @WrapForJNI
+ /* package */ static @Error int convertGeckoError(
+ final long geckoError, final int geckoErrorClass) {
+ // safebrowsing
+ if (geckoError == XPCOMError.NS_ERROR_PHISHING_URI) {
+ return ERROR_SAFEBROWSING_PHISHING_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALWARE_URI) {
+ return ERROR_SAFEBROWSING_MALWARE_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNWANTED_URI) {
+ return ERROR_SAFEBROWSING_UNWANTED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HARMFUL_URI) {
+ return ERROR_SAFEBROWSING_HARMFUL_URI;
+ }
+ // content
+ if (geckoError == XPCOMError.NS_ERROR_CONTENT_CRASHED) {
+ return ERROR_CONTENT_CRASHED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_INVALID_CONTENT_ENCODING) {
+ return ERROR_INVALID_CONTENT_ENCODING;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNSAFE_CONTENT_TYPE) {
+ return ERROR_UNSAFE_CONTENT_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CORRUPTED_CONTENT) {
+ return ERROR_CORRUPTED_CONTENT;
+ }
+ // network
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_RESET;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_RESET) {
+ return ERROR_NET_INTERRUPT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_NET_TIMEOUT) {
+ return ERROR_NET_TIMEOUT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_CONNECTION_REFUSED) {
+ return ERROR_CONNECTION_REFUSED;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_SOCKET_TYPE) {
+ return ERROR_UNKNOWN_SOCKET_TYPE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_REDIRECT_LOOP) {
+ return ERROR_REDIRECT_LOOP;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_HTTPS_ONLY) {
+ return ERROR_HTTPS_ONLY;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_BAD_HSTS_CERT) {
+ return ERROR_BAD_HSTS_CERT;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_OFFLINE) {
+ return ERROR_OFFLINE;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PORT_ACCESS_NOT_ALLOWED) {
+ return ERROR_PORT_BLOCKED;
+ }
+ // uri
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROTOCOL) {
+ return ERROR_UNKNOWN_PROTOCOL;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_HOST) {
+ return ERROR_UNKNOWN_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_MALFORMED_URI) {
+ return ERROR_MALFORMED_URI;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_NOT_FOUND) {
+ return ERROR_FILE_NOT_FOUND;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_FILE_ACCESS_DENIED) {
+ return ERROR_FILE_ACCESS_DENIED;
+ }
+ // proxy
+ if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROXY_HOST) {
+ return ERROR_UNKNOWN_PROXY_HOST;
+ }
+ if (geckoError == XPCOMError.NS_ERROR_PROXY_CONNECTION_REFUSED) {
+ return ERROR_PROXY_CONNECTION_REFUSED;
+ }
+
+ if (XPCOMError.getErrorModule(geckoError) == XPCOMError.NS_ERROR_MODULE_SECURITY) {
+ if (geckoErrorClass == 1) {
+ return ERROR_SECURITY_SSL;
+ }
+ if (geckoErrorClass == 2) {
+ return ERROR_SECURITY_BAD_CERT;
+ }
+ }
+
+ return ERROR_UNKNOWN;
+ }
+}
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java
new file mode 100644
index 0000000000..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}.
+ *
+ * <p>If 0, there will be no timeout and read() will block indefinitely.
+ *
+ * @param millis The duration in milliseconds for the timeout.
+ */
+ public void setReadTimeoutMillis(final long millis) {
+ if (this.body != null && this.body instanceof GeckoInputStream) {
+ ((GeckoInputStream) this.body).setReadTimeoutMillis(millis);
+ }
+ }
+
+ /** Builder offers a convenient way to create WebResponse instances. */
+ @WrapForJNI
+ @AnyThread
+ public static class Builder extends WebMessage.Builder {
+ /* package */ int mStatusCode;
+ /* package */ boolean mRedirected;
+ /* package */ InputStream mBody;
+ /* package */ boolean 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<WebResponse>`].
+- ⚠️ 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#<init>(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<Boolean>` for whether or not a session has form data, ([bug 1777506]({{bugzilla}}1777506)).
+
+[108.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html
+[108.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingMode(int)
+[108.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingModePrivateBrowsing(int)
+[108.4]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerMode()
+[108.5]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerMode(int)
+[108.6]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerModePrivateBrowsing()
+[108.7]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerModePrivateBrowsing(int)
+[108.9]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html
+[108.10]: {{javadoc_uri}}/GeckoSession.html#containsFormData()
+
+## v107
+- Removed deprecated [`cookieLifetime`][103.2]
+- Removed deprecated `setPermission`, see deprecation note in [v90](#v90)
+
+## v106
+- Added [`SelectionActionDelegate.onShowClipboardPermissionRequest`][106.1],
+ [`SelectionActionDelegate.onDismissClipboardPermissionRequest`][106.2],
+ [`BasicSelectionActionDelegate.onShowClipboardPermissionRequest`][106.3],
+ [`BasicSelectionActionDelegate.onDismissCancelClipboardPermissionRequest`][106.4] and
+ [`SelectionActionDelegate.ClipboardPermission`][106.5] to handle permission
+ request for reading clipboard data by [`clipboard.readText`][106.6].
+ ([bug 1776829]({{bugzilla}}1776829))
+
+[106.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission)
+[106.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onDismissClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession)
+[106.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission)
+[106.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onDismissClipboardPermission(org.mozilla.geckoview.GeckoSession)
+[106.5]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.ClipboardPermission.html
+[106.6]: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText
+
+## v104
+- Removed deprecated Autofill.Delegate `onAutofill`, Autofill.Node `fillViewStructure`, `getFocused`, `getId`, `getValue`, `getVisible`, Autofill.NodeData `Autofill.Notify`, Autofill.Session `surfaceChanged`.
+ ([bug 1781180]({{bugzilla}}1781180))
+- Removed deprecated `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5]
+- Removed deprecated [`GeckoSession.autofill`][102.18].
+ ([bug 1781180]({{bugzilla}}1781180))
+- Removed deprecated [`onLocationChange(2)`][102.3]
+ ([bug 1781180]({{bugzilla}}1781180))
+
+## v103
+- Added [`GeckoSession.saveAsPdf`][103.1] that returns a `GeckoResult<InputStream>` that contains a PDF of the current session's page.
+- Added missing `@Deprecated` tag for `setPermission`, see deprecation note in [v90](#v90).
+- ⚠️ Deprecated [`cookieLifetime`][103.2], this feature is not available anymore.
+
+[103.1]: {{javadoc_uri}}/GeckoSession.html#saveAsPdf()
+[103.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieLifetime(int)
+
+## v102
+- Added [`DateTimePrompt.stepValue`][102.1] to export [`step`][102.2] attribute of input element.
+ ([bug 1499635]({{bugzilla}}1499635))
+- Deprecated [`onLocationChange(2)`][102.3], please use [`onLocationChange(3)`][102.4].
+- Added [`GeckoSession.setPriorityHint`][102.5] function to set the session to either high priority or default.
+- [`WebRequestError.ERROR_HTTPS_ONLY`][102.6] now has error category
+ `ERROR_CATEGORY_NETWORK` rather than `ERROR_CATEGORY_SECURITY`.
+- ⚠️ The Autofill.Delegate API now receives a [`AutofillNode`][102.7] object instead of
+ the entire [`Node`][102.8] structure. The `onAutofill` delegate method is now split
+ into several methods: [`onNodeAdd`][102.9], [`onNodeBlur`][102.10],
+ [`onNodeFocus`][102.11], [`onNodeRemove`][102.12], [`onNodeUpdate`][102.13],
+ [`onSessionCancel`][102.14], [`onSessionCommit`][102.15],
+ [`onSessionStart`][102.16].
+- Added [`PromptInstanceDelegate.onPromptUpdate`][102.17] to allow GeckoView to update current prompts.
+ ([bug 1758800]({{bugzilla}}1758800))
+- Deprecated [`GeckoSession.autofill`][102.18], use [`Autofill.Session.autofill`][102.19] instead.
+ ([bug 1770010]({{bugzilla}}1770010))
+- Added [`WebRequestError.ERROR_BAD_HSTS_CERT`][102.20] error code to notify the app of a connection to a site that does not allow error overrides.
+ ([bug 1721220]({{bugzilla}}1721220))
+
+[102.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.DateTimePrompt.html#stepValue
+[102.2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#step
+[102.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[102.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[102.5]: {{javadoc_uri}}/GeckoSession.html#setPriorityHint(int)
+[102.6]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+[102.7]: {{javadoc_uri}}/Autofill.AutofillNode.html
+[102.8]: {{javadoc_uri}}/Autofill.Node.html
+[102.9]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeAdd(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.10]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeBlur(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.11]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeFocus(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.12]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeRemove(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.13]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeUpdate(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.14]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCancel(org.mozilla.geckoview.GeckoSession)
+[102.15]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCommit(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData)
+[102.16]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionStart(org.mozilla.geckoview.GeckoSession)
+[102.17]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html#onPromptUpdate(org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt)
+[102.18]: {{javadoc_uri}}/GeckoSession.html#autofill(android.util.SparseArray)
+[102.19]: {{javadoc_uri}}/Autofill.Session.html#autofill(android.util.SparseArray)
+[102.20]: {{javadoc_uri}}/WebRequestError.html#ERROR_BAD_HSTS_CERT
+
+## v101
+- Added [`GeckoDisplay.surfaceChanged`][101.1] function taking new type [`GeckoDisplay.SurfaceInfo`][101.2].
+ This allows the caller to provide a [`SurfaceControl`][101.3] object, which must be set on SDK level 29 and
+ above when rendering in to a `SurfaceView`.
+ ([bug 1762424]({{bugzilla}}1762424))
+- ⚠️ Deprecated old `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5].
+- Add [`WebExtensionController.optionalPrompt`][101.6] to allow handling of optional permission requests from extensions.
+
+[101.1]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(org.mozilla.geckoview.GeckoDisplay.SurfaceInfo)
+[101.2]: {{javadoc_uri}}/GeckoDisplay.SurfaceInfo.html
+[101.3]: https://developer.android.com/reference/android/view/SurfaceControl
+[101.4]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int)
+[101.5]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int,int,int)
+[101.6]: {{javadoc_uri}}/WebExtensionController.html#optionalPrompt(org.mozilla.geckoview.WebExtension.Message,org.mozilla.geckoview.WebExtension)
+
+## v100
+- ⚠️ Changed [`GeckoSession.isOpen`][100.1] to `@UiThread`.
+- [`WebNotification`][100.2] now implements [`Parcelable`][100.3] to support
+ persisting notifications and responding to them while the browser is not
+ running.
+- Removed deprecated `GeckoRuntime.EXTRA_CRASH_FATAL`
+- Removed deprecated `MediaSource.rawId`
+
+[100.1]: {{javadoc_uri}}/GeckoSession.html#isOpen()
+[100.2]: {{javadoc_uri}}/WebNotification.html
+[100.3]: https://developer.android.com/reference/android/os/Parcelable
+
+## v99
+- Removed deprecated `GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`.
+ ([bug 1754244]({{bugzilla}}1754244))
+
+## v98
+- Add [`WebRequest.beConservative`][98.1] to allow critical infrastructure to
+ avoid using bleeding-edge network features.
+ ([bug 1750231]({{bugzilla}}1750231))
+
+[98.1]: {{javadoc_uri}}/WebRequest.html#beConservative
+
+## v97
+- ⚠️ Deprecated [`MediaSource.rawId`][97.1],
+ which now provides the same string as [`id`][97.2].
+ ([bug 1744346]({{bugzilla}}1744346))
+- Added [`EXTRA_CRASH_PROCESS_TYPE`][97.3] field to `ACTION_CRASHED` intents,
+ and corresponding [`CRASHED_PROCESS_TYPE_*`][97.4] constants, indicating which
+ type of process a crash occured in.
+ ([bug 1743454]({{bugzilla}}1743454))
+- ⚠️ Deprecated [`EXTRA_CRASH_FATAL`][97.5]. Use `EXTRA_CRASH_PROCESS_TYPE` instead.
+ ([bug 1743454]({{bugzilla}}1743454))
+- Added [`OrientationController`][97.6] to allow GeckoView to handle orientation locking.
+ ([bug 1697647]({{bugzilla}}1697647))
+- Added [GeckoSession.goBack][97.7] and [GeckoSession.goForward][97.8] with a
+ `userInteraction` parameter. Updated the default goBack/goForward behaviour
+ to also be considered as a user interaction.
+ ([bug 1644595]({{bugzilla}}1644595))
+
+[97.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#rawId
+[97.2]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#id
+[97.3]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_PROCESS_TYPE
+[97.4]: {{javadoc_uri}}/GeckoRuntime.html#CRASHED_PROCESS_TYPE_MAIN
+[97.5]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_FATAL
+[97.6]: {{javadoc_uri}}/OrientationController.html
+[97.7]: {{javadoc_uri}}/GeckoSession.html#goBack(boolean)
+[97.8]: {{javadoc_uri}}/GeckoSession.html#goForward(boolean)
+
+## v96
+- Added [`onLoginFetch`][96.1] which allows apps to provide all saved logins to
+ GeckoView.
+ ([bug 1733423]({{bugzilla}}1733423))
+- Added [`GeckoResult.finally_`][96.2] to unconditionally run an action after
+ the GeckoResult has been completed.
+ ([bug 1736433]({{bugzilla}}1736433))
+- Added [`ERROR_INVALID_DOMAIN`][96.3] to `WebExtension.InstallException.ErrorCodes`.
+ ([bug 1740634]({{bugzilla}}1740634))
+- Added [`Selection.pasteAsPlainText`][96.4] to paste HTML content as plain
+ text.
+ ([bug 1740414]({{bugzilla}}1740414))
+- Removed deprecated Content Blocking APIs.
+ ([bug 1743706]({{bugzilla}}1743706))
+
+[96.1]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html#onLoginFetch()
+[96.2]: {{javadoc_uri}}/GeckoResult.html#finally_(java.lang.Runnable)
+[96.3]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INVALID_DOMAIN
+[96.4]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#pasteAsPlainText()
+
+## v95
+- Added [`GeckoSession.ContentDelegate.onPointerIconChange()`][95.1] to notify
+ the application of changing pointer icon. If the application wants to handle
+ pointer icon, it should override this.
+ ([bug 1672609]({{bugzilla}}1672609))
+- Deprecated [`ContentBlockingController`][95.2], use
+ [`StorageController`][95.3] instead. A [`PERMISSION_TRACKING`][95.4]
+ permission is now present in [`onLocationChange`][95.5] for every page load,
+ which can be used to set tracking protection exceptions.
+ ([bug 1714945]({{bugzilla}}1714945))
+- Added [`setPrivateBrowsingPermanentPermission`][95.6], which allows apps to set
+ permanent permissions in private browsing (e.g. to set permanent tracking
+ protection permissions in private browsing).
+ ([bug 1714945]({{bugzilla}}1714945))
+- Deprecated [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7] due to typo.
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoRuntimeSettings.Builder.enterpriseRootsEnabled`][95.8] to replace [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7].
+ ([bug 1708815]({{bugzilla}}1708815))
+- Added [`GeckoSession.ContentDelegate.onPreviewImage`][95.9] to notify
+ the application of a preview image URL.
+ ([bug 1732219]({{bugzilla}}1732219))
+
+[95.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPointerIconChange(org.mozilla.geckoview.GeckoSession,android.view.PointerIcon)
+[95.2]: {{javadoc_uri}}/ContentBlockingController.html
+[95.3]: {{javadoc_uri}}/StorageController.java
+[95.4]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING
+[95.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)
+[95.6]: {{javadoc_uri}}/StorageController.html#setPrivateBrowsingPermanentPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[95.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpiseRootsEnabled(boolean)
+[95.8]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean)
+[95.9]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPreviewImage(org.mozilla.geckoview.GeckoSession,java.lang.String)
+
+## v94
+- Extended [`Autocomplete`][78.7] API to support credit card saving.
+ ([bug 1703976]({{bugzilla}}1703976))
+
+## v93
+- Removed deprecated [`Autocomplete.LoginStorageDelegate`][78.8].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Removed deprecated [`GeckoRuntime.getProfileDir`][90.5].
+ ([bug 1725469]({{bugzilla}}1725469))
+- Added [`PromptInstanceDelegate`][93.1] to allow GeckoView to dismiss stale prompts.
+ ([bug 1710668]({{bugzilla}}1710668))
+- Added [`WebRequestError.ERROR_HTTPS_ONLY`][93.2] error code to allow GeckoView display custom HTTPS-only error pages and bypass them.
+ ([bug 1697866]({{bugzilla}}1697866))
+
+[93.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html
+[93.2]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY
+
+## v92
+- Added [`PermissionDelegate.PERMISSION_STORAGE_ACCESS`][92.1] to
+ control the allowing of third-party frames to access first-party cookies and
+ storage. ([bug 1543720]({{bugzilla}}1543720))
+- Added [`ContentDelegate.onShowDynamicToolbar`][92.2] to notify
+ the app that it must fully-expand its dynamic toolbar ([bug 1690296]({{bugzilla}}1690296))
+- Removed deprecated `GeckoResult.ALLOW` and `GeckoResult.DENY`.
+ Use [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9] instead.
+
+[92.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_STORAGE_ACCESS
+[92.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onShowDynamicToolbar(org.mozilla.geckoview.GeckoSession)
+
+## v91
+- Extended [`Autocomplete`][78.7] API to support addresses.
+ ([bug 1699794]({{bugzilla}}1699794)).
+- Added [`clearDataFromBaseDomain`][91.1] to [`StorageController`][90.2] for
+ clearing site data by base domain. This includes data of associated subdomains
+ and data partitioned via [`State Partitioning`][91.3].
+- Removed deprecated `MediaElement` API.
+
+[91.1]: {{javadoc_uri}}/StorageController.html#clearDataFromBaseDomain(java.lang.String,long)
+[91.2]: {{javadoc_uri}}/StorageController.html
+[91.3]: https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning
+
+## v90
+- Added [`WebNotification.silent`][90.1] and [`WebNotification.vibrate`][90.2]
+ support. See also [Web/API/Notification/silent][90.3] and
+ [Web/API/Notification/vibrate][90.4].
+ ([bug 1696145]({{bugzilla}}1696145))
+- ⚠️ Deprecated [`GeckoRuntime.getProfileDir`][90.5], the API is being kept for
+ compatibility but it always returns null.
+- Added [`forceEnableAccessibility`][90.6] runtime setting to enable
+ accessibility during testing.
+ ([bug 1701269]({{bugzilla}}1701269))
+- Removed deprecated [`GeckoView.onTouchEventForResult`][88.4].
+ ([bug 1706403]({{bugzilla}}1706403))
+- ⚠️ Updated [`onContentPermissionRequest`][90.7] to use [`ContentPermission`][90.8]; added
+ [`setPermission`][90.9] to [`StorageController`][90.10] for modifying existing permissions, and
+ allowed Gecko to handle persisting permissions.
+- ⚠️ Added a deprecation schedule to most existing content blocking exception functionality;
+ other than [`addException`][90.11], content blocking exceptions should be treated as content
+ permissions going forward.
+
+[90.1]: {{javadoc_uri}}/WebNotification.html#silent
+[90.2]: {{javadoc_uri}}/WebNotification.html#vibrate
+[90.3]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent
+[90.4]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate
+[90.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfileDir()
+[90.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setForceEnableAccessibility(boolean)
+[90.7]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission)
+[90.8]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[90.9]: {{javadoc_uri}}/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)
+[90.10]: {{javadoc_uri}}/StorageController.html
+[90.11]: {{javadoc_uri}}/ContentBlockingController.html#addException(org.mozilla.geckoview.GeckoSession)
+
+## v89
+- Added [`ContentPermission`][89.1], which is used to report what permissions content
+ is loaded with in `onLocationChange`.
+- Added [`StorageController.getPermissions`][89.2] and [`StorageController.getAllPermissions`][89.3],
+ allowing inspection of what permissions have been set for a given URI and for all URIs.
+- ⚠️ Deprecated [`NavigationDelegate.onLocationChange`][89.4], to be removed in v92. The
+ new `onLocationChange` callback simply adds permissions information, migration of existing
+ functionality should only require updating the function signature.
+- Added [`GeckoRuntimeSettings.setEnterpriseRootsEnabled`][89.5] which allows
+ GeckoView to add third party certificate roots from the Android OS CA store.
+ ([bug 1678191]({{bugzilla}}1678191)).
+- ⚠️ [`GeckoSession.load`][89.6] now throws `IllegalArgumentException` if the
+ session has no [`GeckoSession.NavigationDelegate`][89.7] and the request's `data` URI is too long.
+ If a `GeckoSession` *does* have a `GeckoSession.NavigationDelegate` and `GeckoSession.load` is called
+ with a top-level `data` URI that is too long, [`NavigationDelgate.onLoadError`][89.8] will be called
+ with a [`WebRequestError`][89.9] containing error code [`WebRequestError.ERROR_DATA_URI_TOO_LONG`][89.10].
+ ([bug 1668952]({{bugzilla}}1668952))
+- Extended [`Autocomplete`][78.7] API to support credit cards.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- ⚠️ Deprecated [`Autocomplete.LoginStorageDelegate`][78.8] with the intention
+ of removing it in GeckoView v93. Please use
+ [`Autocomplete.StorageDelegate`][89.11] instead.
+ ([bug 1691819]({{bugzilla}}1691819)).
+- Added [`ALLOWED_TRACKING_CONTENT`][89.12] to content blocking API to indicate
+ when unsafe content is allowed by a shim.
+ ([bug 1661330]({{bugzilla}}1661330))
+- ⚠️ Added [`setCookieBehaviorPrivateMode`][89.13] to control cookie behavior for private browsing
+ mode independently of normal browsing mode. To maintain current behavior, set this to the same
+ value as [`setCookieBehavior`][89.14] is set to.
+
+[89.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html
+[89.2]: {{javadoc_uri}}/StorageController.html#getPermissions(java.lang.String)
+[89.3]: {{javadoc_uri}}/StorageController.html#getAllPermissions()
+[89.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[89.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setEnterpriseRootsEnabled(boolean)
+[89.6]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[89.7]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html
+[89.8]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadError(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.WebRequestError)
+[89.9]: {{javadoc_uri}}/WebRequestError.html
+[89.10]: {{javadoc_uri}}/WebRequestError.html#ERROR_DATA_URI_TOO_LONG
+[89.11]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html
+[89.12]: {{javadoc_uri}}/ContentBlockingController.Event.html#ALLOWED_TRACKING_CONTENT
+[89.13]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehaviorPrivateMode(int)
+[89.14]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehavior(int)
+
+## v88
+- Added [`WebExtension.Download#update`][88.1] that can be used to
+ implement the WebExtension `downloads` API. This method is used to communicate
+ updates in the download status to the Web Extension
+- Added [`PanZoomController.onTouchEventForDetailResult`][88.2] and
+ [`GeckoView.onTouchEventForDetailResult`][88.3] to tell information
+ that the website doesn't expect browser apps to react the event,
+ also and deprecated [`PanZoomController.onTouchEventForResult`][88.4]
+ and [`GeckoView.onTouchEventForResult`][88.5]. With these new methods
+ browser apps can differentiate cases where the browser can do something
+ the browser's specific behavior in response to the event (e.g.
+ pull-to-refresh) and cases where the browser should not react to the event
+ because the event was consumed in the web site (e.g. in canvas like
+ web apps).
+ ([bug 1678505]({{bugzilla}}1678505)).
+- ⚠️ Deprecate the [`MediaElement`][65.11] API to be removed in v91.
+ Please use [`MediaSession`][81.6] for media events and control.
+ ([bug 1693584]({{bugzilla}}1693584)).
+- ⚠️ Deprecate [`GeckoResult.ALLOW`][89.6] and [`GeckoResult.DENY`][89.7] in
+ favor of [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9].
+ ([bug 1697270]({{bugzilla}}1697270)).
+- ⚠️ Update [`SessionState`][88.10] to handle null states/strings more gracefully.
+ ([bug 1685486]({{bugzilla}}1685486)).
+
+[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update(org.mozilla.geckoview.WebExtension.Download.Info)
+[88.2]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForDetailResult
+[88.3]: {{javadoc_uri}}/GeckoView.html#onTouchEventForDetailResult
+[88.4]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForResult
+[88.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult
+[88.6]: {{javadoc_uri}}/GeckoResult.html#ALLOW
+[88.7]: {{javadoc_uri}}/GeckoResult.html#DENY
+[88.8]: {{javadoc_uri}}/GeckoResult.html#allow()
+[88.9]: {{javadoc_uri}}/GeckoResult.html#deny()
+[88.10]: {{javadoc_uri}}/GeckoSession.SessionState.html
+
+## v87
+- ⚠️ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to
+ implement the WebExtension `downloads` API. This class represents initial state of a download.
+- Added [`WebExtension.Download.Info`][87.2] interface that can be used to
+ implement the WebExtension `downloads` API. This interface allows communicating
+ download's state to Web Extension.
+- [`Image#getBitmap`][87.3] now throws [`ImageProcessingException`][87.4] if
+ the image cannot be processed.
+ ([bug 1689745]({{bugzilla}}1689745))
+- Added support for HTTPS-only mode to [`GeckoRuntimeSettings`][87.5] via
+ [`setAllowInsecureConnections`][87.6].
+- Removed `JSONException` throws from [`SessionState.fromString`][87.7], fixed annotations,
+ and clarified null-handling a bit.
+
+[87.1]: {{javadoc_uri}}/WebExtension.DownloadInitData.html
+[87.2]: {{javadoc_uri}}/WebExtension.Download.Info.html
+[87.3]: {{javadoc_uri}}/Image.html#getBitmap(int)
+[87.4]: {{javadoc_uri}}/Image.ImageProcessingException.html
+[87.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[87.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAllowInsecureConnections(int)
+[87.7]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+
+## v86
+- Removed deprecated `ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`.
+ Use [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] instead.
+ ([bug 1665157]({{bugzilla}}1665157))
+- Added [`WebExtension.DownloadDelegate`][86.1] and that can be used to
+ implement the WebExtension `downloads` API.
+ ([bug 1656336]({{bugzilla}}1656336))
+- Added [`WebRequest.Builder#body(@Nullable String)`][86.2] which converts a string to direct byte buffer.
+- Removed deprecated `REPLACED_UNSAFE_CONTENT`.
+ ([bug 1667471]({{bugzilla}}1667471))
+- Removed deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`GeckoResult#map`][86.3] to synchronously map a GeckoResult value.
+- Added [`PanZoomController#INPUT_RESULT_IGNORED`][86.4].
+ ([bug 1687430]({{bugzilla}}1687430))
+
+[86.1]: {{javadoc_uri}}/WebExtension.DownloadDelegate.html
+[86.2]: {{javadoc_uri}}/WebRequest.Builder#body(java.lang.String)
+[86.3]: {{javadoc_uri}}/GeckoResult.html#map(org.mozilla.geckoview.GeckoResult.OnValueMapper)
+[86.4]: {{javadoc_uri}}/PanZoomController.html#INPUT_RESULT_IGNORED
+
+## v85
+- Added [`WebExtension.BrowsingDataDelegate`][85.1] that can be used to
+ implement the WebExtension `browsingData` API.
+
+[85.1]: {{javadoc_uri}}/WebExtension.BrowsingDataDelegate.html
+
+## v84
+- ⚠️ Removed deprecated `GeckoRuntimeSettings.Builder.useMultiprocess` and
+ [`GeckoRuntimeSettings.getUseMultiprocess`]. Single-process GeckoView is no
+ longer supported. ([bug 1650118]({{bugzilla}}1650118))
+- Deprecated members now have an additional [`@DeprecationSchedule`][84.1] annotation which
+ includes the `version` that we expect to remove the member and an `id` that
+ can be used to group annotation notices in tooling.
+ ([bug 1671460]({{bugzilla}}1671460))
+- ⚠️ Removed deprecated `ContentBlockingController.ExceptionList` and
+ `ContentBlockingController.restoreExceptionList`. ([bug 1674500]({{bugzilla}}1674500))
+
+[84.1]: {{javadoc_uri}}/DeprecationSchedule.html
+
+## v83
+- Added [`WebExtension.MetaData.temporary`][83.1] which exposes whether an extension
+ has been installed temporarily, e.g. when using web-ext.
+ ([bug 1624410]({{bugzilla}}1624410))
+- ⚠️ Removing unsupported `MediaSession.Delegate.onPictureInPicture` for now.
+ Also, [`MediaSession.Delegate.onMetadata`][83.2] is no longer dispatched for
+ plain media elements.
+ ([bug 1658937]({{bugzilla}}1658937))
+- Replaced android.util.ArrayMap with java.util.TreeMap in [`WebMessage`][65.13] to enable case-insensitive handling of the HTTP headers.
+ ([bug 1666013]({{bugzilla}}1666013))
+- Added [`ContentBlocking.SafeBrowsingProvider`][83.3] to configure Safe
+ Browsing providers.
+ ([bug 1660241]({{bugzilla}}1660241))
+- Added [`GeckoRuntime.ActivityDelegate`][83.4] which allows applications to handle
+ starting external Activities on behalf of GeckoView. Currently this is used to integrate
+ FIDO support for WebAuthn.
+- Added [`GeckoWebExecutor#FETCH_FLAG_PRIVATE`][83.5]. This new flag allows for private browsing downloads using WebExecutor.
+ ([bug 1665426]({{bugzilla}}1665426))
+- ⚠️ Deprecated [`GeckoSession#loadUri`][83.6] variants in favor of
+ [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8].
+ ([bug 1667471]({{bugzilla}}1667471))
+- Added [`Loader#headerFilter`][83.9] to override the default header filtering
+ behavior.
+ ([bug 1667471]({{bugzilla}}1667471))
+
+[83.1]: {{javadoc_uri}}/WebExtension.MetaData.html#temporary
+[83.2]: {{javadoc_uri}}/MediaSession.Delegate.html#onMetadata(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.MediaSession,org.mozilla.geckoview.MediaSession.Metadata)
+[83.3]: {{javadoc_uri}}/ContentBlocking.SafeBrowsingProvider.html
+[83.4]: {{javadoc_uri}}/GeckoRuntime.ActivityDelegate.html
+[83.5]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAG_PRIVATE
+[83.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int,java.util.Map)
+[83.7]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader)
+[83.8]: {{javadoc_uri}}/GeckoSession.Loader.html
+[83.9]: {{javadoc_uri}}/GeckoSession.Loader.html#headerFilter(int)
+
+## v82
+- ⚠️ [`WebNotification.source`][79.2] is now `@Nullable` to account for
+ WebExtension notifications which don't have a `source` field.
+- ⚠️ Deprecated [`ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`][82.1] with the intention of removing
+ them in GeckoView v85.
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] to eliminate the need
+ to make a second request for downloads and ensure more efficient and reliable downloads in a single request. The second
+ parameter is now a [`WebResponse`][65.15]
+ ([bug 1530022]({{bugzilla}}1530022))
+- Added [`Image`][82.3] support for size-dependent bitmap retrieval from image resources.
+ ([bug 1658456]({{bugzilla}}1658456))
+- ⚠️ Use [`Image`][82.3] for [`MediaSession`][81.6] artwork and [`WebExtension`][69.5] icon support.
+ ([bug 1662508]({{bugzilla}}1662508))
+- Added [`RepostConfirmPrompt`][82.4] to prompt the user for cofirmation before
+ resending POST requests.
+ ([bug 1659073]({{bugzilla}}1659073))
+- Removed `Parcelable` support in `GeckoSession`. Use [`ProgressDelegate#onSessionStateChange`][68.29] and [`ProgressDelegate#restoreState`][82.5] instead.
+ ([bug 1650108]({{bugzilla}}1650108))
+- ⚠️ Use AndroidX instead of the Android support library. For the public API this only changes
+ the thread and nullable annotation types.
+- Added [`REPLACED_TRACKING_CONTENT`][82.6] to content blocking API to indicate when unsafe content is shimmed.
+ ([bug 1663756]({{bugzilla}}1663756))
+
+[82.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.WebResponseInfo)
+[82.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoResult)
+[82.3]: {{javadoc_uri}}/Image.html
+[82.4]: {{javadoc_uri}}/GeckoSession.PromptDelegate.RepostConfirmPrompt.html
+[82.5]: {{javadoc_uri}}/GeckoSession.html#restoreState(org.mozilla.geckoview.GeckoSession.SessionState)
+[82.6]: {{javadoc_uri}}/ContentBlockingController.Event.html#REPLACED_TRACKING_CONTENT
+
+## v81
+- Added `cookiePurging` to [`ContentBlocking.Settings.Builder`][81.1] and `getCookiePurging` and `setCookiePurging`
+ to [`ContentBlocking.Settings`][81.2].
+- Added [`GeckoSession.ContentDelegate.onPaintStatusReset()`][81.3] callback which notifies when valid content is no longer being rendered.
+- Made [`GeckoSession.ContentDelegate.onFirstContentfulPaint()`][81.4] additionally be called for the first contentful paint following a `onPaintStatusReset()` event, rather than just the first contentful paint of the session.
+- Removed deprecated `GeckoRuntime.registerWebExtension`. Use [`WebExtensionController.install`][73.1] instead.
+⚠️ - Changed [`GeckoView.onTouchEventForResult`][81.5] to return a `GeckoResult`, as it now
+makes a round-trip to Gecko. The result will be more accurate now, since how content treats
+the event is now considered.
+- Added [`MediaSession`][81.6] API for session-based media events and control.
+
+[81.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[81.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+[81.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPaintStatusReset(org.mozilla.geckoview.GeckoSession)
+[81.4]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[81.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[81.6]: {{javadoc_uri}}/MediaSession.html
+
+## v80
+- Removed `GeckoSession.hashCode` and `GeckoSession.equals` overrides in favor
+ of the default implementations. ([bug 1647883]({{bugzilla}}1647883))
+- Added `strictSocialTrackingProtection` to [`ContentBlocking.Settings.Builder`][80.1] and `getStrictSocialTrackingProtection`
+ to [`ContentBlocking.Settings`][80.2].
+
+[80.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html
+[80.2]: {{javadoc_uri}}/ContentBlocking.Settings.html
+
+## v79
+- Added `runtime.openOptionsPage` support. For `options_ui.open_in_new_tab ==
+ false`, [`TabDelegate.onOpenOptionsPage`][79.1] is called.
+ ([bug 1618058]({{bugzilla}}1619766))
+- Added [`WebNotification.source`][79.2], which is the URL of the page
+ or Service Worker that created the notification.
+- Removed deprecated `WebExtensionController.setTabDelegate` and `WebExtensionController.getTabDelegate`
+ APIs ([bug 1618987]({{bugzilla}}1618987)).
+- ⚠️ [`RuntimeTelemetry#getSnapshots`][68.10] is removed after deprecation.
+ Use Glean to handle Gecko telemetry.
+ ([bug 1644447]({{bugzilla}}1644447))
+- Added [`ensureBuiltIn`][79.3] that ensures that a built-in extension is
+ installed without re-installing.
+ ([bug 1635564]({{bugzilla}}1635564))
+- Added [`ProfilerController`][79.4], accessible via [`GeckoRuntime.getProfilerController`][79.5]
+to allow adding gecko profiler markers.
+([bug 1624993]({{bugzilla}}1624993))
+- ⚠️ Deprecated `Parcelable` support in `GeckoSession` with the intention of removing
+ in GeckoView v82. ([bug 1649529]({{bugzilla}}1649529))
+- ⚠️ Deprecated [`GeckoRuntimeSettings.Builder.useMultiprocess`][79.6] and
+ [`GeckoRuntimeSettings.getUseMultiprocess`][79.7] with the intention of removing
+ them in GeckoView v82. ([bug 1649530]({{bugzilla}}1649530))
+
+[79.1]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onOpenOptionsPage(org.mozilla.geckoview.WebExtension)
+[79.2]: {{javadoc_uri}}/WebNotification.html#source
+[79.3]: {{javadoc_uri}}/WebExtensionController.html#ensureBuiltIn(java.lang.String,java.lang.String)
+[79.4]: {{javadoc_uri}}/ProfilerController.html
+[79.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfilerController()
+[79.6]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[79.7]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getUseMultiprocess()
+
+## v78
+- Added [`WebExtensionController.installBuiltIn`][78.1] that allows installing an
+ extension that is bundled with the APK. This method is meant as a replacement
+ for [`GeckoRuntime.registerWebExtension`][67.15], ⚠️ which is now deprecated
+ and will be removed in GeckoView 81.
+- Added [`CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS`][78.2] to allow
+ enabling dynamic first party isolation; this will block tracking cookies and
+ isolate all other third party cookies by keying them based on the first party
+ from which they are accessed.
+- Added `cookieStoreId` field to [`WebExtension.CreateTabDetails`][78.3]. This adds the optional
+ ability to create a tab with a given cookie store ID for its [`contextual identity`][78.4].
+ ([bug 1622500]({{bugzilla}}1622500))
+- Added [`NavigationDelegate.onSubframeLoadRequest`][78.5] to allow intercepting
+ non-top-level navigations.
+- Added [`BeforeUnloadPrompt`][78.6] to respond to prompts from onbeforeunload.
+- ⚠️ Refactored `LoginStorage` to the [`Autocomplete`][78.7] API to support
+ login form autocomplete delegation.
+ Refactored `LoginStorage.Delegate` to [`Autocomplete.LoginStorageDelegate`][78.8].
+ Refactored `GeckoSession.PromptDelegate.onLoginStoragePrompt` to
+ [`GeckoSession.PromptDelegate.onLoginSave`][78.9].
+ Added [`GeckoSession.PromptDelegate.onLoginSelect`][78.10].
+ ([bug 1618058]({{bugzilla}}1618058))
+- Added [`GeckoRuntimeSettings#setLoginAutofillEnabled`][78.11] to control
+ whether login forms should be automatically filled in suitable situations.
+
+[78.1]: {{javadoc_uri}}/WebExtensionController.html#installBuiltIn(java.lang.String)
+[78.2]: {{javadoc_uri}}/ContentBlocking.CookieBehavior.html#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS
+[78.3]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[78.4]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities
+[78.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onSubframeLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+[78.6]: {{javadoc_uri}}/GeckoSession.PromptDelegate.BeforeUnloadPrompt.html
+[78.7]: {{javadoc_uri}}/Autocomplete.html
+[78.8]: {{javadoc_uri}}/Autocomplete.LoginStorageDelegate.html
+[78.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSave(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.10]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSelect(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest)
+[78.11]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLoginAutofillEnabled(boolean)
+
+## v77
+- Added [`GeckoRuntime.appendAppNotesToCrashReport`][77.1] For adding app notes to the crash report.
+ ([bug 1626979]({{bugzilla}}1626979))
+- ⚠️ Remove the `DynamicToolbarAnimator` API along with accesors on `GeckoView` and `GeckoSession`.
+ ([bug 1627716]({{bugzilla}}1627716))
+
+[77.1]: {{javadoc_uri}}/GeckoRuntime.html#appendAppNotesToCrashReport(java.lang.String)
+
+## v76
+- Added [`GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS`][76.1] to control EME media key access.
+- [`RuntimeTelemetry#getSnapshots`][68.10] is deprecated and will be removed
+ in 79. Use Glean to handle Gecko telemetry.
+ ([bug 1620395]({{bugzilla}}1620395))
+- Added `LoadRequest.isDirectNavigation` to know when calls to
+ [`onLoadRequest`][76.3] originate from a direct navigation made by the app
+ itself.
+ ([bug 1624675]({{bugzilla}}1624675))
+
+[76.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS
+[76.2]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isDirectNavigation
+[76.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest)
+
+## v75
+- ⚠️ Remove `GeckoRuntimeSettings.Builder#useContentProcessHint`. The content
+ process is now preloaded by default if
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1] is enabled.
+- ⚠️ Move `GeckoSessionSettings.Builder#useMultiprocess` to
+ [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1]. Multiprocess state is
+ no longer determined per session.
+- Added [`DebuggerDelegate#onExtensionListUpdated`][75.2] to notify that a temporary
+ extension has been installed by the debugger.
+ ([bug 1614295]({{bugzilla}}1614295))
+- ⚠️ Removed [`GeckoRuntimeSettings.setAutoplayDefault`][75.3], use
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13] to
+ control autoplay.
+ ([bug 1614894]({{bugzilla}}1614894))
+- Added [`GeckoSession.reload(int flags)`][75.4] That takes a [load flag][75.5] parameter.
+- ⚠️ Moved [`ActionDelegate`][75.6] and [`MessageDelegate`][75.7] to
+ [`SessionController`][75.8].
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate`][75.9] to [`SessionController`][75.8] and
+ [`TabDelegate`][75.10] to [`WebExtension`][69.5] which receive respectively
+ calls for the session and the runtime. `TabDelegate` is also now
+ per-`WebExtension` object instead of being global. The existing global
+ [`TabDelegate`][75.11] is now deprecated and will be removed in GeckoView 77.
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`SessionTabDelegate#onUpdateTab`][75.12] which is called whenever an
+ extension calls `tabs.update` on the corresponding `GeckoSession`.
+ [`TabDelegate#onCreateTab`][75.13] now takes a [`CreateTabDetails`][75.14]
+ object which contains additional information about the newly created tab
+ (including the `url` which used to be passed in directly).
+ ([bug 1616625]({{bugzilla}}1616625))
+- Added [`GeckoRuntimeSettings.setWebManifestEnabled`][75.15],
+ [`GeckoRuntimeSettings.webManifest`][75.16], and
+ [`GeckoRuntimeSettings.getWebManifestEnabled`][75.17]
+ ([bug 1614894]({{bugzilla}}1603673)), to enable or check Web Manifest support.
+- Added [`GeckoDisplay.safeAreaInsetsChanged`][75.18] to notify the content of [safe area insets][75.19].
+ ([bug 1503656]({{bugzilla}}1503656))
+- Added [`GeckoResult#cancel()`][75.22], [`GeckoResult#setCancellationDelegate()`][75.22],
+ and [`GeckoResult.CancellationDelegate`][75.23]. This adds the optional ability to cancel
+ an operation behind a pending `GeckoResult`.
+- Added [`baseUrl`][75.24] to [`WebExtension.MetaData`][75.25] to expose the
+ base URL for all WebExtension pages for a given extension.
+ ([bug 1560048]({{bugzilla}}1560048))
+- Added [`allowedInPrivateBrowsing`][75.26] and
+ [`setAllowedInPrivateBrowsing`][75.27] to control whether an extension can
+ run in private browsing or not. Extensions installed with
+ [`registerWebExtension`][67.15] will always be allowed to run in private
+ browsing.
+ ([bug 1599139]({{bugzilla}}1599139))
+
+[75.1]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean)
+[75.2]: {{javadoc_uri}}/WebExtensionController.DebuggerDelegate.html#onExtensionListUpdated()
+[75.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#autoplayDefault(boolean)
+[75.4]: {{javadoc_uri}}/GeckoSession.html#reload(int)
+[75.5]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_NONE
+[75.6]: {{javadoc_uri}}/WebExtension.ActionDelegate.html
+[75.7]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[75.8]: {{javadoc_uri}}/WebExtension.SessionController.html
+[75.9]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html
+[75.10]: {{javadoc_uri}}/WebExtension.TabDelegate.html
+[75.11]: {{javadoc_uri}}/WebExtensionRuntime.TabDelegate.html
+[75.12]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html#onUpdateTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebExtension.UpdateTabDetails)
+[75.13]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onNewTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.CreateTabDetails)
+[75.14]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html
+[75.15]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#setWebManifestEnabled(boolean)
+[75.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#webManifest(boolean)
+[75.17]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#getWebManifestEnabled()
+[75.18]: {{javadoc_uri}}/GeckoDisplay.html#safeAreaInsetsChanged(int,int,int,int)
+[75.19]: https://developer.mozilla.org/en-US/docs/Web/CSS/env
+[75.20]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_POSTPONED
+[75.21]: {{javadoc_uri}}/GeckoResult.html#cancel()
+[75.22]: {{javadoc_uri}}/GeckoResult.html#setCancellationDelegate(CancellationDelegate)
+[75.23]: {{javadoc_uri}}/GeckoResult.CancellationDelegate.html
+[75.24]: {{javadoc_uri}}/WebExtension.MetaData.html#baseUrl
+[75.25]: {{javadoc_uri}}/WebExtension.MetaData.html
+[75.26]: {{javadoc_uri}}/WebExtension.MetaData.html#allowedInPrivateBrowsing
+[75.27]: {{javadoc_uri}}/WebExtensionController.html#setAllowedInPrivateBrowsing(org.mozilla.geckoview.WebExtension,boolean)
+
+## v74
+- Added [`WebExtensionController.enable`][74.1] and [`disable`][74.2] to
+ enable and disable extensions.
+ ([bug 1599585]({{bugzilla}}1599585))
+- ⚠️ Added [`GeckoSession.ProgressDelegate.SecurityInformation#certificate`][74.3], which is the
+ full server certificate in use, if any. The other certificate-related fields were removed.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#isSecure`][74.4], which indicates whether or not the response was
+ delivered over a secure connection.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebResponse#certificate`][74.5], which is the server certificate used for the
+ response, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- Added [`WebRequestError#certificate`][74.6], which is the server certificate used in the
+ failed request, if any.
+ ([bug 1508730]({{bugzilla}}1508730))
+- ⚠️ Updated [`ContentBlockingController`][74.7] to use new representation for content blocking
+ exceptions and to add better support for removing exceptions. This deprecates [`ExceptionList`][74.8]
+ and [`restoreExceptionList`][74.9] with the intent to remove them in 76.
+ ([bug 1587552]({{bugzilla}}1587552))
+- Added [`GeckoSession.ContentDelegate.onMetaViewportFitChange`][74.10]. This exposes `viewport-fit` value that is CSS Round Display Level 1. ([bug 1574307]({{bugzilla}}1574307))
+- Extended [`LoginStorage.Delegate`][74.11] with [`onLoginUsed`][74.12] to
+ report when existing login entries are used for autofill.
+ ([bug 1610353]({{bugzilla}}1610353))
+- Added [`WebExtensionController#setTabActive`][74.13], which is used to notify extensions about
+ tab changes
+ ([bug 1597793]({{bugzilla}}1597793))
+- Added [`WebExtension.metaData.optionsUrl`][74.14] and [`WebExtension.metaData.openOptionsPageInTab`][74.15],
+ which is the addon metadata necessary to show their option pages.
+ ([bug 1598792]({{bugzilla}}1598792))
+- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581))
+- ⚠️ Replaced `subscription` argument in [`WebPushDelegate.onSubscriptionChanged`][74.17] from a [`WebPushSubscription`][74.18] to the [`String`][74.19] `scope`.
+
+[74.1]: {{javadoc_uri}}/WebExtensionController.html#enable(org.mozilla.geckoview.WebExtension,int)
+[74.2]: {{javadoc_uri}}/WebExtensionController.html#disable(org.mozilla.geckoview.WebExtension,int)
+[74.3]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html#certificate
+[74.4]: {{javadoc_uri}}/WebResponse.html#isSecure
+[74.5]: {{javadoc_uri}}/WebResponse.html#certificate
+[74.6]: {{javadoc_uri}}/WebRequestError.html#certificate
+[74.7]: {{javadoc_uri}}/ContentBlockingController.html
+[74.8]: {{javadoc_uri}}/ContentBlockingController.ExceptionList.html
+[74.9]: {{javadoc_uri}}/ContentBlockingController.html#restoreExceptionList(org.mozilla.geckoview.ContentBlockingController.ExceptionList)
+[74.10]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onMetaViewportFitChange(org.mozilla.geckoview.GeckoSession,java.lang.String)
+[74.11]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[74.12]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginUsed(org.mozilla.geckoview.LoginStorage.LoginEntry,int)
+[74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive
+[74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl
+[74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab
+[74.16]: {{javadoc_uri}}/WebExtensionController.html#update(org.mozilla.geckoview.WebExtension,int)
+[74.17]: {{javadoc_uri}}/WebPushController.html#onSubscriptionChange(org.mozilla.geckoview.WebPushSubscription,byte[])
+[74.18]: {{javadoc_uri}}/WebPushSubscription.html
+[74.19]: https://developer.android.com/reference/java/lang/String
+
+## v73
+- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to
+ manage installed extensions
+- ⚠️ Renamed `ScreenLength.VIEWPORT_WIDTH`, `ScreenLength.VIEWPORT_HEIGHT`,
+ `ScreenLength.fromViewportWidth` and `ScreenLength.fromViewportHeight` to
+ [`ScreenLength.VISUAL_VIEWPORT_WIDTH`][73.3],
+ [`ScreenLength.VISUAL_VIEWPORT_HEIGHT`][73.4],
+ [`ScreenLength.fromVisualViewportWidth`][73.5] and
+ [`ScreenLength.fromVisualViewportHeight`][73.6] respectively.
+- Added the [`LoginStorage`][73.7] API. Apps may handle login fetch requests now by
+ attaching a [`LoginStorage.Delegate`][73.8] via
+ [`GeckoRuntime#setLoginStorageDelegate`][73.9]
+ ([bug 1602881]({{bugzilla}}1602881))
+- ⚠️ [`WebExtension`][69.5]'s constructor now requires a `WebExtensionController`
+ instance.
+- Added [`GeckoResult.allOf`][73.10] for consuming a list of results.
+- Added [`WebExtensionController.list`][73.11] to list all installed extensions.
+- Added [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and
+ [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13]. These control
+ autoplay permissions for audible and inaudible videos.
+ ([bug 1577596]({{bugzilla}}1577596))
+- Added [`LoginStorage.Delegate.onLoginSave`][73.14] for login storage save
+ requests and [`GeckoSession.PromptDelegate.onLoginStoragePrompt`][73.15] for
+ login storage prompts.
+ ([bug 1599873]({{bugzilla}}1599873))
+
+[73.1]: {{javadoc_uri}}/WebExtensionController.html#install(java.lang.String)
+[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall(org.mozilla.geckoview.WebExtension)
+[73.3]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_WIDTH
+[73.4]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_HEIGHT
+[73.5]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportWidth(double)
+[73.6]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportHeight(double)
+[73.7]: {{javadoc_uri}}/LoginStorage.html
+[73.8]: {{javadoc_uri}}/LoginStorage.Delegate.html
+[73.9]: {{javadoc_uri}}/GeckoRuntime.html#setLoginStorageDelegate(org.mozilla.geckoview.LoginStorage.Delegate)
+[73.10]: {{javadoc_uri}}/GeckoResult.html#allOf(java.util.List)
+[73.11]: {{javadoc_uri}}/WebExtensionController.html#list()
+[73.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE
+[73.13]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE
+[73.14]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginSave(org.mozilla.geckoview.LoginStorage.LoginEntry)
+[73.15]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginStoragePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.LoginStoragePrompt)
+
+## v72
+- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates
+ if a load was requested while a user gesture was active (e.g., a tap).
+ ([bug 1555337]({{bugzilla}}1555337))
+- ⚠️ Refactored `AutofillElement` and `AutofillSupport` into the
+ [`Autofill`][72.2] API.
+ ([bug 1591462]({{bugzilla}}1591462))
+- Make `read()` in the `InputStream` returned from [`WebResponse#body`][72.3] timeout according
+ to [`WebResponse#setReadTimeoutMillis()`][72.4]. The default timeout value is reflected in
+ [`WebResponse#DEFAULT_READ_TIMEOUT_MS`][72.5], currently 30s.
+ ([bug 1595145]({{bugzilla}}1595145))
+- ⚠️ Removed `GeckoResponse`
+ ([bug 1581161]({{bugzilla}}1581161))
+- ⚠️ Removed `actions` and `response` arguments from [`SelectionActionDelegate.onShowActionRequest`][72.6]
+ and [`BasicSelectionActionDelegate.onShowActionRequest`][72.7]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added text selection action methods to [`SelectionActionDelegate.Selection`][72.8]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added [`BasicSelectionActionDelegate.getSelection`][72.9]
+ ([bug 1581161]({{bugzilla}}1581161))
+- Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public.
+ ([bug 1581161]({{bugzilla}}1581161))
+- Added `Autofill` commit support.
+ ([bug 1577005]({{bugzilla}}1577005))
+- Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be
+ backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13].
+ ([bug 1530402]({{bugzilla}}1530402))
+- Added support for Browser and Page Action from the WebExtension API.
+ See [`WebExtension.Action`][72.14].
+ ([bug 1530402]({{bugzilla}}1530402))
+- ⚠️ Split [`ContentBlockingController.Event.LOADED_TRACKING_CONTENT`][72.15] into
+ [`ContentBlockingController.Event.LOADED_LEVEL_1_TRACKING_CONTENT`][72.16] and
+ [`ContentBlockingController.Event.LOADED_LEVEL_2_TRACKING_CONTENT`][72.17].
+- Replaced `subscription` argument in [`WebPushDelegate.onPushEvent`][72.18] from a [`WebPushSubscription`][72.19] to the [`String`][72.20] `scope`.
+- ⚠️ Renamed `WebExtension.ActionIcon` to [`Icon`][72.21].
+- Added [`GeckoWebExecutor#FETCH_FLAGS_STREAM_FAILURE_TEST`][72.22], which is a new
+ flag used to immediately fail when reading a `WebResponse` body.
+ ([bug 1594905]({{bugzilla}}1594905))
+- Changed [`CrashReporter#sendCrashReport(Context, File, JSONObject)`][72.23] to
+ accept a JSON object instead of a Map. Said object also includes the
+ application name that was previously passed as the fourth argument to the
+ method, which was thus removed.
+- Added WebXR device access permission support, [`PERMISSION_PERSISTENT_XR`][72.24].
+ ([bug 1599927]({{bugzilla}}1599927))
+
+[72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture
+[72.2]: {{javadoc_uri}}/Autofill.html
+[72.3]: {{javadoc_uri}}/WebResponse.html#body
+[72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis(long)
+[72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS
+[72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection)
+[72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html
+[72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection
+[72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection
+[72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend(int)
+[72.12]: https://developer.android.com/reference/android/view/TextureView
+[72.13]: https://developer.android.com/reference/android/view/SurfaceView
+[72.14]: {{javadoc_uri}}/WebExtension.Action.html
+[72.15]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_TRACKING_CONTENT
+[72.16]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_1_TRACKING_CONTENT
+[72.17]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_2_TRACKING_CONTENT
+[72.18]: {{javadoc_uri}}/WebPushController.html#onPushEvent(org.mozilla.geckoview.WebPushSubscription,byte[])
+[72.19]: {{javadoc_uri}}/WebPushSubscription.html
+[72.20]: https://developer.android.com/reference/java/lang/String
+[72.21]: {{javadoc_uri}}/WebExtension.Icon.html
+[72.22]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_STREAM_FAILURE_TEST
+[72.23]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,org.json.JSONObject)
+[72.24]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_XR
+
+## v71
+- Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17].
+ ([bug 1584479]({{bugzilla}}1584479))
+- Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2],
+ [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support
+ scalars in streaming telemetry. ⚠️ As part of this change,
+ `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and
+ [`Metric`][71.5] now takes a type parameter.
+ ([bug 1576730]({{bugzilla}}1576730))
+- Added overloads of [`GeckoSession.loadUri`][71.6] that accept a map of
+ additional HTTP request headers.
+ ([bug 1567549]({{bugzilla}}1567549))
+- Added support for exposing the content blocking log in [`ContentBlockingController`][71.7].
+ ([bug 1580201]({{bugzilla}}1580201))
+- ⚠️ Added `nativeApp` to [`WebExtension.MessageDelegate.onMessage`][71.8] which
+ exposes the native application identifier that was used to send the message.
+ ([bug 1546445]({{bugzilla}}1546445))
+- Added [`GeckoRuntime.ServiceWorkerDelegate`][71.9] set via
+ [`setServiceWorkerDelegate`][71.10] to support [`ServiceWorkerClients.openWindow`][71.11]
+ ([bug 1511033]({{bugzilla}}1511033))
+- Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or
+ not `about:config` should be available.
+ ([bug 1540065]({{bugzilla}}1540065))
+- Added [`GeckoSession.ContentDelegate.onFirstContentfulPaint`][71.13]
+ ([bug 1578947]({{bugzilla}}1578947))
+- Added `setEnhancedTrackingProtectionLevel` to [`ContentBlocking.Settings`][71.14].
+ ([bug 1580854]({{bugzilla}}1580854))
+- ⚠️ Added [`GeckoView.onTouchEventForResult`][71.15] and modified
+ [`PanZoomController.onTouchEvent`][71.16] to return how the touch event was handled. This
+ allows apps to know if an event is handled by touch event listeners in web content. The methods in `PanZoomController` now return `int` instead of `boolean`.
+- Added [`GeckoSession.purgeHistory`][71.17] allowing apps to clear a session's history.
+ ([bug 1583265]({{bugzilla}}1583265))
+- Added [`GeckoRuntimeSettings.Builder#forceUserScalableEnabled`][71.18] to control whether or
+ not to force user scalable zooming.
+ ([bug 1540615]({{bugzilla}}1540615))
+- ⚠️ Moved Autofill related methods from `SessionTextInput` and `GeckoSession.TextInputDelegate`
+ into `GeckoSession` and `AutofillDelegate`.
+- Added [`GeckoSession.getAutofillElements()`][71.19], which is a new method for getting
+ an autofill virtual structure without using `ViewStructure`. It relies on a new class,
+ [`AutofillElement`][71.20], for representing the virtual tree.
+- Added [`GeckoView.setAutofillEnabled`][71.21] for controlling whether or not the `GeckoView`
+ instance participates in Android autofill. When enabled, this connects an `AutofillDelegate`
+ to the session it holds.
+- Changed [`AutofillElement.children`][71.20] interface to `Collection` to provide
+ an efficient way to pre-allocate memory when filling `ViewStructure`.
+- Added [`GeckoSession.PromptDelegate.onSharePrompt`][71.22] to support the WebShare API.
+ ([bug 1402369]({{bugzilla}}1402369))
+- Added [`GeckoDisplay.screenshot`][71.23] allowing apps finer grain control over screenshots.
+ ([bug 1577192]({{bugzilla}}1577192))
+- Added `GeckoView.setDynamicToolbarMaxHeight` to make ICB size static, ICB doesn't include the dynamic toolbar region.
+ ([bug 1586144]({{bugzilla}}1586144))
+
+[71.1]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onBooleanScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.2]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onLongScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.3]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onStringScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.4]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onHistogram(org.mozilla.geckoview.RuntimeTelemetry.Metric)
+[71.5]: {{javadoc_uri}}/RuntimeTelemetry.Metric.html
+[71.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,java.io.File,java.util.Map)
+[71.7]: {{javadoc_uri}}/ContentBlockingController.html
+[71.8]: {{javadoc_uri}}/WebExtension.MessageDelegate.html#onMessage(java.lang.String,java.lang.Object,org.mozilla.geckoview.WebExtension.MessageSender)
+[71.9]: {{javadoc_uri}}/GeckoRuntime.ServiceWorkerDelegate.html
+[71.10]: {{javadoc_uri}}/GeckoRuntime#setServiceWorkerDelegate(org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate)
+[71.11]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow
+[71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean)
+[71.13]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession)
+[71.15]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent)
+[71.16]: {{javadoc_uri}}/PanZoomController.html#onTouchEvent(android.view.MotionEvent)
+[71.17]: {{javadoc_uri}}/GeckoSession.html#purgeHistory()
+[71.18]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#forceUserScalableEnabled(boolean)
+[71.19]: {{javadoc_uri}}/GeckoSession.html#getAutofillElements()
+[71.20]: {{javadoc_uri}}/AutofillElement.html
+[71.21]: {{javadoc_uri}}/GeckoView.html#setAutofillEnabled(boolean)
+[71.22]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSharePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt)
+[71.23]: {{javadoc_uri}}/GeckoDisplay.html#screenshot()
+
+## v70
+- Added API for session context assignment
+ [`GeckoSessionSettings.Builder.contextId`][70.1] and deletion of data related
+ to a session context [`StorageController.clearDataForSessionContext`][70.2].
+ ([bug 1501108]({{bugzilla}}1501108))
+- Removed `setSession(session, runtime)` from [`GeckoView`][70.5]. With this
+ change, `GeckoView` will no longer manage opening/closing of the
+ [`GeckoSession`][70.6] and instead leave that up to the app. It's also now
+ allowed to call [`setSession`][70.10] with a closed `GeckoSession`.
+ ([bug 1510314]({{bugzilla}}1510314))
+- Added an overload of [`GeckoSession.loadUri()`][70.8] that accepts a
+ referring [`GeckoSession`][70.6]. This should be used when the URI we're
+ loading originates from another page. A common example of this would be long
+ pressing a link and then opening that in a new `GeckoSession`.
+ ([bug 1561079]({{bugzilla}}1561079))
+- Added capture parameter to [`onFilePrompt`][70.9] and corresponding
+ [`CAPTURE_TYPE_*`][70.7] constants.
+ ([bug 1553603]({{bugzilla}}1553603))
+- Removed the obsolete `success` parameter from
+ [`CrashReporter#sendCrashReport(Context, File, File, String)`][70.3] and
+ [`CrashReporter#sendCrashReport(Context, File, Map, String)`][70.4].
+ ([bug 1570789]({{bugzilla}}1570789))
+- Add `GeckoSession.LOAD_FLAGS_REPLACE_HISTORY`.
+ ([bug 1571088]({{bugzilla}}1571088))
+- Complete rewrite of [`PromptDelegate`][70.11].
+ ([bug 1499394]({{bugzilla}}1499394))
+- Added [`RuntimeTelemetry.Delegate`][70.12] that receives streaming telemetry
+ data from GeckoView.
+ ([bug 1566367]({{bugzilla}}1566367))
+- Updated [`ContentBlocking`][70.13] to better report blocked and allowed ETP events.
+ ([bug 1567268]({{bugzilla}}1567268))
+- Added API for controlling Gecko logging [`GeckoRuntimeSettings.debugLogging`][70.14]
+ ([bug 1573304]({{bugzilla}}1573304))
+- Added [`WebNotification`][70.15] and [`WebNotificationDelegate`][70.16] for handling Web Notifications.
+ ([bug 1533057]({{bugzilla}}1533057))
+- Added Social Tracking Protection support to [`ContentBlocking`][70.17].
+ ([bug 1568295]({{bugzilla}}1568295))
+- Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.create`][70.20] calls by WebExtensions.
+ ([bug 1539144]({{bugzilla}}1539144))
+- Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle
+ [`browser.tabs.remove`][70.22] calls by WebExtensions.
+ ([bug 1565782]({{bugzilla}}1565782))
+- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts.
+ ([bug 1621094]({{bugzilla}}1621094))
+- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and
+ [`WebPushSubscription`][70.26].
+- Added [`ContentBlockingController`][70.27], accessible via [`GeckoRuntime.getContentBlockingController`][70.28]
+ to allow modification and inspection of a content blocking exception list.
+
+[70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId(java.lang.String)
+[70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext(java.lang.String)
+[70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.io.File,java.lang.String)
+[70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.util.Map,java.lang.String)
+[70.5]: {{javadoc_uri}}/GeckoView.html
+[70.6]: {{javadoc_uri}}/GeckoSession.html
+[70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE
+[70.8]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int)
+[70.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onFilePrompt(org.mozilla.geckoview.GeckoSession,java.lang.String,int,java.lang.String[],int,org.mozilla.geckoview.GeckoSession.PromptDelegate.FileCallback)
+[70.10]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+[70.11]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html
+[70.12]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html
+[70.13]: {{javadoc_uri}}/ContentBlocking.html
+[70.14]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#debugLogging(boolean)
+[70.15]: {{javadoc_uri}}/WebNotification.html
+[70.16]: {{javadoc_uri}}/WebNotificationDelegate.html
+[70.17]: {{javadoc_uri}}/ContentBlocking.html
+[70.18]: {{javadoc_uri}}/WebExtensionController.html
+[70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html
+[70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create
+[70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession)
+[70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove
+[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[70.24]: {{javadoc_uri}}/WebPushController.html
+[70.25]: {{javadoc_uri}}/WebPushDelegate.html
+[70.26]: {{javadoc_uri}}/WebPushSubscription.html
+[70.27]: {{javadoc_uri}}/ContentBlockingController.html
+[70.28]: {{javadoc_uri}}/GeckoRuntime.html#getContentBlockingController()
+
+## v69
+- Modified behavior of [`setAutomaticFontSizeAdjustment`][69.1] so that it no
+ longer has any effect on [`setFontInflationEnabled`][69.2]
+- Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14]
+- Added [`GeckoResult.accept`][69.3] for consuming a result without
+ transforming it.
+- [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the
+ [`WebExtension`][69.5] that the [`MessageDelegate`][69.4] will receive
+ messages from.
+- Created [`onKill`][69.7] to [`ContentDelegate`][69.11] to differentiate from crashes.
+
+[69.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[69.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontInflationEnabled(boolean)
+[69.3]: {{javadoc_uri}}/GeckoResult.html#accept(org.mozilla.geckoview.GeckoResult.Consumer)
+[69.4]: {{javadoc_uri}}/WebExtension.MessageDelegate.html
+[69.5]: {{javadoc_uri}}/WebExtension.html
+[69.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onKill(org.mozilla.geckoview.GeckoSession)
+[69.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html
+[69.13]: {{javadoc_uri}}/GeckoSession.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[69.14]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_FORCE_ALLOW_DATA_URI
+
+## v68
+- Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device
+ configuration has changed.
+- Added [`onSessionStateChange`][68.29] to [`ProgressDelegate`][68.2] and removed `saveState`.
+- Added [`ContentBlocking#AT_CRYPTOMINING`][68.3] for cryptocurrency miner blocking.
+- Added [`ContentBlocking#AT_DEFAULT`][68.4], [`ContentBlocking#AT_STRICT`][68.5],
+ [`ContentBlocking#CB_DEFAULT`][68.6] and [`ContentBlocking#CB_STRICT`][68.7]
+ for clearer app default selections.
+- Added [`GeckoSession.SessionState.fromString`][68.8]. This can be used to
+ deserialize a `GeckoSession.SessionState` instance previously serialized to
+ a `String` via `GeckoSession.SessionState.toString`.
+- Added [`GeckoRuntimeSettings#setPreferredColorScheme`][68.9] to override
+ the default color theme for web content ("light" or "dark").
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all fields.
+- [`RuntimeTelemetry#getSnapshots`][68.10] returns a [`JSONObject`][68.30] now.
+- Removed all `org.mozilla.gecko` references in the API.
+- Added [`ContentBlocking#AT_FINGERPRINTING`][68.11] to block fingerprinting trackers.
+- Added [`HistoryItem`][68.31] and [`HistoryList`][68.32] interfaces and [`onHistoryStateChange`][68.34] to
+ [`HistoryDelegate`][68.12] and added [`gotoHistoryIndex`][68.33] to [`GeckoSession`][68.13].
+- [`GeckoView`][70.5] will not create a [`GeckoSession`][65.9] anymore when
+ attached to a window without a session.
+- Added [`GeckoRuntimeSettings.Builder#configFilePath`][68.16] to set
+ a path to a configuration file from which GeckoView will read
+ configuration options such as Gecko process arguments, environment
+ variables, and preferences.
+- Added [`unregisterWebExtension`][68.17] to unregister a web extension.
+- Added messaging support for WebExtension. [`setMessageDelegate`][68.18]
+ allows embedders to listen to messages coming from a WebExtension.
+ [`Port`][68.19] allows bidirectional communication between the embedder and
+ the WebExtension.
+- Expose the following prefs in [`GeckoRuntimeSettings`][67.3]:
+ [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21],
+ [`setGlMsaaLevel`][68.22].
+- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.35]
+- Added `setVerticalClipping` to [`GeckoDisplay`][68.24] and
+ [`GeckoView`][68.23] to tell Gecko how much of its vertical space is clipped.
+- Added [`StorageController`][68.25] API for clearing data.
+- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices.
+- Removed redundant constants in [`MediaSource`][68.28]
+
+[68.1]: {{javadoc_uri}}/GeckoRuntime.html#configurationChanged(android.content.res.Configuration)
+[68.2]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html
+[68.3]: {{javadoc_uri}}/ContentBlocking.html#AT_CRYPTOMINING
+[68.4]: {{javadoc_uri}}/ContentBlocking.html#AT_DEFAULT
+[68.5]: {{javadoc_uri}}/ContentBlocking.html#AT_STRICT
+[68.6]: {{javadoc_uri}}/ContentBlocking.html#CB_DEFAULT
+[68.7]: {{javadoc_uri}}/ContentBlocking.html#CB_STRICT
+[68.8]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String)
+[68.9]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setPreferredColorScheme(int)
+[68.10]: {{javadoc_uri}}/RuntimeTelemetry.html#getSnapshots(boolean)
+[68.11]: {{javadoc_uri}}/ContentBlocking.html#AT_FINGERPRINTING
+[68.12]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[68.13]: {{javadoc_uri}}/GeckoSession.html
+[68.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String)
+[68.17]: {{javadoc_uri}}/GeckoRuntime.html#unregisterWebExtension(org.mozilla.geckoview.WebExtension)
+[68.18]: {{javadoc_uri}}/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)
+[68.19]: {{javadoc_uri}}/WebExtension.Port.html
+[68.20]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoZoomEnabled(boolean)
+[68.21]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled(boolean)
+[68.22]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setGlMsaaLevel(int)
+[68.23]: {{javadoc_uri}}/GeckoView.html#setVerticalClipping(int)
+[68.24]: {{javadoc_uri}}/GeckoDisplay.html#setVerticalClipping(int)
+[68.25]: {{javadoc_uri}}/StorageController.html
+[68.26]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html#onRecordingStatusChanged(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice[])
+[68.27]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[68.28]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html
+[68.29]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html#onSessionStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SessionState)
+[68.30]: https://developer.android.com/reference/org/json/JSONObject
+[68.31]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryItem.html
+[68.32]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryList.html
+[68.33]: {{javadoc_uri}}/GeckoSession.html#gotoHistoryIndex(int)
+[68.34]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html#onHistoryStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.HistoryDelegate.HistoryList)
+[68.35]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE
+
+## v67
+- Added [`setAutomaticFontSizeAdjustment`][67.23] to
+ [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings
+ depending on the OS-level font size setting.
+- Added [`setFontSizeFactor`][67.4] to [`GeckoRuntimeSettings`][67.3] for
+ setting a font size scaling factor, and for enabling font inflation for
+ non-mobile-friendly pages.
+- Updated video autoplay API to reflect changes in Gecko. Instead of being a
+ per-video permission in the [`PermissionDelegate`][67.5], it is a [runtime
+ setting][67.6] that either allows or blocks autoplay videos.
+- Change [`ContentBlocking.AT_AD`][67.7] and [`ContentBlocking.SB_ALL`][67.8]
+ values to mirror the actual constants they encompass.
+- Added nested [`ContentBlocking`][67.9] runtime settings.
+- Added [`RuntimeSettings`][67.10] base class to support nested settings.
+- Added [`baseUri`][67.11] to [`ContentDelegate.ContextElement`][65.21] and
+ changed [`linkUri`][67.12] to absolute form.
+- Added [`scrollBy`][67.13] and [`scrollTo`][67.14] to [`PanZoomController`][65.4].
+- Added [`GeckoSession.getDefaultUserAgent`][67.1] to expose the build-time
+ default user agent synchronously.
+- Changed [`WebResponse.body`][67.24] from a [`ByteBuffer`][67.25] to an [`InputStream`][67.26]. Apps that want access
+ to the entire response body will now need to read the stream themselves.
+- Added [`GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`][67.27], which will cause [`GeckoWebExecutor.fetch()`][67.28] to not
+ automatically follow [HTTP redirects][67.29] (e.g., 302).
+- Moved [`GeckoVRManager`][67.2] into the org.mozilla.geckoview package.
+- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15]
+ allows embedders to register a local web extension.
+- Added API to [`GeckoView`][70.5] to take screenshot of the visible page. Calling [`capturePixels`][67.16] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.17] of the current [`Surface`][67.18] contents, or an [`IllegalStateException`][67.19] if the [`GeckoSession`][65.9] is not ready to render content.
+- Added API to capture a screenshot to [`GeckoDisplay`][67.20]. [`capturePixels`][67.21] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.16] of the current [`Surface`][67.17] contents, or an [`IllegalStateException`][67.18] if the [`GeckoSession`][65.9] is not ready to render content.
+- Add missing [`@Nullable`][66.2] annotation to return value for
+ [`GeckoSession.PromptDelegate.ChoiceCallback.onPopupResult()`][67.30]
+- Added `default` implementations for all non-functional `interface`s.
+- Added [`ContentDelegate.onWebAppManifest`][67.22], which will deliver the contents of a parsed
+ and validated Web App Manifest on pages that contain one.
+
+[67.1]: {{javadoc_uri}}/GeckoSession.html#getDefaultUserAgent()
+[67.2]: {{javadoc_uri}}/GeckoVRManager.html
+[67.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html
+[67.4]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontSizeFactor(float)
+[67.5]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html
+[67.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoplayDefault(int)
+[67.7]: {{javadoc_uri}}/ContentBlocking.html#AT_AD
+[67.8]: {{javadoc_uri}}/ContentBlocking.html#SB_ALL
+[67.9]: {{javadoc_uri}}/ContentBlocking.html
+[67.10]: {{javadoc_uri}}/RuntimeSettings.html
+[67.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#baseUri
+[67.12]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#linkUri
+[67.13]: {{javadoc_uri}}/PanZoomController.html#scrollBy(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.14]: {{javadoc_uri}}/PanZoomController.html#scrollTo(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength)
+[67.15]: {{javadoc_uri}}/GeckoRuntime.html#registerWebExtension(org.mozilla.geckoview.WebExtension)
+[67.16]: {{javadoc_uri}}/GeckoView.html#capturePixels()
+[67.17]: https://developer.android.com/reference/android/graphics/Bitmap
+[67.18]: https://developer.android.com/reference/android/view/Surface
+[67.19]: https://developer.android.com/reference/java/lang/IllegalStateException
+[67.20]: {{javadoc_uri}}/GeckoDisplay.html
+[67.21]: {{javadoc_uri}}/GeckoDisplay.html#capturePixels()
+[67.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onWebAppManifest(org.mozilla.geckoview.GeckoSession,org.json.JSONObject)
+[67.23]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean)
+[67.24]: {{javadoc_uri}}/WebResponse.html#body
+[67.25]: https://developer.android.com/reference/java/nio/ByteBuffer
+[67.26]: https://developer.android.com/reference/java/io/InputStream
+[67.27]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_NO_REDIRECTS
+[67.28]: {{javadoc_uri}}/GeckoWebExecutor.html#fetch(org.mozilla.geckoview.WebRequest,int)
+[67.29]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections
+[67.30]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ChoiceCallback.html
+
+## v66
+- Removed redundant field `trackingMode` from [`SecurityInformation`][66.6].
+ Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked
+ elements during page load.
+- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs.
+- Added methods for each setting in [`GeckoSessionSettings`][66.3]
+- Added [`GeckoSessionSettings`][66.4] for enabling desktop viewport. Desktop
+ viewport is no longer set by [`USER_AGENT_MODE_DESKTOP`][66.5] and must be set
+ separately.
+- Added [`@UiThread`][65.6] to [`GeckoSession.releaseSession`][66.7] and
+ [`GeckoSession.setSession`][66.8]
+
+[66.1]: https://developer.android.com/reference/android/support/annotation/NonNull
+[66.2]: https://developer.android.com/reference/android/support/annotation/Nullable
+[66.3]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.4]: {{javadoc_uri}}/GeckoSessionSettings.html
+[66.5]: {{javadoc_uri}}/GeckoSessionSettings.html#USER_AGENT_MODE_DESKTOP
+[66.6]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html
+[66.7]: {{javadoc_uri}}/GeckoView.html#releaseSession()
+[66.8]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession)
+
+## v65
+- Added experimental ad-blocking category to `GeckoSession.TrackingProtectionDelegate`.
+- Moved [`CompositorController`][65.1], [`DynamicToolbarAnimator`][65.2],
+ [`OverscrollEdgeEffect`][65.3], [`PanZoomController`][65.4] from
+ `org.mozilla.gecko.gfx` to [`org.mozilla.geckoview`][65.5]
+- Added [`@UiThread`][65.6], [`@AnyThread`][65.7] annotations to all APIs
+- Changed `GeckoRuntimeSettings#getLocale` to [`getLocales`][65.8] and related
+ APIs.
+- Merged `org.mozilla.gecko.gfx.LayerSession` into [`GeckoSession`][65.9]
+- Added [`GeckoSession.MediaDelegate`][65.10] and [`MediaElement`][65.11]. This
+ allow monitoring and control of web media elements (play, pause, seek, etc).
+- Removed unused `access` parameter from
+ [`GeckoSession.PermissionDelegate#onContentPermissionRequest`][65.12]
+- Added [`WebMessage`][65.13], [`WebRequest`][65.14], [`WebResponse`][65.15],
+ and [`GeckoWebExecutor`][65.16]. This exposes Gecko networking to apps. It
+ includes speculative connections, name resolution, and a Fetch-like HTTP API.
+- Added [`GeckoSession.HistoryDelegate`][65.17]. This allows apps to implement
+ their own history storage system and provide visited link status.
+- Added [`ContentDelegate#onFirstComposite`][65.18] to get first composite
+ callback after a compositor start.
+- Changed `LoadRequest.isUserTriggered` to [`isRedirect`][65.19].
+- Added [`GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER`][65.20] to bypass the URI
+ classifier.
+- Added a `protected` empty constructor to all field-only classes so that apps
+ can mock these classes in tests.
+- Added [`ContentDelegate.ContextElement`][65.21] to extend the information
+ passed to [`ContentDelegate#onContextMenu`][65.22]. Extended information
+ includes the element's title and alt attributes.
+- Changed [`ContentDelegate.ContextElement`][65.21] `TYPE_` constants to public
+ access.
+- Changed [`ContentDelegate.ContextElement`][65.21],
+ [`GeckoSession.FinderResult`][65.23] to non-final class.
+- Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a
+ [`GeckoResult<String>`][65.25].
+
+[65.1]: {{javadoc_uri}}/CompositorController.html
+[65.2]: {{javadoc_uri}}/DynamicToolbarAnimator.html
+[65.3]: {{javadoc_uri}}/OverscrollEdgeEffect.html
+[65.4]: {{javadoc_uri}}/PanZoomController.html
+[65.5]: {{javadoc_uri}}/package-summary.html
+[65.6]: https://developer.android.com/reference/android/support/annotation/UiThread
+[65.7]: https://developer.android.com/reference/android/support/annotation/AnyThread
+[65.8]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getLocales()
+[65.9]: {{javadoc_uri}}/GeckoSession.html
+[65.10]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html
+[65.11]: {{javadoc_uri}}/MediaElement.html
+[65.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,java.lang.String,int,org.mozilla.geckoview.GeckoSession.PermissionDelegate.Callback)
+[65.13]: {{javadoc_uri}}/WebMessage.html
+[65.14]: {{javadoc_uri}}/WebRequest.html
+[65.15]: {{javadoc_uri}}/WebResponse.html
+[65.16]: {{javadoc_uri}}/GeckoWebExecutor.html
+[65.17]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html
+[65.18]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstComposite(org.mozilla.geckoview.GeckoSession)
+[65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect
+[65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER
+[65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html
+[65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu(org.mozilla.geckoview.GeckoSession,int,int,org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement)
+[65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html
+[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,android.os.Bundle,java.lang.String)
+[65.25]: {{javadoc_uri}}/GeckoResult.html
+
+[api-version]: 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.
+ *
+ * <ul>
+ * <li>{@link org.mozilla.geckoview.GeckoRuntime} is the entry point for starting and initializing
+ * Gecko. You can use this to preload Gecko before you need to load a page or to configure
+ * features such as crash reporting.
+ * <li>{@link org.mozilla.geckoview.GeckoSession} is where most interesting work happens, such as
+ * loading pages. It relies on {@link org.mozilla.geckoview.GeckoRuntime} to talk to Gecko.
+ * <li>{@link org.mozilla.geckoview.GeckoView} is the embeddable {@link android.view.View}. This
+ * is the most common way of getting a {@link org.mozilla.geckoview.GeckoSession} onto the
+ * screen.
+ * </ul>
+ *
+ * <p><strong>Permissions</strong>
+ *
+ * <p>This library does not request any dangerous permissions in the manifest, though it's possible
+ * that some web features may require them. For instance, WebRTC video calls would need the {@link
+ * android.Manifest.permission#CAMERA} and {@link android.Manifest.permission#RECORD_AUDIO}
+ * permissions. Declaring these are at the application's discretion. If you want full web
+ * functionality, the following permissions should be declared:
+ *
+ * <ul>
+ * <li>{@link android.Manifest.permission#ACCESS_COARSE_LOCATION}
+ * <li>{@link android.Manifest.permission#ACCESS_FINE_LOCATION}
+ * <li>{@link android.Manifest.permission#READ_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE}
+ * <li>{@link android.Manifest.permission#CAMERA}
+ * <li>{@link android.Manifest.permission#RECORD_AUDIO}
+ * </ul>
+ *
+ * For a detailed change log of the API see: <a href="./doc-files/CHANGELOG"
+ * target="_blank">CHANGELOG</a>.
+ */
+package org.mozilla.geckoview;
diff --git a/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml b/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml
new file mode 100644
index 0000000000..29b19541b2
--- /dev/null
+++ b/mobile/android/geckoview/src/main/res/drawable/ic_generic_file.xml
@@ -0,0 +1,11 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<vector android:height="24dp" android:viewportHeight="40"
+ android:viewportWidth="40" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path android:fillColor="#FFFFFF" android:pathData="M6.5,37.5l0,-35l18.293,0l8.707,8.707l0,26.293z"/>
+ <path android:fillColor="#788B9C" android:pathData="M24.586,3L33,11.414V37H7V3H24.586M25,2H6v36h28V11L25,2L25,2z"/>
+ <path android:fillColor="#FFFFFF" android:pathData="M24.5,11.5l0,-9l0.293,0l8.707,8.707l0,0.293z"/>
+ <path android:fillColor="#788B9C" android:pathData="M25,3.414L32.586,11H25V3.414M25,2h-1v10h10v-1L25,2L25,2z"/>
+</vector>
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<String> 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));
+ }
+}